Add Authentik OIDC integration (task 06)
- next-auth@beta + @auth/drizzle-adapter wired up with database sessions - src/lib/auth.ts: OIDC provider, authorized callback, signIn household-attach, getCurrentUser() - src/middleware.ts: protects all routes except /login, /s/*, /api/auth/* - src/app/api/auth/[...nextauth]/route.ts: mounts Auth.js handlers - src/app/login/page.tsx: single SSO sign-in button (server action) - Schema: users extended (name/emailVerified/image), accounts/sessions/verificationTokens added - drizzle/0001_auth_tables.sql: migration for schema changes - deploy/authentik/README.md: manual bootstrap steps for Authentik - src/lib/db.ts: pass schema to drizzle for relational query builder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f59753404e
commit
5da472d6ff
@@ -10,10 +10,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is
|
|||||||
- **04 — Module loader & registry**. `src/modules/_core/module.ts` types (`ModuleManifest`, `EntityTypeRegistration`, `DashboardWidget`, etc.), `registry.ts` singleton with `registerModule`/`getRegistry`/`getEntityType`/`getWidget`, barrel `_core/index.ts`. Stub manifests for `calendar`, `lists`, `notes`. `src/modules/index.ts` loader. Root layout imports loader; `AppNav` reads registry for nav links. `/debug/registry` dumps full registry JSON (dev only). Uses zod v4 + built-in `z.toJSONSchema()`. `tsc --noEmit`, `pnpm build`, `pnpm lint` all clean.
|
- **04 — Module loader & registry**. `src/modules/_core/module.ts` types (`ModuleManifest`, `EntityTypeRegistration`, `DashboardWidget`, etc.), `registry.ts` singleton with `registerModule`/`getRegistry`/`getEntityType`/`getWidget`, barrel `_core/index.ts`. Stub manifests for `calendar`, `lists`, `notes`. `src/modules/index.ts` loader. Root layout imports loader; `AppNav` reads registry for nav links. `/debug/registry` dumps full registry JSON (dev only). Uses zod v4 + built-in `z.toJSONSchema()`. `tsc --noEmit`, `pnpm build`, `pnpm lint` all clean.
|
||||||
|
|
||||||
- **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`.
|
- **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.
|
||||||
|
|
||||||
## Next up
|
## Next up
|
||||||
|
|
||||||
- **06 — Authentik OIDC** ([brief](docs/tasks/06-authentik-oidc.md)).
|
- **07 — Household seed** ([brief](docs/tasks/07-household-seed.md)).
|
||||||
|
|
||||||
## Phase 1 remaining
|
## Phase 1 remaining
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Authentik — manual bootstrap
|
||||||
|
|
||||||
|
Run these steps once after the first `docker compose up -d` in the `deploy/` directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Set the admin password
|
||||||
|
|
||||||
|
Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin** password.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Create the OIDC provider
|
||||||
|
|
||||||
|
1. Log in to the Authentik Admin UI at `https://auth.ginnoir.com/if/admin/`.
|
||||||
|
2. Go to **Applications → Providers → Create**.
|
||||||
|
3. Choose **OAuth2/OpenID Provider**.
|
||||||
|
4. Configure:
|
||||||
|
- **Name:** `famapp`
|
||||||
|
- **Authorization flow:** `default-provider-authorization-explicit-consent`
|
||||||
|
- **Client type:** `Confidential`
|
||||||
|
- **Client ID:** (auto-generated — copy this)
|
||||||
|
- **Client Secret:** (auto-generated — copy this)
|
||||||
|
- **Redirect URIs:** `https://fam.ginnoir.com/api/auth/callback/authentik`
|
||||||
|
- **Signing Key:** `authentik Self-signed Certificate`
|
||||||
|
- **Token validity:** 24 hours (or your preference)
|
||||||
|
5. Save and note the **Issuer URL** shown on the provider detail page.
|
||||||
|
|
||||||
|
The issuer URL will look like:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://auth.ginnoir.com/application/o/famapp/
|
||||||
|
```
|
||||||
|
|
||||||
|
Set this (and the client ID/secret) in famapp's `.env` / production secrets:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
||||||
|
AUTH_OIDC_CLIENT_ID=<client-id>
|
||||||
|
AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Create the Application
|
||||||
|
|
||||||
|
1. Go to **Applications → Applications → Create**.
|
||||||
|
2. Configure:
|
||||||
|
- **Name:** `famapp`
|
||||||
|
- **Slug:** `famapp`
|
||||||
|
- **Provider:** select the `famapp` provider created above
|
||||||
|
- **Launch URL:** `https://fam.ginnoir.com`
|
||||||
|
3. Save.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Create user accounts
|
||||||
|
|
||||||
|
1. Go to **Directory → Users → Create**.
|
||||||
|
2. Create accounts for Matt and wife. Recommended fields:
|
||||||
|
- **Username / Email:** use real email addresses (famapp uses email as the identity key)
|
||||||
|
- **Name:** display name shown in the app
|
||||||
|
3. Optionally invite them via email to set their own passwords.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Set up passkeys (optional but recommended)
|
||||||
|
|
||||||
|
Each user can enroll a passkey from their Authentik profile:
|
||||||
|
|
||||||
|
1. Sign in as the user at `https://auth.ginnoir.com`.
|
||||||
|
2. Go to **Settings → MFA Devices → Add → WebAuthn Device**.
|
||||||
|
3. Follow the browser prompt to register a Touch ID / Face ID / hardware key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Generate AUTH_SECRET
|
||||||
|
|
||||||
|
Run this locally and put the output in your `.env` / production secrets:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The Authentik image in `compose.yaml` is currently pinned to `latest`. Before production, pin to a specific version tag (e.g. `ghcr.io/goauthentik/server:2024.12.3`).
|
||||||
|
- famapp uses **database sessions** (Auth.js). Sessions are stored in the `sessions` table and expire according to Auth.js defaults (30 days).
|
||||||
|
- The forward-auth / Outpost wiring for other services (Sonarr, Radarr, etc.) is a separate future task.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
ALTER TABLE "users" RENAME COLUMN "display_name" TO "name";--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "name" TYPE text USING "name"::text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "name" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "email_verified" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "image" text;--> statement-breakpoint
|
||||||
|
CREATE TABLE "accounts" (
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"provider_account_id" text NOT NULL,
|
||||||
|
"refresh_token" text,
|
||||||
|
"access_token" text,
|
||||||
|
"expires_at" integer,
|
||||||
|
"token_type" text,
|
||||||
|
"scope" text,
|
||||||
|
"id_token" text,
|
||||||
|
"session_state" text,
|
||||||
|
CONSTRAINT "accounts_provider_provider_account_id_pk" PRIMARY KEY("provider","provider_account_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"session_token" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"expires" timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification_tokens" (
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"expires" timestamp with time zone NOT NULL,
|
||||||
|
CONSTRAINT "verification_tokens_identifier_token_pk" PRIMARY KEY("identifier","token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -37,12 +37,14 @@
|
|||||||
"typescript-eslint": "^8.15.0"
|
"typescript-eslint": "^8.15.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/drizzle-adapter": "^1.11.2",
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
|
"next-auth": "5.0.0-beta.31",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
|||||||
Generated
+85
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@auth/drizzle-adapter':
|
||||||
|
specifier: ^1.11.2
|
||||||
|
version: 1.11.2
|
||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.4.1
|
specifier: ^1.4.1
|
||||||
version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
@@ -26,6 +29,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: ^15.5.15
|
specifier: ^15.5.15
|
||||||
version: 15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
next-auth:
|
||||||
|
specifier: 5.0.0-beta.31
|
||||||
|
version: 5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.9
|
specifier: ^3.4.9
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
@@ -100,6 +106,23 @@ packages:
|
|||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@auth/core@0.41.2':
|
||||||
|
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@simplewebauthn/browser': ^9.0.1
|
||||||
|
'@simplewebauthn/server': ^9.0.2
|
||||||
|
nodemailer: ^7.0.7
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@simplewebauthn/browser':
|
||||||
|
optional: true
|
||||||
|
'@simplewebauthn/server':
|
||||||
|
optional: true
|
||||||
|
nodemailer:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@auth/drizzle-adapter@1.11.2':
|
||||||
|
resolution: {integrity: sha512-VOuj7REI8jfJjpSbsYwDM/Zrn55T6lS9Yc+29V+EXMcel8eqG7x+7LodNfd1WHjakfkLYi+qsbYUHB3E6aDA4w==}
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1141,6 +1164,9 @@ packages:
|
|||||||
'@open-draft/until@2.1.0':
|
'@open-draft/until@2.1.0':
|
||||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1':
|
||||||
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
@@ -2872,6 +2898,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
next-auth@5.0.0-beta.31:
|
||||||
|
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@simplewebauthn/browser': ^9.0.1
|
||||||
|
'@simplewebauthn/server': ^9.0.2
|
||||||
|
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
|
||||||
|
nodemailer: ^7.0.7
|
||||||
|
react: ^18.2.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@simplewebauthn/browser':
|
||||||
|
optional: true
|
||||||
|
'@simplewebauthn/server':
|
||||||
|
optional: true
|
||||||
|
nodemailer:
|
||||||
|
optional: true
|
||||||
|
|
||||||
next@15.5.15:
|
next@15.5.15:
|
||||||
resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==}
|
resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==}
|
||||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||||
@@ -2917,6 +2959,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
|
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.6:
|
||||||
|
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3074,6 +3119,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
preact-render-to-string@6.5.11:
|
||||||
|
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
|
||||||
|
peerDependencies:
|
||||||
|
preact: '>=10'
|
||||||
|
|
||||||
|
preact@10.24.3:
|
||||||
|
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3640,6 +3693,22 @@ snapshots:
|
|||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
|
'@auth/core@0.41.2':
|
||||||
|
dependencies:
|
||||||
|
'@panva/hkdf': 1.2.1
|
||||||
|
jose: 6.2.3
|
||||||
|
oauth4webapi: 3.8.6
|
||||||
|
preact: 10.24.3
|
||||||
|
preact-render-to-string: 6.5.11(preact@10.24.3)
|
||||||
|
|
||||||
|
'@auth/drizzle-adapter@1.11.2':
|
||||||
|
dependencies:
|
||||||
|
'@auth/core': 0.41.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@simplewebauthn/browser'
|
||||||
|
- '@simplewebauthn/server'
|
||||||
|
- nodemailer
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
@@ -4449,6 +4518,8 @@ snapshots:
|
|||||||
|
|
||||||
'@open-draft/until@2.1.0': {}
|
'@open-draft/until@2.1.0': {}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
'@sec-ant/readable-stream@0.4.1': {}
|
'@sec-ant/readable-stream@0.4.1': {}
|
||||||
@@ -6220,6 +6291,12 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
|
next-auth@5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
'@auth/core': 0.41.2
|
||||||
|
next: 15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
react: 19.2.5
|
||||||
|
|
||||||
next@15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
next@15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 15.5.15
|
'@next/env': 15.5.15
|
||||||
@@ -6269,6 +6346,8 @@ snapshots:
|
|||||||
path-key: 4.0.0
|
path-key: 4.0.0
|
||||||
unicorn-magic: 0.3.0
|
unicorn-magic: 0.3.0
|
||||||
|
|
||||||
|
oauth4webapi@3.8.6: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -6435,6 +6514,12 @@ snapshots:
|
|||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
|
preact-render-to-string@6.5.11(preact@10.24.3):
|
||||||
|
dependencies:
|
||||||
|
preact: 10.24.3
|
||||||
|
|
||||||
|
preact@10.24.3: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier@3.8.3: {}
|
prettier@3.8.3: {}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { signIn } from "@/lib/auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<h1 className="text-4xl font-bold">famapp</h1>
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
"use server";
|
||||||
|
await signIn("authentik", { redirectTo: "/" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" size="lg">
|
||||||
|
Sign in with SSO
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||||
|
import NextAuth, { type DefaultSession } from "next-auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
accounts,
|
||||||
|
households,
|
||||||
|
householdMembers,
|
||||||
|
sessions,
|
||||||
|
users,
|
||||||
|
verificationTokens,
|
||||||
|
} from "@/modules/_core/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: { id: string } & DefaultSession["user"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
adapter: DrizzleAdapter(db, {
|
||||||
|
usersTable: users,
|
||||||
|
accountsTable: accounts,
|
||||||
|
sessionsTable: sessions,
|
||||||
|
verificationTokensTable: verificationTokens,
|
||||||
|
}),
|
||||||
|
session: { strategy: "database" },
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: "authentik",
|
||||||
|
name: "Authentik",
|
||||||
|
type: "oidc",
|
||||||
|
issuer: process.env.AUTH_OIDC_ISSUER,
|
||||||
|
clientId: process.env.AUTH_OIDC_CLIENT_ID,
|
||||||
|
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
.where(eq(householdMembers.userId, user.id))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
const [household] = await db.select().from(households).limit(1);
|
||||||
|
if (household) {
|
||||||
|
await db
|
||||||
|
.insert(householdMembers)
|
||||||
|
.values({ householdId: household.id, userId: user.id })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
session({ session, user }) {
|
||||||
|
session.user.id = user.id;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
authorized({ auth: session, request: { nextUrl } }) {
|
||||||
|
const isLoggedIn = !!session?.user;
|
||||||
|
const { pathname } = nextUrl;
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/s/") ||
|
||||||
|
pathname.startsWith("/api/auth/") ||
|
||||||
|
pathname === "/login"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isLoggedIn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getCurrentUser() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return null;
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: (u, { eq }) => eq(u.id, session.user.id),
|
||||||
|
});
|
||||||
|
return user ?? null;
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
import * as schema from "@/modules/_core/schema";
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
const client = postgres(process.env["DATABASE_URL"]!);
|
||||||
|
|
||||||
export const db = drizzle(client);
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { auth as middleware } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
// Skip Next.js internals and static files
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,14 +1,66 @@
|
|||||||
import { pgEnum, pgTable, primaryKey, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
|
import type { AdapterAccountType } from "@auth/core/adapters";
|
||||||
|
import {
|
||||||
|
integer,
|
||||||
|
pgEnum,
|
||||||
|
pgTable,
|
||||||
|
primaryKey,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const roleEnum = pgEnum("household_member_role", ["owner", "member"]);
|
export const roleEnum = pgEnum("household_member_role", ["owner", "member"]);
|
||||||
|
|
||||||
|
// Auth.js-compatible users table. "name" and "image" are populated from OIDC claims.
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
name: text("name"),
|
||||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||||
displayName: varchar("display_name", { length: 255 }).notNull(),
|
emailVerified: timestamp("email_verified", { withTimezone: true }),
|
||||||
|
image: text("image"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auth.js adapter tables
|
||||||
|
export const accounts = pgTable(
|
||||||
|
"accounts",
|
||||||
|
{
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
type: text("type").$type<AdapterAccountType>().notNull(),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
providerAccountId: text("provider_account_id").notNull(),
|
||||||
|
refresh_token: text("refresh_token"),
|
||||||
|
access_token: text("access_token"),
|
||||||
|
expires_at: integer("expires_at"),
|
||||||
|
token_type: text("token_type"),
|
||||||
|
scope: text("scope"),
|
||||||
|
id_token: text("id_token"),
|
||||||
|
session_state: text("session_state"),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.provider, t.providerAccountId] })],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const sessions = pgTable("sessions", {
|
||||||
|
sessionToken: text("session_token").primaryKey(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
expires: timestamp("expires", { withTimezone: true }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verificationTokens = pgTable(
|
||||||
|
"verification_tokens",
|
||||||
|
{
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
token: text("token").notNull(),
|
||||||
|
expires: timestamp("expires", { withTimezone: true }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
|
||||||
|
);
|
||||||
|
|
||||||
export const households = pgTable("households", {
|
export const households = pgTable("households", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
|||||||
Reference in New Issue
Block a user