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).*)', + ], +};