Add 6-digit login code for mobile authentication

Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.

- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-15 20:56:42 +02:00
parent 2a94af5655
commit 3bcb1fce7c
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 130 additions and 20 deletions

View file

@ -49,6 +49,7 @@ export const magicTokens = journalSchema.table("magic_tokens", {
id: text("id").primaryKey(),
email: text("email").notNull(),
token: text("token").notNull().unique(),
code: text("code"),
purpose: text("purpose").notNull().default("login"),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp("used_at", { withTimezone: true }),

View file

@ -265,6 +265,8 @@ export default {
backToPasskey: "Zurück zur Passkey-Anmeldung",
checkEmail: "Prüfe deine E-Mails! Wir haben einen Anmeldelink gesendet an",
linkExpires: "Der Link ist 15 Minuten gültig.",
codeHelp: "Oder gib den 6-stelligen Code aus der E-Mail ein:",
verifyCode: "Code bestätigen",
noAccount: "Noch kein Konto?",
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
registerWithPasskey: "Mit Passkey registrieren",

View file

@ -265,6 +265,8 @@ export default {
backToPasskey: "Back to passkey login",
checkEmail: "Check your email! We sent a login link to",
linkExpires: "The link expires in 15 minutes.",
codeHelp: "Or enter the 6-digit code from the email:",
verifyCode: "Verify Code",
noAccount: "Don't have an account?",
registerDescription: "Register with a passkey — no password needed.",
registerWithPasskey: "Register with Passkey",