Add account settings page with passkey management

- /settings with profile, security, and account sections
- Profile: edit display name and bio
- Security: list passkeys with device labels, add/delete with
  last-passkey warning
- Account: email change with magic link verification, account
  deletion with username confirmation
- Settings link in nav for authenticated users
- E2E tests: auth guard, profile editing, passkey add/delete,
  last-passkey warning, account deletion, nav visibility
- i18n: full en + de translations for all settings UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-29 10:09:06 +02:00
parent 0230d93004
commit be5766fa50
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
12 changed files with 820 additions and 19 deletions

View file

@ -249,6 +249,31 @@ export async function createMagicToken(email: string): Promise<string> {
return token;
}
export async function initiateEmailChange(userId: string, newEmail: string): Promise<string> {
const db = getDb();
const [existing] = await db.select().from(users).where(eq(users.email, newEmail));
if (existing) throw new Error("Email already in use");
const [user] = await db.select().from(users).where(eq(users.id, userId));
if (!user) throw new Error("User not found");
if (user.email === newEmail) throw new Error("This is already your email");
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
// Store new email in the token — verifyMagicToken will detect the mismatch
// and update the user's email
await db.insert(magicTokens).values({
id: randomUUID(),
email: newEmail,
token,
expiresAt,
});
return token;
}
export async function verifyMagicToken(token: string): Promise<string> {
const db = getDb();
@ -271,13 +296,46 @@ export async function verifyMagicToken(token: string): Promise<string> {
.set({ usedAt: new Date() })
.where(eq(magicTokens.id, record.id));
// Find user
// Find user — for login/registration tokens, email matches an existing user.
// For email-change tokens, the email is the NEW email (no user yet).
const [user] = await db.select().from(users).where(eq(users.email, record.email));
if (!user) throw new Error("User not found");
return user.id;
}
export async function verifyEmailChange(token: string, userId: string): Promise<string> {
const db = getDb();
const [record] = await db
.select()
.from(magicTokens)
.where(
and(
eq(magicTokens.token, token),
gt(magicTokens.expiresAt, new Date()),
isNull(magicTokens.usedAt),
),
);
if (!record) throw new Error("Invalid or expired verification link");
await db
.update(magicTokens)
.set({ usedAt: new Date() })
.where(eq(magicTokens.id, record.id));
const newEmail = record.email;
// Update user email
await db
.update(users)
.set({ email: newEmail })
.where(eq(users.id, userId));
return newEmail;
}
// --- Sessions ---
import { createCookieSessionStorage } from "react-router";