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) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 22:40:54 +02:00
co-authored by Claude Opus 5
parent b74cecb729
commit 4c78c5672b
35 changed files with 2089 additions and 974 deletions
+3 -3
View File
@@ -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`),
@@ -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
+76 -76
View File
@@ -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,
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1788726280857,
"tag": "0000_clean_sumo",
"when": 1788726503038,
"tag": "0000_rapid_gargoyle",
"breakpoints": true
}
]
+70 -70
View File
@@ -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"
]
}
}
+45
View File
@@ -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<ActionResult> {
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<ActionResult> {
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 };
}
+62
View File
@@ -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 (
<div className="space-y-6">
<h1 className="text-2xl font-semibold tracking-tight">Konto</h1>
<Card>
<CardHeader>
<CardTitle>Profil</CardTitle>
<CardDescription>Diese Angaben stammen aus deiner Registrierung.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Name</span>
<span className="font-medium">{user.name}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">E-Mail</span>
<span className="truncate font-medium">{user.email}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Rolle</span>
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
{user.role === 'admin' ? 'Administrator' : 'Mitglied'}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Passkeys</CardTitle>
<CardDescription>
Passkeys sind an diese Domain gebunden und funktionieren nur über HTTPS. Du kannst
beliebig viele Geräte hinterlegen.
</CardDescription>
</CardHeader>
<CardContent>
<PasskeyManager passkeys={rows} />
</CardContent>
</Card>
</div>
);
}
+16
View File
@@ -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 (
<div className="flex min-h-dvh flex-col">
<AppHeader user={user} />
{/* Bottom padding keeps the mobile nav from covering the last row. */}
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main>
<BottomNav />
</div>
);
}
+58
View File
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Hallo {user.name}</h1>
<p className="text-sm text-muted-foreground">Was möchtest du tun?</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{shortcuts.map((shortcut) => (
<Link key={shortcut.href} href={shortcut.href} className="group">
<Card className="h-full transition-colors group-hover:border-primary/40">
<CardHeader>
<shortcut.icon className="size-6 text-primary" aria-hidden />
<CardTitle>{shortcut.title}</CardTitle>
<CardDescription>{shortcut.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
);
}
+38
View File
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Anmelden</CardTitle>
<CardDescription>Melde dich an, um die Sammlung zu verwalten.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<LoginForm />
</Suspense>
<p className="text-center text-xs text-muted-foreground">
Noch kein Konto?{' '}
<Link href="/registrieren" className="underline underline-offset-4">
Registrierung
</Link>
</p>
</CardContent>
</Card>
);
}
+17
View File
@@ -0,0 +1,17 @@
import Link from 'next/link';
import { Blocks } from 'lucide-react';
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10">
<Link href="/" className="flex items-center gap-2 text-xl font-semibold tracking-tight">
<Blocks className="size-6 text-primary" aria-hidden />
Bricktrack
</Link>
<main className="w-full max-w-sm">{children}</main>
<p className="text-center text-xs text-muted-foreground">
Die Sammlung und die Wunschlisten deiner Familie – auf deinem eigenen Server.
</p>
</div>
);
}
+47
View File
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Registrierung geschlossen</CardTitle>
<CardDescription>
Diese Bricktrack-Instanz hat bereits einen Administrator. Weitere Konten entstehen nur
über einen Einladungslink.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/anmelden" className="text-sm underline underline-offset-4">
Zur Anmeldung
</Link>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Erstes Konto anlegen</CardTitle>
<CardDescription>
Du bist der erste Nutzer und wirst damit automatisch Administrator.
</CardDescription>
</CardHeader>
<CardContent>
<RegisterForm />
</CardContent>
</Card>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/lib/auth';
export const { GET, POST } = toNextJsHandler(auth.handler);
+19
View File
@@ -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 });
}
}
+138 -129
View File
@@ -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;
}
}
+26 -24
View File
@@ -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 (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang="de" suppressHydrationWarning>
<body className="min-h-dvh bg-background text-foreground antialiased">
{children}
<Toaster position="top-center" richColors closeButton />
</body>
</html>
);
-103
View File
@@ -1,103 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
</div>
);
}
+131
View File
@@ -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<HTMLFormElement>) {
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 (
<div className="space-y-6">
<form onSubmit={signInWithPassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="username webauthn"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="du@example.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password webauthn"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
<Button type="submit" size="lg" className="w-full" disabled={pending !== null}>
{pending === 'password' ? <Loader2 className="animate-spin" /> : null}
Anmelden
</Button>
</form>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
oder
<span className="h-px flex-1 bg-border" />
</div>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={signInWithPasskey}
disabled={pending !== null}
>
{pending === 'passkey' ? <Loader2 className="animate-spin" /> : <Fingerprint />}
Mit Passkey anmelden
</Button>
</div>
);
}
+207
View File
@@ -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<PasskeyRow | null>(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 (
<div className="space-y-4">
{passkeys.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Noch kein Passkey hinterlegt. Mit einem Passkey meldest du dich per Fingerabdruck,
Gesichtserkennung oder Geräte-PIN an – ohne Passwort.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex items-center gap-3 p-3">
<KeyRound className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{passkey.name ?? 'Unbenannt'}</p>
<p className="text-xs text-muted-foreground">
Angelegt am {formatDate(passkey.createdAt)}
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Umbenennen"
onClick={() => setRenaming(passkey)}
disabled={pending}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Löschen"
onClick={() => remove(passkey)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Button size="lg" className="w-full sm:w-auto" onClick={() => setDialogOpen(true)}>
<Fingerprint />
Passkey hinzufügen
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Passkey hinzufügen</DialogTitle>
<DialogDescription>
Gib dem Passkey einen Namen, damit du ihn später wiedererkennst – zum Beispiel
&bdquo;iPhone von Papa&ldquo;.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="passkey-name">Name</Label>
<Input
id="passkey-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={defaultPasskeyName()}
maxLength={60}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Abbrechen</Button>
</DialogClose>
<Button onClick={() => void addPasskey()} disabled={adding}>
{adding ? <Loader2 className="animate-spin" /> : <Fingerprint />}
Jetzt anlegen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={renaming !== null} onOpenChange={(open) => !open && setRenaming(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Passkey umbenennen</DialogTitle>
<DialogDescription>
Der Name hilft dir nur beim Wiedererkennen und ist sonst ohne Bedeutung.
</DialogDescription>
</DialogHeader>
<form
id="rename-passkey"
onSubmit={(event) => {
event.preventDefault();
const value = new FormData(event.currentTarget).get('name');
if (renaming && typeof value === 'string') rename(renaming, value);
}}
className="space-y-2"
>
<Label htmlFor="rename-input">Name</Label>
<Input
id="rename-input"
name="name"
defaultValue={renaming?.name ?? ''}
maxLength={60}
required
/>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Abbrechen</Button>
</DialogClose>
<Button type="submit" form="rename-passkey" disabled={pending}>
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
/** 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';
}
+91
View File
@@ -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<HTMLFormElement>) {
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 (
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
autoComplete="name"
required
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Vorname"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
inputMode="email"
autoComplete="username"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="du@example.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Mindestens {MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du direkt nach der
Anmeldung unter „Konto“ hinterlegen.
</p>
</div>
<Button type="submit" size="lg" className="w-full" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Konto anlegen
</Button>
</form>
);
}
+21
View File
@@ -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 (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
<Link href="/" className="flex items-center gap-2 font-semibold tracking-tight">
<Blocks className="size-5 text-primary" aria-hidden />
<span>Bricktrack</span>
</Link>
<div className="flex-1" />
<DesktopNav />
<UserMenu user={user} />
</div>
</header>
);
}
+73
View File
@@ -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 (
<nav
aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
>
<ul className="mx-auto flex max-w-lg">
{items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<li key={item.href} className="flex-1">
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
>
<item.icon className="size-5" aria-hidden />
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
export function DesktopNav() {
const pathname = usePathname();
return (
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
{items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors',
active
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
<item.icon className="size-4" aria-hidden />
{item.label}
</Link>
);
})}
</nav>
);
}
+62
View File
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Konto-Menü">
<UserRound />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<span className="block text-sm font-medium">{user.name}</span>
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/konto">
<UserRound />
Konto und Passkeys
</Link>
</DropdownMenuItem>
{user.role === 'admin' ? (
<DropdownMenuItem asChild>
<Link href="/verwaltung/hersteller">
<Settings />
Verwaltung
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void signOut()}>
<LogOut />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+66 -66
View File
@@ -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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+18 -18
View File
@@ -1,18 +1,18 @@
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+191 -191
View File
@@ -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<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 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",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
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<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 data-[size=sm]:h-9 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 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",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+33 -33
View File
@@ -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'),
});
/**
+128 -127
View File
@@ -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<Record<string, string>>(),
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<Record<string, string>>(),
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)`,
),
],
);
+102 -95
View File
@@ -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)`,
),
],
);
+14
View File
@@ -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;
+122
View File
@@ -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;
+31
View File
@@ -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 <input type="date">, 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}`;
}
+62
View File
@@ -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 | null | undefined>): number {
return values.reduce<number>((total, value) => total + (value ?? 0), 0);
}
+47
View File
@@ -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<SessionUser | null> {
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<SessionUser> {
const user = await getCurrentUser();
if (!user) redirect('/anmelden');
return user;
}
export async function requireAdmin(): Promise<SessionUser> {
const user = await requireUser();
if (user.role !== 'admin') redirect('/');
return user;
}
+32
View File
@@ -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).*)',
],
};