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
@@ -0,0 +1,174 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { createTranslator } from "next-intl";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ar from "@/messages/ar.json";
|
||||
import bg from "@/messages/bg.json";
|
||||
import cs from "@/messages/cs.json";
|
||||
import da from "@/messages/da.json";
|
||||
import de from "@/messages/de.json";
|
||||
import el from "@/messages/el.json";
|
||||
import en from "@/messages/en.json";
|
||||
import es from "@/messages/es.json";
|
||||
import fi from "@/messages/fi.json";
|
||||
import fr from "@/messages/fr.json";
|
||||
import hr from "@/messages/hr.json";
|
||||
import hu from "@/messages/hu.json";
|
||||
import itMessages from "@/messages/it.json";
|
||||
import ja from "@/messages/ja.json";
|
||||
import nl from "@/messages/nl.json";
|
||||
import no from "@/messages/no.json";
|
||||
import pl from "@/messages/pl.json";
|
||||
import pt from "@/messages/pt.json";
|
||||
import ro from "@/messages/ro.json";
|
||||
import ru from "@/messages/ru.json";
|
||||
import sk from "@/messages/sk.json";
|
||||
import sr from "@/messages/sr.json";
|
||||
import sv from "@/messages/sv.json";
|
||||
import tr from "@/messages/tr.json";
|
||||
import uk from "@/messages/uk.json";
|
||||
|
||||
/**
|
||||
* Every failure reason the register action can report is a `RegisterErrorCode`,
|
||||
* which the form renders as `pages.register.<code>`. If a code ships without a
|
||||
* translation the visitor silently sees the raw English fallback, so this test
|
||||
* locks the contract in both directions: the union of codes must match the
|
||||
* dictionary, and every locale must carry every key.
|
||||
*/
|
||||
|
||||
type RegisterErrorCode =
|
||||
| "usernameMinLength"
|
||||
| "usernameMaxLength"
|
||||
| "usernamePattern"
|
||||
| "usernameReserved"
|
||||
| "usernameTaken"
|
||||
| "emailValid"
|
||||
| "emailDisposable"
|
||||
| "passwordMinLength"
|
||||
| "passwordMaxLength"
|
||||
| "passwordUpper"
|
||||
| "passwordLower"
|
||||
| "passwordDigit"
|
||||
| "passwordSpecial"
|
||||
| "passwordsMatch"
|
||||
| "termsRequired"
|
||||
| "captchaFailed"
|
||||
| "rateLimited"
|
||||
| "vpnBlocked"
|
||||
| "maxAccountsPerIp"
|
||||
| "unavailable"
|
||||
| "createFailed"
|
||||
| "invalidInput";
|
||||
|
||||
/** Mirrors `RegisterErrorCode` in src/actions/register.ts. */
|
||||
const REGISTER_ERROR_CODES: readonly RegisterErrorCode[] = [
|
||||
"usernameMinLength",
|
||||
"usernameMaxLength",
|
||||
"usernamePattern",
|
||||
"usernameReserved",
|
||||
"usernameTaken",
|
||||
"emailValid",
|
||||
"emailDisposable",
|
||||
"passwordMinLength",
|
||||
"passwordMaxLength",
|
||||
"passwordUpper",
|
||||
"passwordLower",
|
||||
"passwordDigit",
|
||||
"passwordSpecial",
|
||||
"passwordsMatch",
|
||||
"termsRequired",
|
||||
"captchaFailed",
|
||||
"rateLimited",
|
||||
"vpnBlocked",
|
||||
"maxAccountsPerIp",
|
||||
"unavailable",
|
||||
"createFailed",
|
||||
"invalidInput",
|
||||
];
|
||||
|
||||
const MESSAGES: Record<string, typeof en> = {
|
||||
ar,
|
||||
bg,
|
||||
cs,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
en,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
hr,
|
||||
hu,
|
||||
it: itMessages as unknown as typeof en,
|
||||
ja,
|
||||
nl: nl as unknown as typeof en,
|
||||
no,
|
||||
pl,
|
||||
pt,
|
||||
ro,
|
||||
ru,
|
||||
sk,
|
||||
sr,
|
||||
sv,
|
||||
tr,
|
||||
uk,
|
||||
};
|
||||
|
||||
const locales = readdirSync("src/messages")
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => file.replace(/\.json$/, ""))
|
||||
.sort();
|
||||
|
||||
const namespaces = ["pages.register", "pages.login", "pages.reset"] as const;
|
||||
|
||||
describe("auth page messages", () => {
|
||||
it("exposes every register error code as a translated message", () => {
|
||||
const t = createTranslator({
|
||||
locale: "en",
|
||||
messages: en,
|
||||
namespace: "pages.register",
|
||||
});
|
||||
for (const code of REGISTER_ERROR_CODES) {
|
||||
expect(t.has(code as never), code).toBe(true);
|
||||
expect(t(code as never), code).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the dictionary free of unreachable register error keys", () => {
|
||||
const known = new Set<string>(REGISTER_ERROR_CODES);
|
||||
for (const key of Object.keys(en.pages.register)) {
|
||||
// Keys that map a code onto its canonical sentence must stay in sync.
|
||||
if (key === "passwordMinLength") continue;
|
||||
expect(known.has(key) || !/^[a-z][A-Z]/.test(key), key).toBe(true);
|
||||
}
|
||||
expect(en.pages.register.passwordError).toBe(
|
||||
en.pages.register.passwordMinLength,
|
||||
);
|
||||
});
|
||||
|
||||
it("ships a dictionary for every locale on disk", () => {
|
||||
expect(Object.keys(MESSAGES).sort()).toEqual(locales);
|
||||
});
|
||||
|
||||
it.each(locales)("provides every auth message in %s", (locale) => {
|
||||
const messages = MESSAGES[locale];
|
||||
expect(messages, locale).toBeDefined();
|
||||
for (const namespace of namespaces) {
|
||||
const t = createTranslator({ locale, messages, namespace });
|
||||
const keys =
|
||||
namespace === "pages.register"
|
||||
? REGISTER_ERROR_CODES
|
||||
: namespace === "pages.login"
|
||||
? ([
|
||||
"username",
|
||||
"password",
|
||||
"showPassword",
|
||||
"hidePassword",
|
||||
] as const)
|
||||
: (["passwordMinLength", "tooManyAttempts"] as const);
|
||||
for (const key of keys) {
|
||||
expect(t.has(key as never), `${namespace}.${key}`).toBe(true);
|
||||
expect(t(key as never), `${namespace}.${key}`).not.toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user