feat(auth): better-auth mit passwort, passkeys und app-shell

Registrierung des ersten Nutzers macht ihn zum Administrator, danach
ist die Registrierung geschlossen (Einladungen folgen in Phase 2).
Login per Passwort oder Passkey inklusive Conditional UI, Verwaltung
der Passkeys unter "Konto" mit Umbenennen und Loeschen.

Rate-Limiting liegt in der Datenbank und ueberlebt Neustarts;
Session-Cookies sind httpOnly, sameSite=lax und im Produktivbetrieb
secure. Die Client-IP wird hinter dem Reverse Proxy aus
x-forwarded-for gelesen.

Dazu die mobile-first Grundstruktur: Bottom-Navigation, Header mit
Konto-Menue, Healthcheck unter /api/health und ein Dashboard mit den
vier Einstiegspunkten. Bedienelemente sind auf 44px Touch-Groesse
angehoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 22:40:54 +02:00
co-authored by Claude Opus 5
parent b74cecb729
commit 4c78c5672b
35 changed files with 2089 additions and 974 deletions
+14
View File
@@ -0,0 +1,14 @@
'use client';
import { passkeyClient } from '@better-auth/passkey/client';
import { createAuthClient } from 'better-auth/react';
/**
* Browser-side auth client. The base URL stays relative so the app works
* behind any reverse proxy without rebuilding.
*/
export const authClient = createAuthClient({
plugins: [passkeyClient()],
});
export const { signIn, signUp, signOut, useSession } = authClient;
+122
View File
@@ -0,0 +1,122 @@
import { passkey } from '@better-auth/passkey';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { APIError } from 'better-auth/api';
import { nextCookies } from 'better-auth/next-js';
import { count } from 'drizzle-orm';
import { db, schema } from '@/db';
import { env } from '@/env';
/**
* Returns true while the instance has no user at all.
*
* The very first registration creates the admin; afterwards sign-up is closed
* and only works through an invitation (phase 2).
*/
export function isFirstRun(): boolean {
const [row] = db.select({ value: count() }).from(schema.user).all();
return (row?.value ?? 0) === 0;
}
export const auth = betterAuth({
appName: 'Bricktrack',
baseURL: env.BASE_URL,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: 'sqlite',
schema: {
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
passkey: schema.passkey,
rateLimit: schema.rateLimit,
},
}),
emailAndPassword: {
enabled: true,
// No mail server in this deployment, so verification links would go nowhere.
requireEmailVerification: false,
minPasswordLength: 10,
autoSignIn: true,
},
user: {
additionalFields: {
role: {
type: 'string',
required: false,
defaultValue: 'member',
// Never settable through the sign-up payload - only by our own hook.
input: false,
},
},
},
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
cookieCache: { enabled: true, maxAge: 5 * 60 },
},
trustedOrigins: [env.BASE_URL],
advanced: {
cookiePrefix: 'bricktrack',
useSecureCookies: env.isProduction,
defaultCookieAttributes: {
httpOnly: true,
sameSite: 'lax',
secure: env.isProduction,
},
// TLS is terminated by the reverse proxy, so the client IP arrives in a header.
ipAddress: {
ipAddressHeaders: ['x-forwarded-for', 'x-real-ip'],
},
database: {
generateId: () => crypto.randomUUID(),
},
},
rateLimit: {
enabled: true,
// Survives container restarts, unlike the default in-memory storage.
storage: 'database',
window: 60,
max: 100,
customRules: {
'/sign-in/email': { window: 60, max: 5 },
'/sign-up/email': { window: 300, max: 3 },
'/passkey/generate-authenticate-options': { window: 60, max: 10 },
'/passkey/verify-authentication': { window: 60, max: 10 },
},
},
databaseHooks: {
user: {
create: {
before: async (user) => {
const firstRun = isFirstRun();
if (!firstRun) {
// Phase 2 opens this up again for invited users.
throw new APIError('FORBIDDEN', {
message:
'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.',
});
}
return { data: { ...user, role: 'admin' as const } };
},
},
},
},
plugins: [
passkey({
rpID: env.RP_ID,
rpName: env.RP_NAME,
origin: env.BASE_URL,
authenticatorSelection: {
// Discoverable credentials enable the passwordless autofill flow.
residentKey: 'preferred',
userVerification: 'preferred',
},
}),
// Must stay last: lets server actions write the session cookie.
nextCookies(),
],
});
export type Auth = typeof auth;
+31
View File
@@ -0,0 +1,31 @@
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
/** dd.MM.yyyy - the only date format used in the UI. */
export function formatDate(value: Date | number | null | undefined): string {
if (value === null || value === undefined) return '–';
return format(value, 'dd.MM.yyyy');
}
export function formatDateTime(value: Date | number | null | undefined): string {
if (value === null || value === undefined) return '–';
return format(value, 'dd.MM.yyyy, HH:mm', { locale: de });
}
/** Value for an <input type="date">, which always expects ISO. */
export function toDateInputValue(value: Date | null | undefined): string {
if (!value) return '';
return format(value, 'yyyy-MM-dd');
}
const numberFormatter = new Intl.NumberFormat('de-DE');
export function formatNumber(value: number | null | undefined): string {
if (value === null || value === undefined) return '–';
return numberFormatter.format(value);
}
/** "1 Set" / "12 Sets" - German plural for the few nouns we count. */
export function pluralize(count: number, singular: string, plural: string): string {
return `${formatNumber(count)} ${count === 1 ? singular : plural}`;
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Money handling. Amounts live in the database as integer cents; euros only
* exist for display and for what the user types. Every conversion goes through
* this module.
*/
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
const decimalFormatter = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/** "12,99 €" - the canonical display form. */
export function formatEuro(cents: number | null | undefined): string {
if (cents === null || cents === undefined) return '–';
return currencyFormatter.format(cents / 100);
}
/** "12,99" - for prefilling a number input without the currency symbol. */
export function centsToInput(cents: number | null | undefined): string {
if (cents === null || cents === undefined) return '';
return decimalFormatter.format(cents / 100);
}
/**
* Parses user input into cents. Accepts both German and English decimal
* separators as well as thousands separators, because phone keyboards differ.
* Returns null for empty input and NaN-ish garbage.
*/
export function parseEuroInput(value: string | null | undefined): number | null {
if (value === null || value === undefined) return null;
const trimmed = value.trim().replace(/[€\s]/g, '');
if (trimmed === '') return null;
// "1.234,56" -> "1234.56" ; "1,234.56" -> "1234.56" ; "12,99" -> "12.99"
const lastComma = trimmed.lastIndexOf(',');
const lastDot = trimmed.lastIndexOf('.');
let normalized: string;
if (lastComma > lastDot) {
normalized = trimmed.replace(/\./g, '').replace(',', '.');
} else if (lastDot > lastComma) {
normalized = trimmed.replace(/,/g, '');
} else {
normalized = trimmed;
}
const parsed = Number.parseFloat(normalized);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.round(parsed * 100);
}
/** Sum of a list of optional cent amounts, ignoring the unknown ones. */
export function sumCents(values: Array<number | null | undefined>): number {
return values.reduce<number>((total, value) => total + (value ?? 0), 0);
}
+47
View File
@@ -0,0 +1,47 @@
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { cache } from 'react';
import type { UserRole } from '@/db/types';
import { auth } from './auth';
/**
* Reads the current session. Cached per request so several server components
* on the same page share one lookup.
*/
export const getSession = cache(async () => {
return auth.api.getSession({ headers: await headers() });
});
export type SessionUser = {
id: string;
name: string;
email: string;
role: UserRole;
};
function toSessionUser(user: { id: string; name: string; email: string; role?: unknown }): SessionUser {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role === 'admin' ? 'admin' : 'member',
};
}
export async function getCurrentUser(): Promise<SessionUser | null> {
const session = await getSession();
return session ? toSessionUser(session.user) : null;
}
/** Guard for every page and server action behind the login. */
export async function requireUser(): Promise<SessionUser> {
const user = await getCurrentUser();
if (!user) redirect('/anmelden');
return user;
}
export async function requireAdmin(): Promise<SessionUser> {
const user = await requireUser();
if (user.role !== 'admin') redirect('/');
return user;
}