feat(i18n): implement dynamic system wording management

Add database schema and API endpoints for system wording keys and values
Replace hardcoded translations in frontend and backend with dynamic messages
Add System Wordings management interface to Admin view
This commit is contained in:
2026-05-02 11:48:11 +08:00
parent e8e20539c9
commit 976a2a2482
18 changed files with 2095 additions and 1087 deletions

View File

@@ -2,6 +2,7 @@ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } fr
import { promisify } from 'node:util';
import type { PoolClient, QueryResultRow } from 'pg';
import { pool, queryOne } from './db.ts';
import { systemMessage } from './systemWordingQueries.ts';
const scrypt = promisify(scryptCallback);
const passwordKeyLength = 64;
@@ -53,87 +54,64 @@ function statusError(message: string, statusCode: number): StatusError {
return error;
}
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): string {
const messages: Record<string, Record<AuthMessageKey, string>> = {
en: {
emailRequired: 'Email is required',
invalidEmail: 'Email format is invalid',
displayNameRequired: 'Display name is required',
displayNameLength: 'Display name must be 1 to 40 characters',
passwordLength: 'Password must be at least 8 characters',
invalidToken: 'The verification link is invalid or expired',
emailAlreadyRegistered: 'This email is already registered',
checkVerificationEmail: 'Please check your verification email',
emailVerified: 'Email verified',
invalidCredentials: 'Email or password is incorrect',
verifyEmailFirst: 'Please complete email verification first',
emailSubject: 'Verify your Pokopia Wiki email',
emailHtml:
'<p>Open the link below to verify your email:</p><p><a href="{url}">Verify email</a></p><p>The link expires in {hours} hours.</p>',
emailText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.'
},
'zh-CN': {
emailRequired: '请输入邮箱',
invalidEmail: '邮箱格式不正确',
displayNameRequired: '请输入显示名',
displayNameLength: '显示名长度需为 1 到 40 个字符',
passwordLength: '密码至少需要 8 个字符',
invalidToken: '验证链接无效或已过期',
emailAlreadyRegistered: '该邮箱已注册',
checkVerificationEmail: '请查收验证邮件',
emailVerified: '邮箱已验证',
invalidCredentials: '邮箱或密码不正确',
verifyEmailFirst: '请先完成邮箱验证',
emailSubject: '验证你的 Pokopia Wiki 邮箱',
emailHtml: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
emailText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。'
}
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): Promise<string> {
const messageKeys: Record<AuthMessageKey, string> = {
emailRequired: 'server.auth.emailRequired',
invalidEmail: 'server.auth.invalidEmail',
displayNameRequired: 'server.auth.displayNameRequired',
displayNameLength: 'server.auth.displayNameLength',
passwordLength: 'server.auth.passwordLength',
invalidToken: 'server.auth.invalidToken',
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
checkVerificationEmail: 'server.auth.checkVerificationEmail',
emailVerified: 'server.auth.emailVerified',
invalidCredentials: 'server.auth.invalidCredentials',
verifyEmailFirst: 'server.auth.verifyEmailFirst',
emailSubject: 'email.auth.verificationSubject',
emailHtml: 'email.auth.verificationHtml',
emailText: 'email.auth.verificationText'
};
let message = messages[locale]?.[key] ?? messages[defaultLocale][key];
for (const [paramKey, paramValue] of Object.entries(params)) {
message = message.replaceAll(`{${paramKey}}`, String(paramValue));
}
return message;
return systemMessage(locale || defaultLocale, messageKeys[key], params);
}
function cleanEmail(value: unknown, locale: string): string {
async function cleanEmail(value: unknown, locale: string): Promise<string> {
if (typeof value !== 'string') {
throw statusError(authMessage(locale, 'emailRequired'), 400);
throw statusError(await authMessage(locale, 'emailRequired'), 400);
}
const email = value.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw statusError(authMessage(locale, 'invalidEmail'), 400);
throw statusError(await authMessage(locale, 'invalidEmail'), 400);
}
return email;
}
function cleanDisplayName(value: unknown, locale: string): string {
async function cleanDisplayName(value: unknown, locale: string): Promise<string> {
if (typeof value !== 'string') {
throw statusError(authMessage(locale, 'displayNameRequired'), 400);
throw statusError(await authMessage(locale, 'displayNameRequired'), 400);
}
const displayName = value.trim();
if (displayName.length < 1 || displayName.length > 40) {
throw statusError(authMessage(locale, 'displayNameLength'), 400);
throw statusError(await authMessage(locale, 'displayNameLength'), 400);
}
return displayName;
}
function cleanPassword(value: unknown, locale: string): string {
async function cleanPassword(value: unknown, locale: string): Promise<string> {
if (typeof value !== 'string' || value.length < 8) {
throw statusError(authMessage(locale, 'passwordLength'), 400);
throw statusError(await authMessage(locale, 'passwordLength'), 400);
}
return value;
}
function cleanToken(value: unknown, locale: string): string {
async function cleanToken(value: unknown, locale: string): Promise<string> {
if (typeof value !== 'string' || value.trim().length < 32) {
throw statusError(authMessage(locale, 'invalidToken'), 400);
throw statusError(await authMessage(locale, 'invalidToken'), 400);
}
return value.trim();
@@ -219,6 +197,9 @@ function buildVerificationUrl(token: string): string {
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
const { apiKey, from } = getEmailConfig();
const verificationUrl = buildVerificationUrl(token);
const subject = await authMessage(locale, 'emailSubject');
const html = await authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours });
const text = await authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours });
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
@@ -228,9 +209,9 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
body: JSON.stringify({
from,
to: [email],
subject: authMessage(locale, 'emailSubject'),
html: authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }),
text: authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours })
subject,
html,
text
})
});
@@ -241,9 +222,9 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
}
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
const email = cleanEmail(payload.email, locale);
const displayName = cleanDisplayName(payload.displayName, locale);
const password = cleanPassword(payload.password, locale);
const email = await cleanEmail(payload.email, locale);
const displayName = await cleanDisplayName(payload.displayName, locale);
const password = await cleanPassword(payload.password, locale);
const passwordHash = await hashPassword(password);
const verificationToken = createPlainToken();
const verificationTokenHash = hashToken(verificationToken);
@@ -256,7 +237,7 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
);
if (existingUser?.email_verified_at) {
throw statusError(authMessage(locale, 'emailAlreadyRegistered'), 409);
throw statusError(await authMessage(locale, 'emailAlreadyRegistered'), 409);
}
const user = existingUser
@@ -295,11 +276,11 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
});
await sendVerificationEmail(email, verificationToken, locale);
return { message: authMessage(locale, 'checkVerificationEmail') };
return { message: await authMessage(locale, 'checkVerificationEmail') };
}
export async function verifyEmail(payload: Record<string, unknown>, locale = defaultLocale) {
const token = cleanToken(payload.token, locale);
const token = await cleanToken(payload.token, locale);
const tokenHash = hashToken(token);
return withTransaction(async (client) => {
@@ -317,7 +298,7 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
);
if (!tokenRow) {
throw statusError(authMessage(locale, 'invalidToken'), 400);
throw statusError(await authMessage(locale, 'invalidToken'), 400);
}
const user = await clientQueryOne<UserRow>(
@@ -332,31 +313,31 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
);
if (!user) {
throw statusError(authMessage(locale, 'invalidToken'), 400);
throw statusError(await authMessage(locale, 'invalidToken'), 400);
}
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
user.id
]);
return { message: authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
return { message: await authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
});
}
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
const email = cleanEmail(payload.email, locale);
const password = cleanPassword(payload.password, locale);
const email = await cleanEmail(payload.email, locale);
const password = await cleanPassword(payload.password, locale);
const user = await queryOne<LoginUserRow>(
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
[email]
);
if (!user || !(await verifyPassword(password, user.password_hash))) {
throw statusError(authMessage(locale, 'invalidCredentials'), 401);
throw statusError(await authMessage(locale, 'invalidCredentials'), 401);
}
if (!user.email_verified_at) {
throw statusError(authMessage(locale, 'verifyEmailFirst'), 403);
throw statusError(await authMessage(locale, 'verifyEmailFirst'), 403);
}
const sessionToken = createPlainToken();