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:
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1788726280857,
|
||||
"tag": "0000_clean_sumo",
|
||||
"when": 1788726503038,
|
||||
"tag": "0000_rapid_gargoyle",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
+70
-70
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { toNextJsHandler } from 'better-auth/next-js';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
„iPhone von Papa“.
|
||||
</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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).*)',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user