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:
parent
0230d93004
commit
be5766fa50
12 changed files with 820 additions and 19 deletions
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue