From 6710c8b231a7db9e12fe6363ad66dc6c6c896162 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 6 May 2026 02:42:48 -0500 Subject: [PATCH] Add household seeding & session (task 07) - scripts/seed.ts: idempotent pnpm db:seed inserts "Home" household if none exists (powered by tsx) - signIn callback: first member of household gets owner role, subsequent users get member - src/lib/session.ts: getCurrentSession() returns { user, household, role }, throws if unauthenticated or unmembered - /settings/household: member list for all roles; rename form gated to owner only (server action also enforces) Co-Authored-By: Claude Sonnet 4.6 --- STATUS.md | 4 +- package.json | 2 + pnpm-lock.yaml | 3 ++ scripts/seed.ts | 22 ++++++++++ src/app/settings/household/actions.ts | 22 ++++++++++ src/app/settings/household/page.tsx | 61 +++++++++++++++++++++++++++ src/lib/auth.ts | 10 +++-- src/lib/session.ts | 31 ++++++++++++++ tsconfig.json | 1 + 9 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 scripts/seed.ts create mode 100644 src/app/settings/household/actions.ts create mode 100644 src/app/settings/household/page.tsx create mode 100644 src/lib/session.ts diff --git a/STATUS.md b/STATUS.md index 4564da6..fd29df2 100644 --- a/STATUS.md +++ b/STATUS.md @@ -12,9 +12,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is - **05 — Compose + Caddy**. `Dockerfile` (3-stage: deps/builder/runner, pnpm fetch + offline install, non-root `nextjs` user, `output: standalone`), `deploy/compose.yaml` (famapp + famapp-db + full Authentik stack on `famapp_net`), `deploy/Caddyfile.snippet`, `.env.production.example`. `docker build -t famapp .` succeeds (~311 MB); `docker compose -f deploy/compose.yaml config` validates clean. Added `.dockerignore` and `public/.gitkeep`. - **06 — Authentik OIDC**. `next-auth@beta` + `@auth/drizzle-adapter` wired up. `src/lib/auth.ts` configures OIDC provider (Authentik), database sessions, `authorized` callback guarding all routes except `/login`, `/s/*`, `/api/auth/*`. `src/middleware.ts` exports `auth` as middleware. `src/app/api/auth/[...nextauth]/route.ts` mounts the handlers. `src/app/login/page.tsx` has a single "Sign in with SSO" server action. `getCurrentUser()` available for server components/actions. Schema extended: `users` → added `name`/`emailVerified`/`image`; new `accounts`, `sessions`, `verificationTokens` tables; migration `0001_auth_tables.sql` generated. `deploy/authentik/README.md` documents the manual Authentik bootstrap. `tsc --noEmit` and `pnpm lint` pass clean. +- **07 — Household seed**. `pnpm db:seed` inserts household "Home" idempotently (powered by `tsx`). `signIn` callback assigns `owner` to the first member of the household and `member` to all subsequent users. `getCurrentSession()` in `src/lib/session.ts` returns `{ user, household, role }` and throws if unauthenticated or unmembered. `/settings/household` renders member list for all roles and a rename form for owner only. `tsc --noEmit` and `pnpm lint` both clean. + ## Next up -- **07 — Household seed** ([brief](docs/tasks/07-household-seed.md)). +- **08 — Theming infrastructure** ([brief](docs/tasks/08-theming.md)). ## Phase 1 remaining diff --git a/package.json b/package.json index 62625ac..de56733 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "typecheck": "tsc --noEmit", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", + "db:seed": "tsx --env-file=.env scripts/seed.ts", "db:studio": "drizzle-kit studio" }, "devDependencies": { @@ -34,6 +35,7 @@ "prettier": "^3.3.3", "tailwindcss": "^4.2.4", "typescript": "^5.6.3", + "tsx": "^4.19.4", "typescript-eslint": "^8.15.0" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 472e28a..139ee0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: tailwindcss: specifier: ^4.2.4 version: 4.2.4 + tsx: + specifier: ^4.19.4 + version: 4.21.0 typescript: specifier: ^5.6.3 version: 5.9.3 diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..26b7289 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,22 @@ +import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { households } from "@/modules/_core/schema"; + +const client = postgres(process.env["DATABASE_URL"]!); +const db = drizzle(client); + +async function seed() { + const [existing] = await db.select().from(households).limit(1); + if (existing) { + console.log(`Household already exists ("${existing.name}"), skipping.`); + } else { + await db.insert(households).values({ name: "Home" }); + console.log('Seeded household "Home".'); + } + await client.end(); +} + +seed().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/settings/household/actions.ts b/src/app/settings/household/actions.ts new file mode 100644 index 0000000..ac2f0f2 --- /dev/null +++ b/src/app/settings/household/actions.ts @@ -0,0 +1,22 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { households } from "@/modules/_core/schema"; + +export async function renameHousehold(formData: FormData) { + const { household, role } = await getCurrentSession(); + if (role !== "owner") throw new Error("Forbidden"); + + const name = formData.get("name"); + if (typeof name !== "string" || !name.trim()) throw new Error("Invalid name"); + + await db + .update(households) + .set({ name: name.trim() }) + .where(eq(households.id, household.id)); + + revalidatePath("/settings/household"); +} diff --git a/src/app/settings/household/page.tsx b/src/app/settings/household/page.tsx new file mode 100644 index 0000000..726bb78 --- /dev/null +++ b/src/app/settings/household/page.tsx @@ -0,0 +1,61 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { householdMembers, users } from "@/modules/_core/schema"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { renameHousehold } from "./actions"; + +export default async function HouseholdSettingsPage() { + const { household, role } = await getCurrentSession(); + + const members = await db + .select({ user: users, role: householdMembers.role }) + .from(householdMembers) + .innerJoin(users, eq(householdMembers.userId, users.id)) + .where(eq(householdMembers.householdId, household.id)); + + return ( +
+

