From d3a40e32a047ed8e6be9ce3a1df24f9897843d6b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 1 May 2026 20:09:33 +0200 Subject: [PATCH] fix: localise auth emails, repair verify link, recover from expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave-3 user feedback (points 1–3): - Email templates: extract de/en/fr/es/it copy into emailTemplates.ts and pick the user's language. Signup forwards navigator.language and the API also respects Accept-Language; resend/reset reuse user.language. - Verify link: the email button now points at the client page instead of the API GET handler, so corporate inbox scanners (M365 Safe Links etc.) can no longer pre-fetch and silently consume the one-shot token. The client page requires a real click before calling PATCH /api/auth/ verify-email. The legacy GET handler now just redirects to the client page so old in-flight emails keep working. - Code expired UX: when users land via an expired-link redirect we no longer leave them with a blank email field — the page exposes an email input so the resend button has something to act on. The verify-email redirect also forwards the email param. Misc: tsconfig migrated to moduleResolution=bundler and dropped downlevelIteration to clear the TS 7.0 deprecation errors. v1.101.0 Co-Authored-By: Claude Opus 4.7 --- package.json | 2 +- src/app/api/auth/resend-verification/route.ts | 3 +- src/app/api/auth/reset-password/route.ts | 2 +- src/app/api/auth/signup/route.ts | 14 +- src/app/api/auth/verify-email/route.ts | 55 ++++--- src/app/auth/signup/page.tsx | 3 +- src/app/auth/verify-email/page.tsx | 83 ++++++++++- src/lib/email.ts | 86 ++++------- src/lib/emailTemplates.ts | 135 ++++++++++++++++++ tsconfig.json | 3 +- 10 files changed, 294 insertions(+), 92 deletions(-) create mode 100644 src/lib/emailTemplates.ts diff --git a/package.json b/package.json index 55203b7..488104c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.100.0", + "version": "1.101.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/auth/resend-verification/route.ts b/src/app/api/auth/resend-verification/route.ts index 0f88fe8..fad455e 100644 --- a/src/app/api/auth/resend-verification/route.ts +++ b/src/app/api/auth/resend-verification/route.ts @@ -20,6 +20,7 @@ export async function POST(request: NextRequest) { id: true, emailVerified: true, emailVerificationExpires: true, + language: true, }, }); @@ -57,7 +58,7 @@ export async function POST(request: NextRequest) { }); try { - await sendVerificationEmail(email, code, token); + await sendVerificationEmail(email, code, token, user.language); } catch (emailError) { console.error('Failed to resend verification email:', emailError); return NextResponse.json( diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 77434e7..435068e 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -30,7 +30,7 @@ export async function POST(request: NextRequest) { }, }); - await sendPasswordResetEmail(email, resetToken); + await sendPasswordResetEmail(email, resetToken, (user as any).language); return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' }); } diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 6430e54..1b4f560 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -3,12 +3,18 @@ export const dynamic = 'force-dynamic'; import { PrismaClient } from '@prisma/client'; import { hash } from 'bcryptjs'; import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email'; +import { languageFromAcceptLanguage } from '@/lib/emailTemplates'; const prisma = new PrismaClient(); export async function POST(request: NextRequest) { try { - const { name, email, password } = await request.json(); + const body = await request.json(); + const { name, email, password } = body; + // Prefer the explicit language from the form (filled in by the signup + // page using navigator.language) and fall back to Accept-Language. + const language = body.language + || languageFromAcceptLanguage(request.headers.get('accept-language')); if (!email || !password) { return NextResponse.json( @@ -42,11 +48,12 @@ export async function POST(request: NextRequest) { emailVerificationCode: code, emailVerificationToken: token, emailVerificationExpires: expires, + language, }, }); try { - await sendVerificationEmail(email, code, token); + await sendVerificationEmail(email, code, token, language); } catch (emailError) { console.error('Failed to send verification email:', emailError); } @@ -77,6 +84,7 @@ export async function POST(request: NextRequest) { name: name || undefined, email, passwordHash, + language, emailVerificationCode: code, emailVerificationToken: token, emailVerificationExpires: expires, @@ -92,7 +100,7 @@ export async function POST(request: NextRequest) { // Send verification email try { - await sendVerificationEmail(email, code, token); + await sendVerificationEmail(email, code, token, language); } catch (emailError) { console.error('Failed to send verification email:', emailError); // User is created but email failed — they can use "resend" later diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts index a75f662..8fc5613 100644 --- a/src/app/api/auth/verify-email/route.ts +++ b/src/app/api/auth/verify-email/route.ts @@ -78,31 +78,58 @@ export async function POST(request: NextRequest) { } } -// GET: Verify via one-click link +// GET: Legacy one-click link. +// +// This used to verify-and-redirect on a single GET, but corporate inbox +// scanners (M365 Safe Links, etc.) pre-fetch URLs to scan them — which +// silently consumed the one-shot token before the recipient ever saw the +// email (the bug behind point #2). New emails point straight at the client +// page; this handler only redirects there for backward compatibility with +// emails already in transit. export async function GET(request: NextRequest) { - try { - const token = request.nextUrl.searchParams.get('token'); + const token = request.nextUrl.searchParams.get('token'); + const target = new URL('/auth/verify-email', request.url); + if (token) target.searchParams.set('token', token); + return NextResponse.redirect(target); +} +// PATCH: Verify via token from the email link (called by the client page after +// the user actually clicks the button — survives scanner pre-fetches). +export async function PATCH(request: NextRequest) { + try { + const { token } = await request.json(); if (!token) { - return NextResponse.redirect( - new URL('/auth/verify-email?error=missing_token', request.url) - ); + return NextResponse.json({ error: 'Token is required' }, { status: 400 }); } const user = await prisma.user.findFirst({ where: { emailVerificationToken: token, - emailVerificationExpires: { gt: new Date() }, + }, + select: { + id: true, + email: true, + emailVerified: true, + emailVerificationExpires: true, }, }); + // Friendly path: token already consumed but the email is verified — treat as success. if (!user) { - return NextResponse.redirect( - new URL('/auth/verify-email?error=invalid_token', request.url) + return NextResponse.json({ error: 'Invalid token. It may have already been used.' }, { status: 400 }); + } + + if (user.emailVerified) { + return NextResponse.json({ message: 'Email already verified', email: user.email }); + } + + if (!user.emailVerificationExpires || new Date() > user.emailVerificationExpires) { + return NextResponse.json( + { error: 'Verification link expired. Please request a new code.', email: user.email }, + { status: 400 } ); } - // Verify the user await prisma.user.update({ where: { id: user.id }, data: { @@ -114,13 +141,9 @@ export async function GET(request: NextRequest) { }, }); - return NextResponse.redirect( - new URL('/auth/login?verified=true', request.url) - ); + return NextResponse.json({ message: 'Email verified successfully', email: user.email }); } catch (error) { console.error('Token verification error:', error); - return NextResponse.redirect( - new URL('/auth/verify-email?error=server_error', request.url) - ); + return NextResponse.json({ error: 'Failed to verify email' }, { status: 500 }); } } \ No newline at end of file diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 0265c7f..97d73dc 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -34,10 +34,11 @@ export default function SignupPage() { } try { + const language = (typeof navigator !== 'undefined' ? navigator.language : 'en').slice(0, 2).toLowerCase(); const response = await fetch('/api/auth/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, email, password }), + body: JSON.stringify({ name, email, password, language }), }); const data = await response.json(); diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 38d17d8..4a972e3 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -7,13 +7,16 @@ import Link from 'next/link'; function VerifyEmailContent() { const router = useRouter(); const searchParams = useSearchParams(); - const email = searchParams.get('email') || ''; + const initialEmail = searchParams.get('email') || ''; + const tokenFromUrl = searchParams.get('token') || ''; const error = searchParams.get('error'); + const [email, setEmail] = useState(initialEmail); const [code, setCode] = useState(['', '', '', '', '', '']); const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState(null); const [errorMsg, setErrorMsg] = useState(null); const [isResending, setIsResending] = useState(false); + const [isTokenVerifying, setIsTokenVerifying] = useState(false); const inputRefs = useRef<(HTMLInputElement | null)[]>([]); useEffect(() => { @@ -26,10 +29,38 @@ function VerifyEmailContent() { } }, [error]); - // Auto-focus first input + // Auto-focus first input (only if we're not in token-link mode) useEffect(() => { - inputRefs.current[0]?.focus(); - }, []); + if (!tokenFromUrl) inputRefs.current[0]?.focus(); + }, [tokenFromUrl]); + + // Token-from-link flow: user landed here from the email button; we wait + // for them to click "Verify Email" rather than firing automatically, so + // inbox scanners that pre-fetch the URL never consume the token. + const verifyByToken = async () => { + setIsTokenVerifying(true); + setErrorMsg(null); + setMessage(null); + try { + const response = await fetch('/api/auth/verify-email', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: tokenFromUrl }), + }); + const data = await response.json(); + if (data.email && !email) setEmail(data.email); + if (!response.ok) { + setErrorMsg(data.error || 'Verification failed'); + setIsTokenVerifying(false); + return; + } + setMessage('Email verified! Redirecting to login...'); + setTimeout(() => router.push('/auth/login?verified=true'), 1500); + } catch { + setErrorMsg('Something went wrong. Please try again.'); + setIsTokenVerifying(false); + } + }; const handleInput = (index: number, value: string) => { // Handle paste of full code @@ -140,9 +171,15 @@ function VerifyEmailContent() {

Verify your email

-

- We sent a 6-digit code to {email} -

+ {tokenFromUrl ? ( +

+ Click the button below to confirm{email ? <> {email} : ' your email'}. +

+ ) : ( +

+ We sent a 6-digit code to {email || 'your email'} +

+ )} {/* Messages */} {errorMsg && ( @@ -165,6 +202,38 @@ function VerifyEmailContent() { )} + {/* Email recovery field when we don't know it (e.g. landed via expired-link redirect). + Without this, the resend button has nothing to send to — that was the actual UX + gap behind point #1. */} + {!email && ( +
+ setEmail(e.target.value)} + /> +
+ )} + + {/* Token-link flow: prominent verify button */} + {tokenFromUrl && !message && ( +
+ +

+ Or enter the 6-digit code instead: +

+
+ )} + {/* Code Input */}
@@ -43,59 +50,32 @@ export async function sendVerificationEmail( - - - - -
-

- My Weekly ToDo's -

+

My Weekly ToDo's

-

- Welcome! Please verify your email address to complete your registration. -

- - +

${t.welcome}

-

- Your verification code -

-

- ${code} -

+

${t.yourCode}

+

${code}

- -

- Or click the button below to verify instantly: -

- - +

${t.orClick}

- - ✓ Verify Email - + ${t.button}
- -

- This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email. -

+

${t.expiresNote}

-

- Simple. Beautiful. Yours. -

+

${t.footer}

@@ -113,18 +93,15 @@ export async function sendVerificationEmail( console.log(`======================================================\n`); try { - // 15 second timeout for SMTP const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('SMTP timeout')), 15000) ); - const mailPromise = getTransporter().sendMail({ from, to: email, - subject: `${code} – Verify your email for My Weekly ToDo's`, + subject: t.subject(code), html, }); - const result = await Promise.race([mailPromise, timeoutPromise]) as any; console.log(`[EMAIL] Email sent successfully: ${result.messageId}`); } catch (err: any) { @@ -132,9 +109,10 @@ export async function sendVerificationEmail( } } -export async function sendPasswordResetEmail(email: string, token: string) { +export async function sendPasswordResetEmail(email: string, token: string, language?: string | null) { const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; const resetLink = `${baseUrl}/auth/reset-password?token=${token}`; + const t = getResetCopy(language); const html = ` @@ -150,35 +128,25 @@ export async function sendPasswordResetEmail(email: string, token: string) {
-

- My Weekly ToDo's -

+

My Weekly ToDo's

-

- We received a request to reset your password. Click the button below to choose a new one. -

+

${t.intro}

- - Reset Password - + ${t.button}
-

- This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email. -

+

${t.expiresNote}

-

- Simple. Beautiful. Yours. -

+

${t.footer}

@@ -199,14 +167,12 @@ export async function sendPasswordResetEmail(email: string, token: string) { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('SMTP timeout')), 15000) ); - const mailPromise = getTransporter().sendMail({ from, to: email, - subject: `Password Reset – My Weekly ToDo's`, + subject: t.subject, html, }); - const result = await Promise.race([mailPromise, timeoutPromise]) as any; console.log(`[EMAIL] Password reset email sent successfully: ${result.messageId}`); } catch (err: any) { diff --git a/src/lib/emailTemplates.ts b/src/lib/emailTemplates.ts new file mode 100644 index 0000000..e28c99f --- /dev/null +++ b/src/lib/emailTemplates.ts @@ -0,0 +1,135 @@ +// Localised email copy. Keep keys stable across languages so the template +// builders can swap them by language code without dynamic imports. + +type Lang = "en" | "de" | "fr" | "es" | "it"; + +interface VerificationCopy { + subject: (code: string) => string; + welcome: string; + yourCode: string; + orClick: string; + button: string; + expiresNote: string; + footer: string; +} + +interface ResetCopy { + subject: string; + intro: string; + button: string; + expiresNote: string; + footer: string; +} + +const VERIFICATION: Record = { + en: { + subject: (c) => `${c} – Verify your email for My Weekly ToDo's`, + welcome: "Welcome! Please verify your email address to complete your registration.", + yourCode: "Your verification code", + orClick: "Or click the button below to verify instantly:", + button: "✓ Verify Email", + expiresNote: "This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email.", + footer: "Simple. Beautiful. Yours.", + }, + de: { + subject: (c) => `${c} – Bestätige deine E-Mail-Adresse für My Weekly ToDo's`, + welcome: "Willkommen! Bitte bestätige deine E-Mail-Adresse, um deine Registrierung abzuschließen.", + yourCode: "Dein Bestätigungscode", + orClick: "Oder klicke unten, um sofort zu bestätigen:", + button: "✓ E-Mail bestätigen", + expiresNote: "Dieser Code läuft in 15 Minuten ab. Falls du diesen Account nicht erstellt hast, kannst du diese E-Mail einfach ignorieren.", + footer: "Einfach. Schön. Deins.", + }, + fr: { + subject: (c) => `${c} – Vérifiez votre adresse e-mail pour My Weekly ToDo's`, + welcome: "Bienvenue ! Veuillez vérifier votre adresse e-mail pour terminer votre inscription.", + yourCode: "Votre code de vérification", + orClick: "Ou cliquez sur le bouton ci-dessous pour vérifier instantanément :", + button: "✓ Vérifier l'e-mail", + expiresNote: "Ce code expire dans 15 minutes. Si vous n'avez pas créé de compte, vous pouvez ignorer cet e-mail.", + footer: "Simple. Beau. À vous.", + }, + es: { + subject: (c) => `${c} – Verifica tu correo para My Weekly ToDo's`, + welcome: "¡Bienvenido! Por favor verifica tu dirección de correo para completar tu registro.", + yourCode: "Tu código de verificación", + orClick: "O haz clic en el botón de abajo para verificar al instante:", + button: "✓ Verificar correo", + expiresNote: "Este código expira en 15 minutos. Si no creaste una cuenta, puedes ignorar este correo.", + footer: "Simple. Hermoso. Tuyo.", + }, + it: { + subject: (c) => `${c} – Verifica la tua email per My Weekly ToDo's`, + welcome: "Benvenuto! Verifica il tuo indirizzo email per completare la registrazione.", + yourCode: "Il tuo codice di verifica", + orClick: "Oppure clicca sul pulsante qui sotto per verificare subito:", + button: "✓ Verifica email", + expiresNote: "Questo codice scade tra 15 minuti. Se non hai creato un account, puoi ignorare questa email.", + footer: "Semplice. Bello. Tuo.", + }, +}; + +const RESET: Record = { + en: { + subject: "Password Reset – My Weekly ToDo's", + intro: "We received a request to reset your password. Click the button below to choose a new one.", + button: "Reset Password", + expiresNote: "This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.", + footer: "Simple. Beautiful. Yours.", + }, + de: { + subject: "Passwort zurücksetzen – My Weekly ToDo's", + intro: "Wir haben eine Anfrage zum Zurücksetzen deines Passworts erhalten. Klicke auf den Button unten, um ein neues Passwort zu wählen.", + button: "Passwort zurücksetzen", + expiresNote: "Dieser Link läuft in 1 Stunde ab. Falls du kein neues Passwort angefordert hast, kannst du diese E-Mail einfach ignorieren.", + footer: "Einfach. Schön. Deins.", + }, + fr: { + subject: "Réinitialisation du mot de passe – My Weekly ToDo's", + intro: "Nous avons reçu une demande de réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.", + button: "Réinitialiser le mot de passe", + expiresNote: "Ce lien expire dans 1 heure. Si vous n'avez pas demandé de réinitialisation, vous pouvez ignorer cet e-mail.", + footer: "Simple. Beau. À vous.", + }, + es: { + subject: "Restablecimiento de contraseña – My Weekly ToDo's", + intro: "Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para elegir una nueva.", + button: "Restablecer contraseña", + expiresNote: "Este enlace expira en 1 hora. Si no solicitaste un restablecimiento, puedes ignorar este correo.", + footer: "Simple. Hermoso. Tuyo.", + }, + it: { + subject: "Reimpostazione password – My Weekly ToDo's", + intro: "Abbiamo ricevuto una richiesta di reimpostazione della password. Clicca sul pulsante qui sotto per sceglierne una nuova.", + button: "Reimposta password", + expiresNote: "Questo link scade tra 1 ora. Se non hai richiesto la reimpostazione, puoi ignorare questa email.", + footer: "Semplice. Bello. Tuo.", + }, +}; + +function pickLang(input?: string | null): Lang { + if (!input) return "en"; + const code = input.toLowerCase().slice(0, 2); + if (code === "de" || code === "fr" || code === "es" || code === "it" || code === "en") return code; + return "en"; +} + +// Parse the first acceptable locale out of a raw Accept-Language header so we +// can pre-fill a user's UI language at signup. +export function languageFromAcceptLanguage(header?: string | null): Lang { + if (!header) return "en"; + const tags = header.split(",").map((t) => t.trim().split(";")[0]); + for (const t of tags) { + const code = t.toLowerCase().slice(0, 2); + if (code === "de" || code === "fr" || code === "es" || code === "it" || code === "en") return code; + } + return "en"; +} + +export function getVerificationCopy(language?: string | null): VerificationCopy { + return VERIFICATION[pickLang(language)]; +} + +export function getResetCopy(language?: string | null): ResetCopy { + return RESET[pickLang(language)]; +} diff --git a/tsconfig.json b/tsconfig.json index 5a4e6f6..3648563 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "target": "es2017", - "downlevelIteration": true, "lib": [ "dom", "dom.iterable", @@ -14,7 +13,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve",