From 4c78c5672b4297ee0a85f7c300a4701c00e8b39d Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 22:40:42 +0200 Subject: [PATCH] feat(auth): better-auth mit passwort, passkeys und app-shell Registrierung des ersten Nutzers macht ihn zum Administrator, danach ist die Registrierung geschlossen (Einladungen folgen in Phase 2). Login per Passwort oder Passkey inklusive Conditional UI, Verwaltung der Passkeys unter "Konto" mit Umbenennen und Loeschen. Rate-Limiting liegt in der Datenbank und ueberlebt Neustarts; Session-Cookies sind httpOnly, sameSite=lax und im Produktivbetrieb secure. Die Client-IP wird hinter dem Reverse Proxy aus x-forwarded-for gelesen. Dazu die mobile-first Grundstruktur: Bottom-Navigation, Header mit Konto-Menue, Healthcheck unter /api/health und ein Dashboard mit den vier Einstiegspunkten. Bedienelemente sind auf 44px Touch-Groesse angehoben. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 6 +- ...clean_sumo.sql => 0000_rapid_gargoyle.sql} | 74 ++-- drizzle/meta/0000_snapshot.json | 152 +++---- drizzle/meta/_journal.json | 4 +- package.json | 140 +++---- src/app/(app)/konto/actions.ts | 45 +++ src/app/(app)/konto/page.tsx | 62 +++ src/app/(app)/layout.tsx | 16 + src/app/(app)/page.tsx | 58 +++ src/app/(auth)/anmelden/page.tsx | 38 ++ src/app/(auth)/layout.tsx | 17 + src/app/(auth)/registrieren/page.tsx | 47 +++ src/app/api/auth/[...all]/route.ts | 4 + src/app/api/health/route.ts | 19 + src/app/globals.css | 267 ++++++------ src/app/layout.tsx | 50 +-- src/app/page.tsx | 103 ----- src/components/auth/login-form.tsx | 131 ++++++ src/components/auth/passkey-manager.tsx | 207 ++++++++++ src/components/auth/register-form.tsx | 91 +++++ src/components/layout/app-header.tsx | 21 + src/components/layout/bottom-nav.tsx | 73 ++++ src/components/layout/user-menu.tsx | 62 +++ src/components/ui/button.tsx | 132 +++--- src/components/ui/input.tsx | 36 +- src/components/ui/select.tsx | 382 +++++++++--------- src/db/schema/auth.ts | 66 +-- src/db/schema/catalog.ts | 255 ++++++------ src/db/schema/collection.ts | 197 ++++----- src/lib/auth-client.ts | 14 + src/lib/auth.ts | 122 ++++++ src/lib/format.ts | 31 ++ src/lib/money.ts | 62 +++ src/lib/session.ts | 47 +++ src/middleware.ts | 32 ++ 35 files changed, 2089 insertions(+), 974 deletions(-) rename drizzle/{0000_clean_sumo.sql => 0000_rapid_gargoyle.sql} (85%) create mode 100644 src/app/(app)/konto/actions.ts create mode 100644 src/app/(app)/konto/page.tsx create mode 100644 src/app/(app)/layout.tsx create mode 100644 src/app/(app)/page.tsx create mode 100644 src/app/(auth)/anmelden/page.tsx create mode 100644 src/app/(auth)/layout.tsx create mode 100644 src/app/(auth)/registrieren/page.tsx create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/app/api/health/route.ts delete mode 100644 src/app/page.tsx create mode 100644 src/components/auth/login-form.tsx create mode 100644 src/components/auth/passkey-manager.tsx create mode 100644 src/components/auth/register-form.tsx create mode 100644 src/components/layout/app-header.tsx create mode 100644 src/components/layout/bottom-nav.tsx create mode 100644 src/components/layout/user-menu.tsx create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/money.ts create mode 100644 src/lib/session.ts create mode 100644 src/middleware.ts diff --git a/CLAUDE.md b/CLAUDE.md index e48bf7d..37b21c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,9 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat. (`retail_price_cents`, `acquired_price_cents`). SQLite hat keinen Decimal-Typ; Float-Summen waeren ungenau. Umrechnung ausschliesslich in `src/lib/money.ts`. -- Datenbankspalten in `snake_case` - **ausser** in den Auth-Tabellen, - die `camelCase` verwenden, weil das exakt der Ausgabe von - `@better-auth/cli generate` entspricht. +- Alle Datenbankspalten in `snake_case`. Auch in den Auth-Tabellen - + der Drizzle-Adapter von better-auth loest Modelle ueber die + TypeScript-Property-Namen auf, nicht ueber die Spaltennamen. - IDs sind Text-UUIDs (`crypto.randomUUID()`), Zeitstempel sind `integer(mode: 'timestamp')`. - Routen sind deutsch (`/katalog`, `/sammlung`, `/wunschlisten`), diff --git a/drizzle/0000_clean_sumo.sql b/drizzle/0000_rapid_gargoyle.sql similarity index 85% rename from drizzle/0000_clean_sumo.sql rename to drizzle/0000_rapid_gargoyle.sql index c1d86bf..5aed9d6 100644 --- a/drizzle/0000_clean_sumo.sql +++ b/drizzle/0000_rapid_gargoyle.sql @@ -1,21 +1,21 @@ CREATE TABLE `account` ( `id` text PRIMARY KEY NOT NULL, - `accountId` text NOT NULL, - `providerId` text NOT NULL, - `userId` text NOT NULL, - `accessToken` text, - `refreshToken` text, - `idToken` text, - `accessTokenExpiresAt` integer, - `refreshTokenExpiresAt` integer, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, `scope` text, `password` text, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint -CREATE INDEX `account_user_idx` ON `account` (`userId`);--> statement-breakpoint +CREATE INDEX `account_user_idx` ON `account` (`user_id`);--> statement-breakpoint CREATE TABLE `invite` ( `id` text PRIMARY KEY NOT NULL, `token_hash` text NOT NULL, @@ -34,50 +34,50 @@ CREATE UNIQUE INDEX `invite_token_hash_unique` ON `invite` (`token_hash`);--> st CREATE TABLE `passkey` ( `id` text PRIMARY KEY NOT NULL, `name` text, - `publicKey` text NOT NULL, - `userId` text NOT NULL, - `credentialID` text NOT NULL, + `public_key` text NOT NULL, + `user_id` text NOT NULL, + `credential_id` text NOT NULL, `counter` integer NOT NULL, - `deviceType` text NOT NULL, - `backedUp` integer NOT NULL, + `device_type` text NOT NULL, + `backed_up` integer NOT NULL, `transports` text, `aaguid` text, - `createdAt` integer, - FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade + `created_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint -CREATE UNIQUE INDEX `passkey_credentialID_unique` ON `passkey` (`credentialID`);--> statement-breakpoint -CREATE INDEX `passkey_user_idx` ON `passkey` (`userId`);--> statement-breakpoint -CREATE TABLE `rateLimit` ( +CREATE UNIQUE INDEX `passkey_credential_id_unique` ON `passkey` (`credential_id`);--> statement-breakpoint +CREATE INDEX `passkey_user_idx` ON `passkey` (`user_id`);--> statement-breakpoint +CREATE TABLE `rate_limit` ( `id` text PRIMARY KEY NOT NULL, `key` text, `count` integer, - `lastRequest` integer + `last_request` integer ); --> statement-breakpoint CREATE TABLE `session` ( `id` text PRIMARY KEY NOT NULL, `token` text NOT NULL, - `userId` text NOT NULL, - `expiresAt` integer NOT NULL, - `ipAddress` text, - `userAgent` text, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade + `user_id` text NOT NULL, + `expires_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint -CREATE INDEX `session_user_idx` ON `session` (`userId`);--> statement-breakpoint +CREATE INDEX `session_user_idx` ON `session` (`user_id`);--> statement-breakpoint CREATE TABLE `user` ( `id` text PRIMARY KEY NOT NULL, `name` text NOT NULL, `email` text NOT NULL, - `emailVerified` integer DEFAULT false NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, `image` text, `role` text DEFAULT 'member' NOT NULL, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL ); --> statement-breakpoint CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint @@ -85,9 +85,9 @@ CREATE TABLE `verification` ( `id` text PRIMARY KEY NOT NULL, `identifier` text NOT NULL, `value` text NOT NULL, - `expiresAt` integer NOT NULL, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL ); --> statement-breakpoint CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 17cdbe0..5951124 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "5d6a1e8b-5b30-4c01-8044-1db2d04615e0", + "id": "426c9bf5-ccbd-473a-a819-11f8b9252494", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -14,57 +14,57 @@ "notNull": true, "autoincrement": false }, - "accountId": { - "name": "accountId", + "account_id": { + "name": "account_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "providerId": { - "name": "providerId", + "provider_id": { + "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "userId": { - "name": "userId", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "accessToken": { - "name": "accessToken", + "access_token": { + "name": "access_token", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "refreshToken": { - "name": "refreshToken", + "refresh_token": { + "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "idToken": { - "name": "idToken", + "id_token": { + "name": "id_token", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "accessTokenExpiresAt": { - "name": "accessTokenExpiresAt", + "access_token_expires_at": { + "name": "access_token_expires_at", "type": "integer", "primaryKey": false, "notNull": false, "autoincrement": false }, - "refreshTokenExpiresAt": { - "name": "refreshTokenExpiresAt", + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", "type": "integer", "primaryKey": false, "notNull": false, @@ -84,15 +84,15 @@ "notNull": false, "autoincrement": false }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updatedAt": { - "name": "updatedAt", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, @@ -103,18 +103,18 @@ "account_user_idx": { "name": "account_user_idx", "columns": [ - "userId" + "user_id" ], "isUnique": false } }, "foreignKeys": { - "account_userId_user_id_fk": { - "name": "account_userId_user_id_fk", + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": [ - "userId" + "user_id" ], "columnsTo": [ "id" @@ -253,22 +253,22 @@ "notNull": false, "autoincrement": false }, - "publicKey": { - "name": "publicKey", + "public_key": { + "name": "public_key", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "userId": { - "name": "userId", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "credentialID": { - "name": "credentialID", + "credential_id": { + "name": "credential_id", "type": "text", "primaryKey": false, "notNull": true, @@ -281,15 +281,15 @@ "notNull": true, "autoincrement": false }, - "deviceType": { - "name": "deviceType", + "device_type": { + "name": "device_type", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "backedUp": { - "name": "backedUp", + "backed_up": { + "name": "backed_up", "type": "integer", "primaryKey": false, "notNull": true, @@ -309,8 +309,8 @@ "notNull": false, "autoincrement": false }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": false, @@ -318,28 +318,28 @@ } }, "indexes": { - "passkey_credentialID_unique": { - "name": "passkey_credentialID_unique", + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", "columns": [ - "credentialID" + "credential_id" ], "isUnique": true }, "passkey_user_idx": { "name": "passkey_user_idx", "columns": [ - "userId" + "user_id" ], "isUnique": false } }, "foreignKeys": { - "passkey_userId_user_id_fk": { - "name": "passkey_userId_user_id_fk", + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", "tableFrom": "passkey", "tableTo": "user", "columnsFrom": [ - "userId" + "user_id" ], "columnsTo": [ "id" @@ -352,8 +352,8 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "rateLimit": { - "name": "rateLimit", + "rate_limit": { + "name": "rate_limit", "columns": { "id": { "name": "id", @@ -376,8 +376,8 @@ "notNull": false, "autoincrement": false }, - "lastRequest": { - "name": "lastRequest", + "last_request": { + "name": "last_request", "type": "integer", "primaryKey": false, "notNull": false, @@ -407,43 +407,43 @@ "notNull": true, "autoincrement": false }, - "userId": { - "name": "userId", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "expiresAt": { - "name": "expiresAt", + "expires_at": { + "name": "expires_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "ipAddress": { - "name": "ipAddress", + "ip_address": { + "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "userAgent": { - "name": "userAgent", + "user_agent": { + "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updatedAt": { - "name": "updatedAt", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, @@ -461,18 +461,18 @@ "session_user_idx": { "name": "session_user_idx", "columns": [ - "userId" + "user_id" ], "isUnique": false } }, "foreignKeys": { - "session_userId_user_id_fk": { - "name": "session_userId_user_id_fk", + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": [ - "userId" + "user_id" ], "columnsTo": [ "id" @@ -509,8 +509,8 @@ "notNull": true, "autoincrement": false }, - "emailVerified": { - "name": "emailVerified", + "email_verified": { + "name": "email_verified", "type": "integer", "primaryKey": false, "notNull": true, @@ -532,15 +532,15 @@ "autoincrement": false, "default": "'member'" }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updatedAt": { - "name": "updatedAt", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, @@ -585,22 +585,22 @@ "notNull": true, "autoincrement": false }, - "expiresAt": { - "name": "expiresAt", + "expires_at": { + "name": "expires_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updatedAt": { - "name": "updatedAt", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5b3d3e7..19c4a53 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1788726280857, - "tag": "0000_clean_sumo", + "when": 1788726503038, + "tag": "0000_rapid_gargoyle", "breakpoints": true } ] diff --git a/package.json b/package.json index d24db96..a1bcbe5 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,70 @@ -{ - "name": "bricktrack", - "version": "0.1.0", - "private": true, - "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", - "packageManager": "pnpm@10.34.5", - "engines": { - "node": ">=22" - }, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "typecheck": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx scripts/migrate.ts", - "db:seed": "tsx scripts/seed.ts", - "db:studio": "drizzle-kit studio", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e": "playwright test", - "screenshots": "playwright test --config=playwright.screenshots.config.ts" - }, - "dependencies": { - "@better-auth/passkey": "^1.7.3", - "better-auth": "^1.7.3", - "better-sqlite3": "^13.0.3", - "class-variance-authority": "^0.7.1", - "cn": "^0.2.6", - "date-fns": "^4.4.0", - "drizzle-orm": "^0.45.2", - "lucide-react": "^1.41.0", - "next": "15.5.25", - "next-themes": "^0.4.6", - "radix-ui": "^1.6.7", - "react": "19.1.0", - "react-dom": "19.1.0", - "sharp": "^0.35.4", - "sonner": "^2.0.8", - "tw-animate-css": "^1.4.0", - "zod": "^4.5.4" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", - "@playwright/test": "^1.63.0", - "@tailwindcss/postcss": "^4", - "@types/better-sqlite3": "^9.6.0", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv": "^17.4.2", - "drizzle-kit": "^0.31.10", - "eslint": "^9", - "eslint-config-next": "15.5.25", - "shadcn": "^4.21.0", - "tailwindcss": "^4", - "tsx": "^4.23.13", - "typescript": "^5", - "vitest": "^5.0.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild", - "sharp", - "unrs-resolver" - ] - } -} +{ + "name": "bricktrack", + "version": "0.1.0", + "private": true, + "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx scripts/migrate.ts", + "db:seed": "tsx scripts/seed.ts", + "db:studio": "drizzle-kit studio", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "screenshots": "playwright test --config=playwright.screenshots.config.ts" + }, + "dependencies": { + "@better-auth/passkey": "^1.7.3", + "better-auth": "^1.7.3", + "better-sqlite3": "^13.0.3", + "class-variance-authority": "^0.7.1", + "cn": "^0.2.6", + "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.41.0", + "next": "15.5.25", + "next-themes": "^0.4.6", + "radix-ui": "^1.6.7", + "react": "19.1.0", + "react-dom": "19.1.0", + "sharp": "^0.35.4", + "sonner": "^2.0.8", + "tw-animate-css": "^1.4.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.63.0", + "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "15.5.25", + "shadcn": "^4.21.0", + "tailwindcss": "^4", + "tsx": "^4.23.13", + "typescript": "^5", + "vitest": "^5.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "esbuild", + "sharp", + "unrs-resolver" + ] + } +} diff --git a/src/app/(app)/konto/actions.ts b/src/app/(app)/konto/actions.ts new file mode 100644 index 0000000..1cc91d6 --- /dev/null +++ b/src/app/(app)/konto/actions.ts @@ -0,0 +1,45 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { auth } from '@/lib/auth'; + +const idSchema = z.object({ id: z.string().min(1) }); +const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) }); + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +export async function deletePasskeyAction(input: unknown): Promise { + const parsed = idSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: 'Ungültige Anfrage.' }; + + try { + await auth.api.deletePasskey({ + body: { id: parsed.data.id }, + headers: await headers(), + }); + } catch { + return { ok: false, error: 'Der Passkey konnte nicht gelöscht werden.' }; + } + + revalidatePath('/konto'); + return { ok: true }; +} + +export async function renamePasskeyAction(input: unknown): Promise { + const parsed = renameSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: 'Bitte gib einen Namen an (max. 60 Zeichen).' }; + + try { + await auth.api.updatePasskey({ + body: { id: parsed.data.id, name: parsed.data.name }, + headers: await headers(), + }); + } catch { + return { ok: false, error: 'Der Passkey konnte nicht umbenannt werden.' }; + } + + revalidatePath('/konto'); + return { ok: true }; +} diff --git a/src/app/(app)/konto/page.tsx b/src/app/(app)/konto/page.tsx new file mode 100644 index 0000000..a08252b --- /dev/null +++ b/src/app/(app)/konto/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { auth } from '@/lib/auth'; +import { requireUser } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Konto' }; + +export default async function AccountPage() { + const user = await requireUser(); + + const passkeys = await auth.api.listPasskeys({ headers: await headers() }); + const rows: PasskeyRow[] = passkeys.map((passkey) => ({ + id: passkey.id, + name: passkey.name ?? null, + createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null, + })); + + return ( +
+

Konto

+ + + + Profil + Diese Angaben stammen aus deiner Registrierung. + + +
+ Name + {user.name} +
+
+ E-Mail + {user.email} +
+
+ Rolle + + {user.role === 'admin' ? 'Administrator' : 'Mitglied'} + +
+
+
+ + + + Passkeys + + Passkeys sind an diese Domain gebunden und funktionieren nur über HTTPS. Du kannst + beliebig viele Geräte hinterlegen. + + + + + + +
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..3fe9e1b --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,16 @@ +import { AppHeader } from '@/components/layout/app-header'; +import { BottomNav } from '@/components/layout/bottom-nav'; +import { requireUser } from '@/lib/session'; + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const user = await requireUser(); + + return ( +
+ + {/* Bottom padding keeps the mobile nav from covering the last row. */} +
{children}
+ +
+ ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx new file mode 100644 index 0000000..dad922a --- /dev/null +++ b/src/app/(app)/page.tsx @@ -0,0 +1,58 @@ +import Link from 'next/link'; +import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react'; +import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { requireUser } from '@/lib/session'; + +const shortcuts = [ + { + href: '/katalog', + icon: LayoutGrid, + title: 'Katalog', + description: 'Alle bekannten Sets durchsuchen und neue anlegen.', + }, + { + href: '/katalog?besitz=fehlt', + icon: PackageSearch, + title: 'Fehlende Sets', + description: 'Was noch nicht in der Sammlung ist – direkt auf die Wunschliste.', + }, + { + href: '/sammlung', + icon: Boxes, + title: 'Sammlung', + description: 'Alles, was tatsächlich da ist – mit Standort und Zustand.', + }, + { + href: '/wunschlisten', + icon: Gift, + title: 'Wunschlisten', + description: 'Listen anlegen und per Link mit Familie und Freunden teilen.', + }, +] as const; + +export default async function DashboardPage() { + const user = await requireUser(); + + return ( +
+
+

Hallo {user.name}

+

Was möchtest du tun?

+
+ +
+ {shortcuts.map((shortcut) => ( + + + + + {shortcut.title} + {shortcut.description} + + + + ))} +
+
+ ); +} diff --git a/src/app/(auth)/anmelden/page.tsx b/src/app/(auth)/anmelden/page.tsx new file mode 100644 index 0000000..088094b --- /dev/null +++ b/src/app/(auth)/anmelden/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { Suspense } from 'react'; +import { LoginForm } from '@/components/auth/login-form'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { isFirstRun } from '@/lib/auth'; +import { getSession } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Anmelden' }; + +export default async function LoginPage() { + if (await getSession()) redirect('/'); + + // Nobody registered yet: send the very first visitor straight to sign-up. + if (isFirstRun()) redirect('/registrieren'); + + return ( + + + Anmelden + Melde dich an, um die Sammlung zu verwalten. + + + }> + + +

+ Noch kein Konto?{' '} + + Registrierung + +

+
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..09aa574 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,17 @@ +import Link from 'next/link'; +import { Blocks } from 'lucide-react'; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + + Bricktrack + +
{children}
+

+ Die Sammlung und die Wunschlisten deiner Familie – auf deinem eigenen Server. +

+
+ ); +} diff --git a/src/app/(auth)/registrieren/page.tsx b/src/app/(auth)/registrieren/page.tsx new file mode 100644 index 0000000..d9792ae --- /dev/null +++ b/src/app/(auth)/registrieren/page.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { RegisterForm } from '@/components/auth/register-form'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { isFirstRun } from '@/lib/auth'; +import { getSession } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Registrieren' }; + +export default async function RegisterPage() { + if (await getSession()) redirect('/'); + + // After the first user, accounts only come from an invitation link. + if (!isFirstRun()) { + return ( + + + Registrierung geschlossen + + Diese Bricktrack-Instanz hat bereits einen Administrator. Weitere Konten entstehen nur + über einen Einladungslink. + + + + + Zur Anmeldung + + + + ); + } + + return ( + + + Erstes Konto anlegen + + Du bist der erste Nutzer und wirst damit automatisch Administrator. + + + + + + + ); +} diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..9122667 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { toNextJsHandler } from 'better-auth/next-js'; +import { auth } from '@/lib/auth'; + +export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..c774a02 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,19 @@ +import { sql } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { db } from '@/db'; + +export const dynamic = 'force-dynamic'; + +/** + * Container healthcheck. Verifies that the process is up *and* that the SQLite + * file is readable - a healthy Node process on a broken volume is not healthy. + */ +export function GET() { + try { + db.get(sql`select 1`); + return NextResponse.json({ status: 'ok', time: new Date().toISOString() }); + } catch (error) { + console.error('[health] Datenbank nicht erreichbar', error); + return NextResponse.json({ status: 'error', error: 'database unavailable' }, { status: 503 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index c56032b..6b1710d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,130 +1,139 @@ -@import "tailwindcss"; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; - -@custom-variant dark (&:is(.dark *)); - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); -} - -:root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } - html { - @apply font-sans; - } +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", + "Segoe UI Emoji"; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; +} + + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..2e04487 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,33 +1,35 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { Metadata, Viewport } from 'next'; +import { Toaster } from '@/components/ui/sonner'; +import './globals.css'; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: { + default: 'Bricktrack', + template: '%s · Bricktrack', + }, + description: 'Sammlung und Wunschlisten für Spielzeug-Sets', + applicationName: 'Bricktrack', + // A private, self-hosted instance never belongs in a search index - and the + // public wishlist links are meant to be shared, not found. + robots: { index: false, follow: false }, }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }, + ], +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( - - + + {children} + ); diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index a932894..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- - -
- -
- ); -} diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx new file mode 100644 index 0000000..864e224 --- /dev/null +++ b/src/components/auth/login-form.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { Fingerprint, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const target = searchParams.get('weiter') ?? '/'; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [pending, setPending] = useState<'password' | 'passkey' | null>(null); + + useEffect(() => { + // Conditional UI: offers matching passkeys straight from the browser's + // autofill, where supported. Silently ignored everywhere else. + if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return; + + let cancelled = false; + void window.PublicKeyCredential.isConditionalMediationAvailable().then((available) => { + if (!available || cancelled) return; + void authClient.signIn + .passkey({ autoFill: true }) + .then((result) => { + if (cancelled || result?.error) return; + router.push(target); + router.refresh(); + }) + .catch(() => { + /* user dismissed the autofill prompt */ + }); + }); + + return () => { + cancelled = true; + }; + }, [router, target]); + + async function signInWithPassword(event: React.FormEvent) { + event.preventDefault(); + setPending('password'); + const { error } = await authClient.signIn.email({ email, password }); + setPending(null); + + if (error) { + toast.error( + error.status === 429 + ? 'Zu viele Versuche. Bitte warte einen Moment.' + : 'E-Mail-Adresse oder Passwort stimmt nicht.', + ); + return; + } + router.push(target); + router.refresh(); + } + + async function signInWithPasskey() { + setPending('passkey'); + const result = await authClient.signIn.passkey(); + setPending(null); + + if (!result || result.error) { + toast.error('Anmeldung mit Passkey hat nicht geklappt.'); + return; + } + router.push(target); + router.refresh(); + } + + return ( +
+
+
+ + setEmail(event.target.value)} + placeholder="du@example.de" + /> +
+
+ + setPassword(event.target.value)} + /> +
+ +
+ +
+ + oder + +
+ + +
+ ); +} diff --git a/src/components/auth/passkey-manager.tsx b/src/components/auth/passkey-manager.tsx new file mode 100644 index 0000000..cedea85 --- /dev/null +++ b/src/components/auth/passkey-manager.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Fingerprint, KeyRound, Loader2, Pencil, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { deletePasskeyAction, renamePasskeyAction } from '@/app/(app)/konto/actions'; +import { authClient } from '@/lib/auth-client'; +import { formatDate } from '@/lib/format'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export type PasskeyRow = { + id: string; + name: string | null; + createdAt: Date | null; +}; + +export function PasskeyManager({ passkeys }: { passkeys: PasskeyRow[] }) { + const router = useRouter(); + const [adding, setAdding] = useState(false); + const [name, setName] = useState(''); + const [dialogOpen, setDialogOpen] = useState(false); + const [renaming, setRenaming] = useState(null); + const [pending, startTransition] = useTransition(); + + async function addPasskey() { + setAdding(true); + const result = await authClient.passkey.addPasskey({ + name: name.trim() || defaultPasskeyName(), + }); + setAdding(false); + + if (result?.error) { + toast.error(result.error.message ?? 'Der Passkey konnte nicht angelegt werden.'); + return; + } + + toast.success('Passkey gespeichert.'); + setName(''); + setDialogOpen(false); + router.refresh(); + } + + function remove(passkey: PasskeyRow) { + startTransition(async () => { + const result = await deletePasskeyAction({ id: passkey.id }); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Passkey gelöscht.'); + router.refresh(); + }); + } + + function rename(passkey: PasskeyRow, newName: string) { + startTransition(async () => { + const result = await renamePasskeyAction({ id: passkey.id, name: newName }); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Passkey umbenannt.'); + setRenaming(null); + router.refresh(); + }); + } + + return ( +
+ {passkeys.length === 0 ? ( +

+ Noch kein Passkey hinterlegt. Mit einem Passkey meldest du dich per Fingerabdruck, + Gesichtserkennung oder Geräte-PIN an – ohne Passwort. +

+ ) : ( +
    + {passkeys.map((passkey) => ( +
  • + +
    +

    {passkey.name ?? 'Unbenannt'}

    +

    + Angelegt am {formatDate(passkey.createdAt)} +

    +
    + + +
  • + ))} +
+ )} + + + + + + + Passkey hinzufügen + + Gib dem Passkey einen Namen, damit du ihn später wiedererkennst – zum Beispiel + „iPhone von Papa“. + + +
+ + setName(event.target.value)} + placeholder={defaultPasskeyName()} + maxLength={60} + /> +
+ + + + + + +
+
+ + !open && setRenaming(null)}> + + + Passkey umbenennen + + Der Name hilft dir nur beim Wiedererkennen und ist sonst ohne Bedeutung. + + +
{ + event.preventDefault(); + const value = new FormData(event.currentTarget).get('name'); + if (renaming && typeof value === 'string') rename(renaming, value); + }} + className="space-y-2" + > + + +
+ + + + + + +
+
+
+ ); +} + +/** A sensible default so the list does not fill up with "Unbenannt". */ +function defaultPasskeyName(): string { + if (typeof navigator === 'undefined') return 'Mein Gerät'; + const ua = navigator.userAgent; + if (/iPhone/.test(ua)) return 'iPhone'; + if (/iPad/.test(ua)) return 'iPad'; + if (/Android/.test(ua)) return 'Android-Gerät'; + if (/Macintosh/.test(ua)) return 'Mac'; + if (/Windows/.test(ua)) return 'Windows-PC'; + return 'Mein Gerät'; +} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx new file mode 100644 index 0000000..9baa45b --- /dev/null +++ b/src/components/auth/register-form.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +const MIN_PASSWORD_LENGTH = 10; + +export function RegisterForm() { + const router = useRouter(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [pending, setPending] = useState(false); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + + if (password.length < MIN_PASSWORD_LENGTH) { + toast.error(`Das Passwort braucht mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`); + return; + } + + setPending(true); + const { error } = await authClient.signUp.email({ name, email, password }); + setPending(false); + + if (error) { + toast.error(error.message ?? 'Registrierung fehlgeschlagen.'); + return; + } + + toast.success('Konto angelegt. Willkommen bei Bricktrack!'); + router.push('/'); + router.refresh(); + } + + return ( +
+
+ + setName(event.target.value)} + placeholder="Vorname" + /> +
+
+ + setEmail(event.target.value)} + placeholder="du@example.de" + /> +
+
+ + setPassword(event.target.value)} + /> +

+ Mindestens {MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du direkt nach der + Anmeldung unter „Konto“ hinterlegen. +

+
+ +
+ ); +} diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx new file mode 100644 index 0000000..bdb8d67 --- /dev/null +++ b/src/components/layout/app-header.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link'; +import { Blocks } from 'lucide-react'; +import { DesktopNav } from './bottom-nav'; +import { UserMenu } from './user-menu'; +import type { SessionUser } from '@/lib/session'; + +export function AppHeader({ user }: { user: SessionUser }) { + return ( +
+
+ + + Bricktrack + +
+ + +
+
+ ); +} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx new file mode 100644 index 0000000..80685cb --- /dev/null +++ b/src/components/layout/bottom-nav.tsx @@ -0,0 +1,73 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Boxes, Gift, LayoutGrid, User } from 'lucide-react'; +import { cn } from 'cn'; + +const items = [ + { href: '/katalog', label: 'Katalog', icon: LayoutGrid }, + { href: '/sammlung', label: 'Sammlung', icon: Boxes }, + { href: '/wunschlisten', label: 'Wünsche', icon: Gift }, + { href: '/konto', label: 'Konto', icon: User }, +] as const; + +export function BottomNav() { + const pathname = usePathname(); + + return ( + + ); +} + +export function DesktopNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx new file mode 100644 index 0000000..8aa10df --- /dev/null +++ b/src/components/layout/user-menu.tsx @@ -0,0 +1,62 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { LogOut, Settings, UserRound } from 'lucide-react'; +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { SessionUser } from '@/lib/session'; + +export function UserMenu({ user }: { user: SessionUser }) { + const router = useRouter(); + + async function signOut() { + await authClient.signOut(); + router.push('/anmelden'); + router.refresh(); + } + + return ( + + + + + + + {user.name} + {user.email} + + + + + + Konto und Passkeys + + + {user.role === 'admin' ? ( + + + + Verwaltung + + + ) : null} + + void signOut()}> + + Abmelden + + + + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 18407c9..7e1eb83 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,66 +1,66 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" -import { Slot } from "radix-ui" - -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : "button" - - return ( - - ) -} - -export { Button, buttonVariants } +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Slot } from "radix-ui" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-10 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-9 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-11 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-10", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-11", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 7aa671b..607e963 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,18 +1,18 @@ -import * as React from "react" -import { cn } from "cn" - -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ) -} - -export { Input } +import * as React from "react" +import { cn } from "cn" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index cdd962b..e2d9617 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -1,191 +1,191 @@ -"use client" - -import * as React from "react" -import { cn } from "cn" -import { Select as SelectPrimitive } from "radix-ui" -import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" - -function Select({ - ...props -}: React.ComponentProps) { - return -} - -function SelectGroup({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SelectValue({ - ...props -}: React.ComponentProps) { - return -} - -function SelectTrigger({ - className, - size = "default", - children, - ...props -}: React.ComponentProps & { - size?: "sm" | "default" -}) { - return ( - - {children} - - - - - ) -} - -function SelectContent({ - className, - children, - position = "item-aligned", - align = "center", - ...props -}: React.ComponentProps) { - return ( - - - - - {children} - - - - - ) -} - -function SelectLabel({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SelectItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - -function SelectSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SelectScrollUpButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function SelectScrollDownButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -export { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectScrollDownButton, - SelectScrollUpButton, - SelectSeparator, - SelectTrigger, - SelectValue, -} +"use client" + +import * as React from "react" +import { cn } from "cn" +import { Select as SelectPrimitive } from "radix-ui" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts index b0443c6..e2090ef 100644 --- a/src/db/schema/auth.ts +++ b/src/db/schema/auth.ts @@ -3,23 +3,23 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; /** * Tables owned by better-auth. * - * Column names are camelCase here (unlike the rest of the schema) because that - * is exactly what `@better-auth/cli generate` produces. Renaming them is a - * reliable source of breakage whenever a plugin is added or updated. + * The drizzle adapter resolves models through the TypeScript property names + * (`userId`, `expiresAt`, ...), so the column names below are free to follow + * the snake_case convention used by the rest of the schema. */ export const user = sqliteTable('user', { id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), - emailVerified: integer('emailVerified', { mode: 'boolean' }).notNull().default(false), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), image: text('image'), /** App specific: the first registered user becomes admin, everyone else member. */ role: text('role', { enum: ['admin', 'member'] }) .notNull() .default('member'), - createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }); export const session = sqliteTable( @@ -27,14 +27,14 @@ export const session = sqliteTable( { id: text('id').primaryKey(), token: text('token').notNull().unique(), - userId: text('userId') + userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), - expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), - ipAddress: text('ipAddress'), - userAgent: text('userAgent'), - createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (t) => [index('session_user_idx').on(t.userId)], ); @@ -44,20 +44,20 @@ export const account = sqliteTable( 'account', { id: text('id').primaryKey(), - accountId: text('accountId').notNull(), - providerId: text('providerId').notNull(), - userId: text('userId') + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), - accessToken: text('accessToken'), - refreshToken: text('refreshToken'), - idToken: text('idToken'), - accessTokenExpiresAt: integer('accessTokenExpiresAt', { mode: 'timestamp' }), - refreshTokenExpiresAt: integer('refreshTokenExpiresAt', { mode: 'timestamp' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }), scope: text('scope'), password: text('password'), - createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (t) => [index('account_user_idx').on(t.userId)], ); @@ -68,9 +68,9 @@ export const verification = sqliteTable( id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(), - expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), - createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (t) => [index('verification_identifier_idx').on(t.identifier)], ); @@ -81,27 +81,27 @@ export const passkey = sqliteTable( { id: text('id').primaryKey(), name: text('name'), - publicKey: text('publicKey').notNull(), - userId: text('userId') + publicKey: text('public_key').notNull(), + userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), - credentialID: text('credentialID').notNull().unique(), + credentialID: text('credential_id').notNull().unique(), counter: integer('counter').notNull(), - deviceType: text('deviceType').notNull(), - backedUp: integer('backedUp', { mode: 'boolean' }).notNull(), + deviceType: text('device_type').notNull(), + backedUp: integer('backed_up', { mode: 'boolean' }).notNull(), transports: text('transports'), aaguid: text('aaguid'), - createdAt: integer('createdAt', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }), }, (t) => [index('passkey_user_idx').on(t.userId)], ); /** Persistent rate limiting so restarts do not reset login throttling. */ -export const rateLimit = sqliteTable('rateLimit', { +export const rateLimit = sqliteTable('rate_limit', { id: text('id').primaryKey(), key: text('key'), count: integer('count'), - lastRequest: integer('lastRequest'), + lastRequest: integer('last_request'), }); /** diff --git a/src/db/schema/catalog.ts b/src/db/schema/catalog.ts index 6194846..46567d5 100644 --- a/src/db/schema/catalog.ts +++ b/src/db/schema/catalog.ts @@ -1,127 +1,128 @@ -import { sql, type AnyColumn } from 'drizzle-orm'; -import { - check, - index, - integer, - primaryKey, - sqliteTable, - text, - uniqueIndex, -} from 'drizzle-orm/sqlite-core'; - -export const manufacturer = sqliteTable('manufacturer', { - id: text('id').primaryKey(), - name: text('name').notNull(), - slug: text('slug').notNull().unique(), - website: text('website'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), -}); - -export const theme = sqliteTable( - 'theme', - { - id: text('id').primaryKey(), - manufacturerId: text('manufacturer_id') - .notNull() - .references(() => manufacturer.id, { onDelete: 'cascade' }), - parentThemeId: text('parent_theme_id').references((): AnyColumn => theme.id, { - onDelete: 'set null', - }), - name: text('name').notNull(), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - index('theme_manufacturer_idx').on(t.manufacturerId), - uniqueIndex('theme_unique_name').on(t.manufacturerId, t.name), - ], -); - -/** - * A catalog entry. The set exists in the world, independent of whether we own - * it - ownership is expressed by `collection_item`. - */ -export const set = sqliteTable( - 'set', - { - id: text('id').primaryKey(), - manufacturerId: text('manufacturer_id') - .notNull() - .references(() => manufacturer.id, { onDelete: 'restrict' }), - themeId: text('theme_id').references(() => theme.id, { onDelete: 'set null' }), - /** Manufacturer's own number, e.g. "60398", "71427", "2-30500". */ - setNumber: text('set_number').notNull(), - name: text('name').notNull(), - year: integer('year'), - pieceCount: integer('piece_count'), - minifigCount: integer('minifig_count'), - ean: text('ean'), - ageRange: text('age_range'), - /** Either an external URL the user entered ... */ - imageUrl: text('image_url'), - /** ... or a locally stored file below FILES_DIR. */ - imagePath: text('image_path'), - /** EUR in cents - SQLite has no decimal type and sums must stay exact. */ - retailPriceCents: integer('retail_price_cents'), - externalIds: text('external_ids', { mode: 'json' }).$type>(), - notes: text('notes'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - uniqueIndex('set_manufacturer_number_unique').on(t.manufacturerId, t.setNumber), - index('set_name_idx').on(t.name), - index('set_ean_idx').on(t.ean), - index('set_theme_idx').on(t.themeId), - index('set_year_idx').on(t.year), - ], -); - -export const tag = sqliteTable('tag', { - id: text('id').primaryKey(), - name: text('name').notNull(), - slug: text('slug').notNull().unique(), -}); - -export const setTag = sqliteTable( - 'set_tag', - { - setId: text('set_id') - .notNull() - .references(() => set.id, { onDelete: 'cascade' }), - tagId: text('tag_id') - .notNull() - .references(() => tag.id, { onDelete: 'cascade' }), - }, - (t) => [primaryKey({ columns: [t.setId, t.tagId] })], -); - -export const instruction = sqliteTable( - 'instruction', - { - id: text('id').primaryKey(), - setId: text('set_id') - .notNull() - .references(() => set.id, { onDelete: 'cascade' }), - kind: text('kind', { enum: ['pdf_upload', 'external_url'] }).notNull(), - /** Set when kind = pdf_upload. */ - filePath: text('file_path'), - /** Set when kind = external_url. */ - url: text('url'), - /** Free text label, e.g. "Buch 1 von 3". */ - label: text('label'), - pageCount: integer('page_count'), - fileSizeBytes: integer('file_size_bytes'), - sortOrder: integer('sort_order').notNull().default(0), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - index('instruction_set_idx').on(t.setId), - check( - 'instruction_source', - sql`(${t.kind} = 'pdf_upload' AND ${t.filePath} IS NOT NULL AND ${t.url} IS NULL) - OR (${t.kind} = 'external_url' AND ${t.url} IS NOT NULL AND ${t.filePath} IS NULL)`, - ), - ], -); +import { sql } from 'drizzle-orm'; +import { + type AnySQLiteColumn, + check, + index, + integer, + primaryKey, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; + +export const manufacturer = sqliteTable('manufacturer', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + website: text('website'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +export const theme = sqliteTable( + 'theme', + { + id: text('id').primaryKey(), + manufacturerId: text('manufacturer_id') + .notNull() + .references(() => manufacturer.id, { onDelete: 'cascade' }), + parentThemeId: text('parent_theme_id').references((): AnySQLiteColumn => theme.id, { + onDelete: 'set null', + }), + name: text('name').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('theme_manufacturer_idx').on(t.manufacturerId), + uniqueIndex('theme_unique_name').on(t.manufacturerId, t.name), + ], +); + +/** + * A catalog entry. The set exists in the world, independent of whether we own + * it - ownership is expressed by `collection_item`. + */ +export const set = sqliteTable( + 'set', + { + id: text('id').primaryKey(), + manufacturerId: text('manufacturer_id') + .notNull() + .references(() => manufacturer.id, { onDelete: 'restrict' }), + themeId: text('theme_id').references(() => theme.id, { onDelete: 'set null' }), + /** Manufacturer's own number, e.g. "60398", "71427", "2-30500". */ + setNumber: text('set_number').notNull(), + name: text('name').notNull(), + year: integer('year'), + pieceCount: integer('piece_count'), + minifigCount: integer('minifig_count'), + ean: text('ean'), + ageRange: text('age_range'), + /** Either an external URL the user entered ... */ + imageUrl: text('image_url'), + /** ... or a locally stored file below FILES_DIR. */ + imagePath: text('image_path'), + /** EUR in cents - SQLite has no decimal type and sums must stay exact. */ + retailPriceCents: integer('retail_price_cents'), + externalIds: text('external_ids', { mode: 'json' }).$type>(), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + uniqueIndex('set_manufacturer_number_unique').on(t.manufacturerId, t.setNumber), + index('set_name_idx').on(t.name), + index('set_ean_idx').on(t.ean), + index('set_theme_idx').on(t.themeId), + index('set_year_idx').on(t.year), + ], +); + +export const tag = sqliteTable('tag', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), +}); + +export const setTag = sqliteTable( + 'set_tag', + { + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + tagId: text('tag_id') + .notNull() + .references(() => tag.id, { onDelete: 'cascade' }), + }, + (t) => [primaryKey({ columns: [t.setId, t.tagId] })], +); + +export const instruction = sqliteTable( + 'instruction', + { + id: text('id').primaryKey(), + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + kind: text('kind', { enum: ['pdf_upload', 'external_url'] }).notNull(), + /** Set when kind = pdf_upload. */ + filePath: text('file_path'), + /** Set when kind = external_url. */ + url: text('url'), + /** Free text label, e.g. "Buch 1 von 3". */ + label: text('label'), + pageCount: integer('page_count'), + fileSizeBytes: integer('file_size_bytes'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('instruction_set_idx').on(t.setId), + check( + 'instruction_source', + sql`(${t.kind} = 'pdf_upload' AND ${t.filePath} IS NOT NULL AND ${t.url} IS NULL) + OR (${t.kind} = 'external_url' AND ${t.url} IS NOT NULL AND ${t.filePath} IS NULL)`, + ), + ], +); diff --git a/src/db/schema/collection.ts b/src/db/schema/collection.ts index 503f69f..4144eb7 100644 --- a/src/db/schema/collection.ts +++ b/src/db/schema/collection.ts @@ -1,95 +1,102 @@ -import { sql, type AnyColumn } from 'drizzle-orm'; -import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { set } from './catalog'; - -export const location = sqliteTable('location', { - id: text('id').primaryKey(), - /** e.g. "Regal Kinderzimmer", "Kiste Keller 3". */ - name: text('name').notNull(), - parentLocationId: text('parent_location_id').references((): AnyColumn => location.id, { - onDelete: 'set null', - }), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), -}); - -/** - * Ownership: links a catalog set to a copy we actually have. Owning the same - * set twice means two rows. - */ -export const collectionItem = sqliteTable( - 'collection_item', - { - id: text('id').primaryKey(), - setId: text('set_id') - .notNull() - .references(() => set.id, { onDelete: 'cascade' }), - status: text('status', { - enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'], - }) - .notNull() - .default('owned'), - completeness: text('completeness', { enum: ['complete', 'missing_parts', 'unknown'] }) - .notNull() - .default('unknown'), - condition: text('condition', { - enum: ['new_sealed', 'like_new', 'good', 'used', 'damaged'], - }), - locationId: text('location_id').references(() => location.id, { onDelete: 'set null' }), - acquiredAt: integer('acquired_at', { mode: 'timestamp' }), - acquiredPriceCents: integer('acquired_price_cents'), - acquiredFrom: text('acquired_from'), - giftedBy: text('gifted_by'), - lentOutTo: text('lent_out_to'), - notes: text('notes'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - index('collection_item_set_idx').on(t.setId), - index('collection_item_status_idx').on(t.status), - index('collection_item_location_idx').on(t.locationId), - ], -); - -export const missingPart = sqliteTable( - 'missing_part', - { - id: text('id').primaryKey(), - collectionItemId: text('collection_item_id') - .notNull() - .references(() => collectionItem.id, { onDelete: 'cascade' }), - partNumber: text('part_number'), - colorName: text('color_name'), - quantity: integer('quantity').notNull().default(1), - resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), - notes: text('notes'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [index('missing_part_item_idx').on(t.collectionItemId)], -); - -/** Belongs to either a catalog set or one owned copy - never to both. */ -export const photo = sqliteTable( - 'photo', - { - id: text('id').primaryKey(), - setId: text('set_id').references(() => set.id, { onDelete: 'cascade' }), - collectionItemId: text('collection_item_id').references(() => collectionItem.id, { - onDelete: 'cascade', - }), - filePath: text('file_path').notNull(), - thumbnailPath: text('thumbnail_path'), - caption: text('caption'), - sortOrder: integer('sort_order').notNull().default(0), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - index('photo_set_idx').on(t.setId), - index('photo_item_idx').on(t.collectionItemId), - check( - 'photo_single_owner', - sql`(${t.setId} IS NOT NULL) <> (${t.collectionItemId} IS NOT NULL)`, - ), - ], -); +import { sql } from 'drizzle-orm'; +import { + type AnySQLiteColumn, + check, + index, + integer, + sqliteTable, + text, +} from 'drizzle-orm/sqlite-core'; +import { set } from './catalog'; + +export const location = sqliteTable('location', { + id: text('id').primaryKey(), + /** e.g. "Regal Kinderzimmer", "Kiste Keller 3". */ + name: text('name').notNull(), + parentLocationId: text('parent_location_id').references((): AnySQLiteColumn => location.id, { + onDelete: 'set null', + }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +/** + * Ownership: links a catalog set to a copy we actually have. Owning the same + * set twice means two rows. + */ +export const collectionItem = sqliteTable( + 'collection_item', + { + id: text('id').primaryKey(), + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + status: text('status', { + enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'], + }) + .notNull() + .default('owned'), + completeness: text('completeness', { enum: ['complete', 'missing_parts', 'unknown'] }) + .notNull() + .default('unknown'), + condition: text('condition', { + enum: ['new_sealed', 'like_new', 'good', 'used', 'damaged'], + }), + locationId: text('location_id').references(() => location.id, { onDelete: 'set null' }), + acquiredAt: integer('acquired_at', { mode: 'timestamp' }), + acquiredPriceCents: integer('acquired_price_cents'), + acquiredFrom: text('acquired_from'), + giftedBy: text('gifted_by'), + lentOutTo: text('lent_out_to'), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('collection_item_set_idx').on(t.setId), + index('collection_item_status_idx').on(t.status), + index('collection_item_location_idx').on(t.locationId), + ], +); + +export const missingPart = sqliteTable( + 'missing_part', + { + id: text('id').primaryKey(), + collectionItemId: text('collection_item_id') + .notNull() + .references(() => collectionItem.id, { onDelete: 'cascade' }), + partNumber: text('part_number'), + colorName: text('color_name'), + quantity: integer('quantity').notNull().default(1), + resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [index('missing_part_item_idx').on(t.collectionItemId)], +); + +/** Belongs to either a catalog set or one owned copy - never to both. */ +export const photo = sqliteTable( + 'photo', + { + id: text('id').primaryKey(), + setId: text('set_id').references(() => set.id, { onDelete: 'cascade' }), + collectionItemId: text('collection_item_id').references(() => collectionItem.id, { + onDelete: 'cascade', + }), + filePath: text('file_path').notNull(), + thumbnailPath: text('thumbnail_path'), + caption: text('caption'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('photo_set_idx').on(t.setId), + index('photo_item_idx').on(t.collectionItemId), + check( + 'photo_single_owner', + sql`(${t.setId} IS NOT NULL) <> (${t.collectionItemId} IS NOT NULL)`, + ), + ], +); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..b8c790e --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,14 @@ +'use client'; + +import { passkeyClient } from '@better-auth/passkey/client'; +import { createAuthClient } from 'better-auth/react'; + +/** + * Browser-side auth client. The base URL stays relative so the app works + * behind any reverse proxy without rebuilding. + */ +export const authClient = createAuthClient({ + plugins: [passkeyClient()], +}); + +export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..5fc90d9 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,122 @@ +import { passkey } from '@better-auth/passkey'; +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { APIError } from 'better-auth/api'; +import { nextCookies } from 'better-auth/next-js'; +import { count } from 'drizzle-orm'; +import { db, schema } from '@/db'; +import { env } from '@/env'; + +/** + * Returns true while the instance has no user at all. + * + * The very first registration creates the admin; afterwards sign-up is closed + * and only works through an invitation (phase 2). + */ +export function isFirstRun(): boolean { + const [row] = db.select({ value: count() }).from(schema.user).all(); + return (row?.value ?? 0) === 0; +} + +export const auth = betterAuth({ + appName: 'Bricktrack', + baseURL: env.BASE_URL, + secret: env.BETTER_AUTH_SECRET, + database: drizzleAdapter(db, { + provider: 'sqlite', + schema: { + user: schema.user, + session: schema.session, + account: schema.account, + verification: schema.verification, + passkey: schema.passkey, + rateLimit: schema.rateLimit, + }, + }), + emailAndPassword: { + enabled: true, + // No mail server in this deployment, so verification links would go nowhere. + requireEmailVerification: false, + minPasswordLength: 10, + autoSignIn: true, + }, + user: { + additionalFields: { + role: { + type: 'string', + required: false, + defaultValue: 'member', + // Never settable through the sign-up payload - only by our own hook. + input: false, + }, + }, + }, + session: { + expiresIn: 60 * 60 * 24 * 30, + updateAge: 60 * 60 * 24, + cookieCache: { enabled: true, maxAge: 5 * 60 }, + }, + trustedOrigins: [env.BASE_URL], + advanced: { + cookiePrefix: 'bricktrack', + useSecureCookies: env.isProduction, + defaultCookieAttributes: { + httpOnly: true, + sameSite: 'lax', + secure: env.isProduction, + }, + // TLS is terminated by the reverse proxy, so the client IP arrives in a header. + ipAddress: { + ipAddressHeaders: ['x-forwarded-for', 'x-real-ip'], + }, + database: { + generateId: () => crypto.randomUUID(), + }, + }, + rateLimit: { + enabled: true, + // Survives container restarts, unlike the default in-memory storage. + storage: 'database', + window: 60, + max: 100, + customRules: { + '/sign-in/email': { window: 60, max: 5 }, + '/sign-up/email': { window: 300, max: 3 }, + '/passkey/generate-authenticate-options': { window: 60, max: 10 }, + '/passkey/verify-authentication': { window: 60, max: 10 }, + }, + }, + databaseHooks: { + user: { + create: { + before: async (user) => { + const firstRun = isFirstRun(); + if (!firstRun) { + // Phase 2 opens this up again for invited users. + throw new APIError('FORBIDDEN', { + message: + 'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.', + }); + } + return { data: { ...user, role: 'admin' as const } }; + }, + }, + }, + }, + plugins: [ + passkey({ + rpID: env.RP_ID, + rpName: env.RP_NAME, + origin: env.BASE_URL, + authenticatorSelection: { + // Discoverable credentials enable the passwordless autofill flow. + residentKey: 'preferred', + userVerification: 'preferred', + }, + }), + // Must stay last: lets server actions write the session cookie. + nextCookies(), + ], +}); + +export type Auth = typeof auth; diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..ff60812 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,31 @@ +import { format } from 'date-fns'; +import { de } from 'date-fns/locale'; + +/** dd.MM.yyyy - the only date format used in the UI. */ +export function formatDate(value: Date | number | null | undefined): string { + if (value === null || value === undefined) return '–'; + return format(value, 'dd.MM.yyyy'); +} + +export function formatDateTime(value: Date | number | null | undefined): string { + if (value === null || value === undefined) return '–'; + return format(value, 'dd.MM.yyyy, HH:mm', { locale: de }); +} + +/** Value for an , which always expects ISO. */ +export function toDateInputValue(value: Date | null | undefined): string { + if (!value) return ''; + return format(value, 'yyyy-MM-dd'); +} + +const numberFormatter = new Intl.NumberFormat('de-DE'); + +export function formatNumber(value: number | null | undefined): string { + if (value === null || value === undefined) return '–'; + return numberFormatter.format(value); +} + +/** "1 Set" / "12 Sets" - German plural for the few nouns we count. */ +export function pluralize(count: number, singular: string, plural: string): string { + return `${formatNumber(count)} ${count === 1 ? singular : plural}`; +} diff --git a/src/lib/money.ts b/src/lib/money.ts new file mode 100644 index 0000000..9cbce8e --- /dev/null +++ b/src/lib/money.ts @@ -0,0 +1,62 @@ +/** + * Money handling. Amounts live in the database as integer cents; euros only + * exist for display and for what the user types. Every conversion goes through + * this module. + */ + +const currencyFormatter = new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', +}); + +const decimalFormatter = new Intl.NumberFormat('de-DE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +/** "12,99 €" - the canonical display form. */ +export function formatEuro(cents: number | null | undefined): string { + if (cents === null || cents === undefined) return '–'; + return currencyFormatter.format(cents / 100); +} + +/** "12,99" - for prefilling a number input without the currency symbol. */ +export function centsToInput(cents: number | null | undefined): string { + if (cents === null || cents === undefined) return ''; + return decimalFormatter.format(cents / 100); +} + +/** + * Parses user input into cents. Accepts both German and English decimal + * separators as well as thousands separators, because phone keyboards differ. + * Returns null for empty input and NaN-ish garbage. + */ +export function parseEuroInput(value: string | null | undefined): number | null { + if (value === null || value === undefined) return null; + + const trimmed = value.trim().replace(/[€\s]/g, ''); + if (trimmed === '') return null; + + // "1.234,56" -> "1234.56" ; "1,234.56" -> "1234.56" ; "12,99" -> "12.99" + const lastComma = trimmed.lastIndexOf(','); + const lastDot = trimmed.lastIndexOf('.'); + let normalized: string; + + if (lastComma > lastDot) { + normalized = trimmed.replace(/\./g, '').replace(',', '.'); + } else if (lastDot > lastComma) { + normalized = trimmed.replace(/,/g, ''); + } else { + normalized = trimmed; + } + + const parsed = Number.parseFloat(normalized); + if (!Number.isFinite(parsed) || parsed < 0) return null; + + return Math.round(parsed * 100); +} + +/** Sum of a list of optional cent amounts, ignoring the unknown ones. */ +export function sumCents(values: Array): number { + return values.reduce((total, value) => total + (value ?? 0), 0); +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..bf478d0 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,47 @@ +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { cache } from 'react'; +import type { UserRole } from '@/db/types'; +import { auth } from './auth'; + +/** + * Reads the current session. Cached per request so several server components + * on the same page share one lookup. + */ +export const getSession = cache(async () => { + return auth.api.getSession({ headers: await headers() }); +}); + +export type SessionUser = { + id: string; + name: string; + email: string; + role: UserRole; +}; + +function toSessionUser(user: { id: string; name: string; email: string; role?: unknown }): SessionUser { + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role === 'admin' ? 'admin' : 'member', + }; +} + +export async function getCurrentUser(): Promise { + const session = await getSession(); + return session ? toSessionUser(session.user) : null; +} + +/** Guard for every page and server action behind the login. */ +export async function requireUser(): Promise { + const user = await getCurrentUser(); + if (!user) redirect('/anmelden'); + return user; +} + +export async function requireAdmin(): Promise { + const user = await requireUser(); + if (user.role !== 'admin') redirect('/'); + return user; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..457809b --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,32 @@ +import { getSessionCookie } from 'better-auth/cookies'; +import { NextResponse, type NextRequest } from 'next/server'; + +/** + * Optimistic route guard: it only checks whether a session cookie is present, + * which keeps the middleware free of database access. The real check happens + * in every page and server action via `requireUser()`. + */ +export function middleware(request: NextRequest) { + const hasSession = getSessionCookie(request, { cookiePrefix: 'bricktrack' }); + if (hasSession) return NextResponse.next(); + + const target = new URL('/anmelden', request.url); + const { pathname, search } = request.nextUrl; + if (pathname !== '/') { + target.searchParams.set('weiter', `${pathname}${search}`); + } + return NextResponse.redirect(target); +} + +export const config = { + matcher: [ + /* + * Everything except the public surface: + * - /anmelden, /registrieren, /einladung (auth pages) + * - /w/... (public wishlists) + * - /api/auth, /api/health, /api/files (handled by their own guards) + * - Next internals and static assets + */ + '/((?!anmelden|registrieren|einladung|w/|api/auth|api/health|api/files|_next/static|_next/image|favicon.ico|icons/|manifest.webmanifest).*)', + ], +};