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 ( +