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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5da472d6ff
commit
6710c8b231
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="container max-w-2xl py-8 space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Household Settings</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Members</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{members.map(({ user, role: memberRole }) => (
|
||||
<li key={user.id} className="flex items-center justify-between">
|
||||
<span>{user.name ?? user.email}</span>
|
||||
<span className="text-sm text-muted-foreground capitalize">
|
||||
{memberRole}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{role === "owner" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rename Household</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={renameHousehold} className="flex gap-2">
|
||||
<Input name="name" defaultValue={household.name} required />
|
||||
<Button type="submit">Save</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CurrentSession> {
|
||||
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;
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
"*.mts",
|
||||
"*.ts",
|
||||
"src/**/*",
|
||||
"scripts/**/*",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
Reference in New Issue
Block a user