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:
parent
2a94af5655
commit
3bcb1fce7c
7 changed files with 130 additions and 20 deletions
|
|
@ -229,7 +229,13 @@ export async function finishAuthentication(
|
|||
|
||||
// --- Magic Links ---
|
||||
|
||||
export async function createMagicToken(email: string): Promise<string> {
|
||||
function generateLoginCode(): string {
|
||||
// 6-digit numeric code, zero-padded
|
||||
const num = randomBytes(3).readUIntBE(0, 3) % 1_000_000;
|
||||
return String(num).padStart(6, "0");
|
||||
}
|
||||
|
||||
export async function createMagicToken(email: string): Promise<{ token: string; code: string }> {
|
||||
const db = getDb();
|
||||
|
||||
// Check user exists
|
||||
|
|
@ -237,16 +243,47 @@ export async function createMagicToken(email: string): Promise<string> {
|
|||
if (!user) throw new Error("No account found for this email");
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const code = generateLoginCode();
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||
|
||||
await db.insert(magicTokens).values({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
token,
|
||||
code,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return token;
|
||||
return { token, code };
|
||||
}
|
||||
|
||||
export async function verifyLoginCode(email: string, code: string): Promise<string> {
|
||||
const db = getDb();
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(magicTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(magicTokens.email, email),
|
||||
eq(magicTokens.code, code),
|
||||
eq(magicTokens.purpose, "login"),
|
||||
gt(magicTokens.expiresAt, new Date()),
|
||||
isNull(magicTokens.usedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!record) throw new Error("Invalid or expired code");
|
||||
|
||||
await db
|
||||
.update(magicTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(magicTokens.id, record.id));
|
||||
|
||||
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 initiateEmailChange(userId: string, newEmail: string): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -30,23 +30,33 @@ export async function sendEmail(
|
|||
|
||||
// --- Templates ---
|
||||
|
||||
export function magicLinkTemplate(link: string): { html: string; text: string } {
|
||||
export function magicLinkTemplate(link: string, code?: string): { html: string; text: string } {
|
||||
const codeSection = code
|
||||
? `<p style="color: #555; line-height: 1.6;">Or enter this code on the login page:</p>
|
||||
<div style="margin: 16px 0; padding: 16px; background: #f3f4f6; border-radius: 8px; text-align: center;">
|
||||
<span style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #111;">${code}</span>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h1 style="font-size: 24px; font-weight: 600; color: #111;">Sign in to trails.cool</h1>
|
||||
<p style="color: #555; line-height: 1.6;">Click the button below to sign in to your account. This link expires in 15 minutes.</p>
|
||||
<a href="${link}" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">Sign In</a>
|
||||
${codeSection}
|
||||
<p style="color: #888; font-size: 14px;">If the button doesn't work, copy and paste this link:<br/><a href="${link}" style="color: #2563eb;">${link}</a></p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="color: #aaa; font-size: 12px;">If you didn't request this link, you can safely ignore this email.</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const codeText = code ? `\nOr enter this code: ${code}\n` : "";
|
||||
|
||||
const text = [
|
||||
"Sign in to trails.cool",
|
||||
"",
|
||||
`Click here to sign in: ${link}`,
|
||||
"",
|
||||
codeText,
|
||||
"This link expires in 15 minutes.",
|
||||
"",
|
||||
"If you didn't request this link, you can safely ignore this email.",
|
||||
|
|
@ -89,8 +99,8 @@ export function welcomeTemplate(username: string): { html: string; text: string
|
|||
|
||||
// --- Convenience wrappers ---
|
||||
|
||||
export async function sendMagicLink(email: string, link: string): Promise<void> {
|
||||
const { html, text } = magicLinkTemplate(link);
|
||||
export async function sendMagicLink(email: string, link: string, code?: string): Promise<void> {
|
||||
const { html, text } = magicLinkTemplate(link, code);
|
||||
await sendEmail(email, "Sign in to trails.cool", html, text);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue