refactor(auth): merge the duplicate login form and localize the auth screens
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s
`home-login-form.tsx` and `login-form.tsx` were two ~240-line near-identical components. Delete the former and give `LoginForm` a `variant` prop: - `variant="page"` sr-only labels plus the register/forgot footer (/login) - `variant="compact"` visible labels, no footer (homepage sidebar) Field ids now come from `useId()`, so the two usages can never collide, and the hardcoded "Show"/"Hide"/"Loading" strings are translated. Localization of the login and register screens: - `home-login-form.tsx` was entirely hardcoded English. - `passwordStrength()` returned hardcoded "Weak"/"Fair"/"Good"/"Strong". - `register.ts` returned only English strings. It now returns a locale-independent `code` next to the message, and the form renders `t(code)` with the English string as a fallback. - Backfilled the new keys across all 25 locales, plus the login/register strings that were still English in most of them. `ar`, `fi` and `ja` had their entire login/register namespace in English and are now filled in. Locale parity stays at 0 missing keys, as `i18n:check` requires. Copy that did not match the enforced rules: the UI advertised "min 8 chars" (EN) / "min 6 tekens" (NL) while registration requires 12 characters plus an uppercase, a lowercase, a digit and a special character. Corrected in every locale. `password-reset.ts` enforced only 6 characters and is raised to 12 to match registration. Accessibility: `login-form.tsx` had no `<label>`, no `id` and no `required` on any field. All three are now present, and error banners are announced with `role="alert"`. Adds `src/i18n/auth-messages.test.ts`, which asserts every `RegisterErrorCode` resolves to a non-empty message in all 25 locales; verified it fails when a key is removed. The existing register tests now also assert the error `code`.
This commit is contained in:
1 parent
3d828a61ab
commit
6bffc53779
33 files changed
+1756
-842
No files matched your search
+72
-7
@@ -121,19 +121,72 @@ const registerSchema = z
|
||||
path: ["passwordConfirmation"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable, locale-independent reason for a failed sign-up. The client maps these
|
||||
* onto `pages.register.<code>` so the form speaks the visitor's language; the
|
||||
* English `error` string stays as a fallback and for API/log consumers.
|
||||
*/
|
||||
export type RegisterErrorCode =
|
||||
| "usernameMinLength"
|
||||
| "usernameMaxLength"
|
||||
| "usernamePattern"
|
||||
| "usernameReserved"
|
||||
| "usernameTaken"
|
||||
| "emailValid"
|
||||
| "emailDisposable"
|
||||
| "passwordMinLength"
|
||||
| "passwordMaxLength"
|
||||
| "passwordUpper"
|
||||
| "passwordLower"
|
||||
| "passwordDigit"
|
||||
| "passwordSpecial"
|
||||
| "passwordsMatch"
|
||||
| "termsRequired"
|
||||
| "captchaFailed"
|
||||
| "rateLimited"
|
||||
| "vpnBlocked"
|
||||
| "maxAccountsPerIp"
|
||||
| "unavailable"
|
||||
| "createFailed"
|
||||
| "invalidInput";
|
||||
|
||||
/** Maps the schema's English messages onto locale-independent codes. */
|
||||
const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
"Username must be at least 3 characters": "usernameMinLength",
|
||||
"Username must be at most 25 characters": "usernameMaxLength",
|
||||
"Username may only contain letters, numbers, underscore and hyphen":
|
||||
"usernamePattern",
|
||||
"This username is reserved": "usernameReserved",
|
||||
"Enter a valid email address": "emailValid",
|
||||
"Temporary email domains are not allowed": "emailDisposable",
|
||||
"Password must be at least 12 characters": "passwordMinLength",
|
||||
"Password is too long": "passwordMaxLength",
|
||||
"Password must contain at least one uppercase letter": "passwordUpper",
|
||||
"Password must contain at least one lowercase letter": "passwordLower",
|
||||
"Password must contain at least one digit": "passwordDigit",
|
||||
"Password must contain at least one special character": "passwordSpecial",
|
||||
"Passwords do not match": "passwordsMatch",
|
||||
};
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
|
||||
export interface RegisterState {
|
||||
error: string | null;
|
||||
ok: boolean;
|
||||
/** Locale-independent reason, present on every failure. */
|
||||
code?: RegisterErrorCode;
|
||||
}
|
||||
|
||||
export async function register(
|
||||
_prevState: RegisterState,
|
||||
formData: FormData,
|
||||
): Promise<RegisterState> {
|
||||
const fail = (error: string): RegisterState => ({ error, ok: false });
|
||||
const fail = (error: string, code: RegisterErrorCode): RegisterState => ({
|
||||
error,
|
||||
ok: false,
|
||||
code,
|
||||
});
|
||||
const raw = {
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -155,7 +208,8 @@ export async function register(
|
||||
|
||||
const parsed = registerSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
|
||||
const message = parsed.error.issues[0]?.message ?? "Invalid input";
|
||||
return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput");
|
||||
}
|
||||
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
@@ -166,6 +220,7 @@ export async function register(
|
||||
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
|
||||
return fail(
|
||||
"Too many sign-up attempts. Please wait a few minutes and try again.",
|
||||
"rateLimited",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,18 +229,25 @@ export async function register(
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
||||
if (!(await verifyCaptcha(token, ip)))
|
||||
return fail("Captcha verification failed. Please try again.");
|
||||
return fail(
|
||||
"Captcha verification failed. Please try again.",
|
||||
"captchaFailed",
|
||||
);
|
||||
}
|
||||
|
||||
// Terms acceptance check.
|
||||
if (!raw.termsAccepted)
|
||||
return fail("You must accept the terms and conditions to register.");
|
||||
return fail(
|
||||
"You must accept the terms and conditions to register.",
|
||||
"termsRequired",
|
||||
);
|
||||
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if ((await checkVpn(ip)).blocked) {
|
||||
return fail(
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed.",
|
||||
"vpnBlocked",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,6 +262,7 @@ export async function register(
|
||||
if (Number(row?.total ?? 0) >= max)
|
||||
return fail(
|
||||
"You have reached the maximum number of accounts for your connection.",
|
||||
"maxAccountsPerIp",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,10 +273,11 @@ export async function register(
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (existing) return fail("That username is already taken");
|
||||
if (existing)
|
||||
return fail("That username is already taken", "usernameTaken");
|
||||
} catch {
|
||||
logger.warn("Username uniqueness check failed during registration");
|
||||
return fail("Registration is temporarily unavailable");
|
||||
return fail("Registration is temporarily unavailable", "unavailable");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -233,7 +297,7 @@ export async function register(
|
||||
} catch (err) {
|
||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||
if (code === "ER_DUP_ENTRY") {
|
||||
return fail("That username is already taken");
|
||||
return fail("That username is already taken", "usernameTaken");
|
||||
}
|
||||
logger.error("Account creation failed", {
|
||||
code,
|
||||
@@ -241,6 +305,7 @@ export async function register(
|
||||
});
|
||||
return fail(
|
||||
"Could not create the account. Please try again or contact staff.",
|
||||
"createFailed",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user