Household Settings

+ + + + Members + + +
    + {members.map(({ user, role: memberRole }) => ( +
  • + {user.name ?? user.email} + + {memberRole} + +
  • + ))} +
+
+
+ + {role === "owner" && ( + + + Rename Household + + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 215a2a7..d6e4555 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -41,8 +41,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ callbacks: { async signIn({ user }) { if (!user.id) return true; - // Attach user to the single household if not already a member. - // (Household is seeded by task 07; this is a no-op until then.) const existing = await db .select() .from(householdMembers) @@ -51,9 +49,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (existing.length === 0) { const [household] = await db.select().from(households).limit(1); if (household) { + const [anyMember] = await db + .select() + .from(householdMembers) + .where(eq(householdMembers.householdId, household.id)) + .limit(1); + const role = anyMember ? "member" : "owner"; await db .insert(householdMembers) - .values({ householdId: household.id, userId: user.id }) + .values({ householdId: household.id, userId: user.id, role }) .onConflictDoNothing(); } } diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..0cbb6ab --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { householdMembers, households, users } from "@/modules/_core/schema"; + +export interface CurrentSession { + user: typeof users.$inferSelect; + household: typeof households.$inferSelect; + role: "owner" | "member"; +} + +export async function getCurrentSession(): Promise { + const session = await auth(); + if (!session?.user?.id) throw new Error("Not authenticated"); + + const [row] = await db + .select({ + user: users, + household: households, + role: householdMembers.role, + }) + .from(householdMembers) + .innerJoin(users, eq(householdMembers.userId, users.id)) + .innerJoin(households, eq(householdMembers.householdId, households.id)) + .where(eq(householdMembers.userId, session.user.id)) + .limit(1); + + if (!row) throw new Error("No household membership"); + + return row; +} diff --git a/tsconfig.json b/tsconfig.json index cd992a3..4b66f33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,6 +40,7 @@ "*.mts", "*.ts", "src/**/*", + "scripts/**/*", ".next/types/**/*.ts" ], "exclude": [