fix: localise auth emails, repair verify link, recover from expired

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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-01 20:09:33 +02:00
parent ffe2f46c64
commit d3a40e32a0
10 changed files with 294 additions and 92 deletions

View File

@ -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": {

View File

@ -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(

View File

@ -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.' });
}

View File

@ -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

View File

@ -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 });
}
}

View File

@ -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();

View File

@ -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<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(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() {
<h2 style={{ color: '#333', fontSize: '1.25rem', fontWeight: 600, textAlign: 'center', margin: '0 0 8px' }}>
Verify your email
</h2>
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email}</strong>
</p>
{tokenFromUrl ? (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
Click the button below to confirm{email ? <> <strong style={{ color: '#667eea' }}>{email}</strong></> : ' your email'}.
</p>
) : (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email || 'your email'}</strong>
</p>
)}
{/* Messages */}
{errorMsg && (
@ -165,6 +202,38 @@ function VerifyEmailContent() {
</div>
)}
{/* 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 && (
<div className="weekly-auth-field" style={{ marginBottom: '16px' }}>
<input
type="email"
placeholder="Your email address"
autoComplete="email"
className="weekly-auth-input"
onChange={(e) => setEmail(e.target.value)}
/>
</div>
)}
{/* Token-link flow: prominent verify button */}
{tokenFromUrl && !message && (
<div style={{ marginBottom: '24px' }}>
<button
onClick={verifyByToken}
disabled={isTokenVerifying}
className="weekly-auth-button primary"
style={{ width: '100%' }}
>
{isTokenVerifying ? 'Verifying...' : 'Verify Email'}
</button>
<p style={{ color: '#888', fontSize: '0.8rem', textAlign: 'center', margin: '12px 0 0' }}>
Or enter the 6-digit code instead:
</p>
</div>
)}
{/* Code Input */}
<div style={{
display: 'flex',

View File

@ -1,4 +1,5 @@
import nodemailer from 'nodemailer';
import { getVerificationCopy, getResetCopy } from './emailTemplates';
let _transporter: nodemailer.Transporter | null = null;
@ -26,10 +27,16 @@ function getTransporter() {
export async function sendVerificationEmail(
email: string,
code: string,
token: string
token: string,
language?: string | null,
) {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const verifyLink = `${baseUrl}/api/auth/verify-email?token=${token}`;
// Point the button at the client verify page (not the API GET handler).
// This stops corporate email scanners (M365 Safe Links, etc.) from
// pre-fetching the URL and silently consuming the one-shot token before
// the user ever sees the email — which is the bug behind point #2.
const verifyLink = `${baseUrl}/auth/verify-email?token=${token}&email=${encodeURIComponent(email)}`;
const t = getVerificationCopy(language);
const html = `
<!DOCTYPE html>
@ -43,59 +50,32 @@ export async function sendVerificationEmail(
<tr>
<td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
<!-- Header -->
<tr>
<td style="padding: 32px 40px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
My Weekly ToDo's
</h1>
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 16px 40px;">
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
Welcome! Please verify your email address to complete your registration.
</p>
<!-- Code Box -->
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.welcome}</p>
<div style="background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px; padding: 24px; text-align: center; margin: 0 0 24px;">
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">
Your verification code
</p>
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">
${code}
</p>
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">${t.yourCode}</p>
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">${code}</p>
</div>
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">
Or click the button below to verify instantly:
</p>
<!-- Button -->
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">${t.orClick}</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 0 0 24px;">
<a href="${verifyLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
Verify Email
</a>
<a href="${verifyLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">${t.button}</a>
</td>
</tr>
</table>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email.
</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
Simple. Beautiful. Yours.
</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
</td>
</tr>
</table>
@ -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 = `
<!DOCTYPE html>
@ -150,35 +128,25 @@ export async function sendPasswordResetEmail(email: string, token: string) {
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 32px 40px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
My Weekly ToDo's
</h1>
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
</td>
</tr>
<tr>
<td style="padding: 16px 40px;">
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
We received a request to reset your password. Click the button below to choose a new one.
</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.intro}</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 0 0 24px;">
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
Reset Password
</a>
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">${t.button}</a>
</td>
</tr>
</table>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.
</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
Simple. Beautiful. Yours.
</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
</td>
</tr>
</table>
@ -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) {

135
src/lib/emailTemplates.ts Normal file
View File

@ -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<Lang, VerificationCopy> = {
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<Lang, ResetCopy> = {
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)];
}

View File

@ -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",