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
+33 -33
View File
@@ -3,23 +3,23 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
/**
* Tables owned by better-auth.
*
* Column names are camelCase here (unlike the rest of the schema) because that
* is exactly what `@better-auth/cli generate` produces. Renaming them is a
* reliable source of breakage whenever a plugin is added or updated.
* The drizzle adapter resolves models through the TypeScript property names
* (`userId`, `expiresAt`, ...), so the column names below are free to follow
* the snake_case convention used by the rest of the schema.
*/
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('emailVerified', { mode: 'boolean' }).notNull().default(false),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
/** App specific: the first registered user becomes admin, everyone else member. */
role: text('role', { enum: ['admin', 'member'] })
.notNull()
.default('member'),
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const session = sqliteTable(
@@ -27,14 +27,14 @@ export const session = sqliteTable(
{
id: text('id').primaryKey(),
token: text('token').notNull().unique(),
userId: text('userId')
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(),
ipAddress: text('ipAddress'),
userAgent: text('userAgent'),
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('session_user_idx').on(t.userId)],
);
@@ -44,20 +44,20 @@ export const account = sqliteTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('accountId').notNull(),
providerId: text('providerId').notNull(),
userId: text('userId')
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('accessToken'),
refreshToken: text('refreshToken'),
idToken: text('idToken'),
accessTokenExpiresAt: integer('accessTokenExpiresAt', { mode: 'timestamp' }),
refreshTokenExpiresAt: integer('refreshTokenExpiresAt', { mode: 'timestamp' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('account_user_idx').on(t.userId)],
);
@@ -68,9 +68,9 @@ export const verification = sqliteTable(
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(),
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('verification_identifier_idx').on(t.identifier)],
);
@@ -81,27 +81,27 @@ export const passkey = sqliteTable(
{
id: text('id').primaryKey(),
name: text('name'),
publicKey: text('publicKey').notNull(),
userId: text('userId')
publicKey: text('public_key').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
credentialID: text('credentialID').notNull().unique(),
credentialID: text('credential_id').notNull().unique(),
counter: integer('counter').notNull(),
deviceType: text('deviceType').notNull(),
backedUp: integer('backedUp', { mode: 'boolean' }).notNull(),
deviceType: text('device_type').notNull(),
backedUp: integer('backed_up', { mode: 'boolean' }).notNull(),
transports: text('transports'),
aaguid: text('aaguid'),
createdAt: integer('createdAt', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }),
},
(t) => [index('passkey_user_idx').on(t.userId)],
);
/** Persistent rate limiting so restarts do not reset login throttling. */
export const rateLimit = sqliteTable('rateLimit', {
export const rateLimit = sqliteTable('rate_limit', {
id: text('id').primaryKey(),
key: text('key'),
count: integer('count'),
lastRequest: integer('lastRequest'),
lastRequest: integer('last_request'),
});
/**