Skip to content

Commit 85ae293

Browse files
B2JK-Industryclaude
andcommitted
fix(auth): PR-P G-01 — register validation hardened (5 of 7 patches)
GDPR-K + UX safety pass on the register flow. Patch 1 — birth year dropdown clamped (auth-form.tsx:99). Was 90 entries (1932-2021). Now 11 entries (currentYear-6 → currentYear-16) — exactly the GDPR-K target window (7-16) plus a 2-year buffer at each end for user error. Pre-teens can no longer pick birth years that fall outside parental-consent flow eligibility. Patch 2 — password strength gates (auth-form.tsx:82-83 + route.ts). Form: minLength 6 → 8, plus HTML5 pattern `(?=.*[a-zA-Z])(?=.*\d).{8,}` + localized title for keyboard-tooltip + screen-reader hint. Server mirrors with the same Zod regex so a hand-crafted POST can't bypass. Login mode keeps minLength 6 to avoid breaking existing accounts. Patch 3 — hardcoded PL labels → dict (auth-form.tsx:32, 89, 109, 117). 4 strings moved into `dict.auth.{errorBirthYearMissing,birthYearLabel, parentEmailLabel,parentEmailPlaceholder}` × 4 locales. UK/CS/EN players no longer see leaked Polish form chrome. Patch 4 — server-side birth year clamp (api/auth/register/route.ts:31-36). Zod `.min(CURRENT_YEAR - 16).max(CURRENT_YEAR - 6)` matches the client clamp. Validation failure now returns the first Zod issue's message instead of a generic "fill the form" string, so the client sees actionable error copy. Existing accounts unaffected — clamp gates new registrations only. Patch 5 — register chip title i18n (register/page.tsx:28). Was hardcoded EN ("GDPR-K compliant — automatic parental consent under 16"), leaked to all non-en locales. Now `t.gdprKTooltip` × 4 locales. Patch 7 — live password strength checklist (auth-form.tsx). Register-only. Three rules visible under password input as the user types (8+ chars / letter / digit), each flips ○ → ✓ + green when satisfied. Kids see WHY the form blocks submission instead of an opaque HTML5 alert. Hidden in login mode. Patch 6 (vitest register-validation test) deferred — schema + client form pattern give belt-and-braces coverage; a dedicated test file would be a Pass-11 polish. Validation: - pnpm typecheck → 0 errors - 5 of 7 spec patches landed; 2 deferred (test, G-22 CSRF audit) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 44f251a commit 85ae293

7 files changed

Lines changed: 104 additions & 12 deletions

File tree

app/api/auth/register/route.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,25 @@ const REGISTER_IP_WINDOW_MS = Number(process.env.REGISTER_IP_WINDOW_MS ?? 60_000
2626

2727
const BodySchema = z.object({
2828
username: z.string().min(1).max(64),
29-
password: z.string().min(1).max(200),
30-
// Phase 6.3.1: required. Client collects via a <select> of birth years.
29+
// G-01 patch 4 — server-side password rules mirror the form. Min 8
30+
// + one letter + one digit. The HTML5 pattern on the form already
31+
// blocks bad input, but a hand-crafted POST should not get through.
32+
password: z
33+
.string()
34+
.min(8)
35+
.max(200)
36+
.regex(/(?=.*[a-zA-Z])(?=.*\d).{8,}/, "Hasło: min. 8 znaków, 1 litera i 1 cyfra."),
37+
// G-01 patch 4 — birth year clamped to GDPR-K target range (7-16).
38+
// currentYear-6 = oldest birthYear that still qualifies as 7+,
39+
// currentYear-16 = youngest birthYear we accept (older players are
40+
// outside our child-product cohort and shouldn't be onboarded
41+
// through the kid signup flow). Existing accounts with legacy birth
42+
// years are unaffected — clamp gates new registrations only.
3143
birthYear: z
3244
.number()
3345
.int()
34-
.min(1900)
35-
.max(CURRENT_YEAR)
46+
.min(CURRENT_YEAR - 16)
47+
.max(CURRENT_YEAR - 6)
3648
.optional(),
3749
// Phase 6.3.2: if under-16, parent's email for consent dispatch.
3850
parentEmail: z.string().email().max(120).optional(),
@@ -67,8 +79,16 @@ export async function POST(request: NextRequest) {
6779
);
6880
}
6981
if (!parsed.success) {
82+
// G-01 patch 4 — surface the first Zod issue's message so password
83+
// rule violations + birth year clamps return useful client copy
84+
// instead of a generic "fill the form" string.
85+
const firstIssue = parsed.error.issues[0];
7086
return Response.json(
71-
{ ok: false, error: "Podaj nazwę, hasło i rok urodzenia." },
87+
{
88+
ok: false,
89+
error:
90+
firstIssue?.message ?? "Podaj nazwę, hasło i rok urodzenia.",
91+
},
7292
{ status: 400 },
7393
);
7494
}

app/register/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ export default async function RegisterPage() {
2525
</Link>
2626
</p>
2727
<div className="flex flex-wrap gap-2 pt-2 border-t border-[var(--line)]">
28-
<span className="chip" title="GDPR-K compliant — automatic parental consent under 16">
28+
{/* G-01 patch 5 — chip title was hardcoded EN, leaked to all
29+
non-en locales as untranslatable. Now reads from `t.gdprKTooltip`
30+
so PL/UK/CS players see localized tooltip text. */}
31+
<span className="chip" title={t.gdprKTooltip}>
2932
🔒 GDPR-K
3033
</span>
3134
<span className="chip" title="KNF / UOKiK aligned">

components/auth-form.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function AuthForm({ mode, dict }: Props) {
2929
const body: Record<string, unknown> = { username, password };
3030
if (mode === "register") {
3131
if (birthYear === "") {
32-
setError("Podaj rok urodzenia.");
32+
setError(t.errorBirthYearMissing);
3333
return;
3434
}
3535
body.birthYear = Number(birthYear);
@@ -79,14 +79,37 @@ export function AuthForm({ mode, dict }: Props) {
7979
onChange={(e) => setPassword(e.target.value)}
8080
autoComplete={mode === "login" ? "current-password" : "new-password"}
8181
required
82-
minLength={6}
82+
minLength={mode === "register" ? 8 : 6}
8383
maxLength={200}
84+
{...(mode === "register"
85+
? {
86+
pattern: "(?=.*[a-zA-Z])(?=.*\\d).{8,}",
87+
title: t.passwordTitle,
88+
}
89+
: {})}
8490
/>
91+
{/* G-01 patch 7 — live password strength checklist (register
92+
only). Strict GDPR-K + bank-grade discipline asks for an
93+
explicit visible rule list so kids see WHY the form is
94+
blocking submission, not just an opaque HTML5 alert. */}
95+
{mode === "register" && password.length > 0 && (
96+
<ul className="t-caption text-[var(--ink-muted)] flex flex-col gap-0.5 mt-1">
97+
<li className={password.length >= 8 ? "text-[var(--success)]" : ""}>
98+
{password.length >= 8 ? "✓" : "○"} {t.pwRule8chars}
99+
</li>
100+
<li className={/[a-zA-Z]/.test(password) ? "text-[var(--success)]" : ""}>
101+
{/[a-zA-Z]/.test(password) ? "✓" : "○"} {t.pwRuleLetter}
102+
</li>
103+
<li className={/\d/.test(password) ? "text-[var(--success)]" : ""}>
104+
{/\d/.test(password) ? "✓" : "○"} {t.pwRuleDigit}
105+
</li>
106+
</ul>
107+
)}
85108
</label>
86109
{mode === "register" && (
87110
<>
88111
<label className="flex flex-col gap-1.5">
89-
<span className="t-body-sm text-[var(--ink-muted)]">Rok urodzenia (RODO-K)</span>
112+
<span className="t-body-sm text-[var(--ink-muted)]">{t.birthYearLabel}</span>
90113
<select
91114
className="input"
92115
value={birthYear}
@@ -96,7 +119,13 @@ export function AuthForm({ mode, dict }: Props) {
96119
required
97120
>
98121
<option value=""></option>
99-
{Array.from({ length: 90 }, (_, i) => currentYear - 5 - i).map((y) => (
122+
{/* G-01 patch 1 — clamped to 11 entries (currentYear-6
123+
newest → currentYear-16 oldest), matching the GDPR-K
124+
child-product target audience (9-14) plus a 2-year
125+
buffer. The previous 90-entry range (1932-2021)
126+
served no realistic age band and let pre-teens select
127+
birth years that fall outside parental-consent flow. */}
128+
{Array.from({ length: 11 }, (_, i) => currentYear - 6 - i).map((y) => (
100129
<option key={y} value={y}>
101130
{y}
102131
</option>
@@ -106,15 +135,15 @@ export function AuthForm({ mode, dict }: Props) {
106135
{needsParent && (
107136
<label className="flex flex-col gap-1.5">
108137
<span className="t-body-sm text-[var(--ink-muted)]">
109-
E-mail rodzica (wymagane dla &lt; 16 lat)
138+
{t.parentEmailLabel}
110139
</span>
111140
<input
112141
type="email"
113142
className="input"
114143
value={parentEmail}
115144
onChange={(e) => setParentEmail(e.target.value)}
116145
required={needsParent}
117-
placeholder="rodzic@example.com"
146+
placeholder={t.parentEmailPlaceholder}
118147
maxLength={120}
119148
/>
120149
</label>

lib/locales/cs.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ const cs: typeof plDict = {
8585
"Registrací souhlasíš se zpracováním jména, hashe hesla a herních skóre. Žádný e-mail, žádná analytika. Účet můžeš kdykoli smazat jedním klikem.",
8686
errorGeneric: "Něco se nepovedlo.",
8787
errorNetwork: "Síťová chyba. Zkus znovu.",
88+
errorBirthYearMissing: "Zadej rok narození.",
89+
birthYearLabel: "Rok narození (GDPR-K)",
90+
parentEmailLabel: "E-mail rodiče (povinný pro < 16 let)",
91+
parentEmailPlaceholder: "rodic@example.com",
92+
passwordTitle: "Min. 8 znaků, 1 písmeno a 1 číslice",
93+
pwRule8chars: "Minimum 8 znaků",
94+
pwRuleLetter: "Alespoň 1 písmeno",
95+
pwRuleDigit: "Alespoň 1 číslice",
96+
gdprKTooltip:
97+
"Soulad s GDPR-K — automatický souhlas rodiče pro uživatele do 16 let.",
8898
},
8999
dashboard: {
90100
yourTier: "Tvůj tier",

lib/locales/en.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ const en: typeof plDict = {
8585
"By signing up you agree to us storing your username, password hash and game scores. No e-mail, no analytics, no advertisers. Delete your account anytime with one click.",
8686
errorGeneric: "Something went wrong.",
8787
errorNetwork: "Network error. Try again.",
88+
errorBirthYearMissing: "Enter your birth year.",
89+
birthYearLabel: "Birth year (GDPR-K)",
90+
parentEmailLabel: "Parent's email (required for under 16)",
91+
parentEmailPlaceholder: "parent@example.com",
92+
passwordTitle: "Min. 8 chars, 1 letter and 1 digit",
93+
pwRule8chars: "At least 8 characters",
94+
pwRuleLetter: "At least 1 letter",
95+
pwRuleDigit: "At least 1 digit",
96+
gdprKTooltip:
97+
"GDPR-K compliant — automatic parental consent for users under 16.",
8898
},
8999
dashboard: {
90100
yourTier: "Your tier",

lib/locales/pl.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ const pl = {
8383
"Rejestracją zgadzasz się na przetwarzanie nazwy, hashu hasła i wyników gier. Brak e-maila, brak analityki. Konto możesz usunąć jednym kliknięciem.",
8484
errorGeneric: "Coś poszło nie tak.",
8585
errorNetwork: "Błąd sieci. Spróbuj jeszcze raz.",
86+
errorBirthYearMissing: "Podaj rok urodzenia.",
87+
birthYearLabel: "Rok urodzenia (RODO-K)",
88+
parentEmailLabel: "E-mail rodzica (wymagane dla < 16 lat)",
89+
parentEmailPlaceholder: "rodzic@example.com",
90+
passwordTitle: "Min. 8 znaków, w tym 1 litera i 1 cyfra",
91+
pwRule8chars: "Minimum 8 znaków",
92+
pwRuleLetter: "Co najmniej 1 litera",
93+
pwRuleDigit: "Co najmniej 1 cyfra",
94+
gdprKTooltip:
95+
"Zgodne z RODO-K — automatyczna zgoda rodzica dla użytkowników < 16 lat.",
8696
},
8797
dashboard: {
8898
welcome: "Miasto gracza",

lib/locales/uk.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ const uk: typeof plDict = {
8585
"Реєстрацією погоджуєшся на обробку імені, хеша пароля та результатів гри. Без e-mail, без аналітики. Акаунт можеш видалити одним кліком.",
8686
errorGeneric: "Щось пішло не так.",
8787
errorNetwork: "Мережева помилка. Спробуй ще раз.",
88+
errorBirthYearMissing: "Вкажи рік народження.",
89+
birthYearLabel: "Рік народження (GDPR-K)",
90+
parentEmailLabel: "Email батьків (потрібно для віку < 16)",
91+
parentEmailPlaceholder: "батько@example.com",
92+
passwordTitle: "Мін. 8 символів, 1 літера і 1 цифра",
93+
pwRule8chars: "Мінімум 8 символів",
94+
pwRuleLetter: "Принаймні 1 літера",
95+
pwRuleDigit: "Принаймні 1 цифра",
96+
gdprKTooltip:
97+
"Відповідає GDPR-K — автоматична згода батьків для користувачів < 16 років.",
8898
},
8999
dashboard: {
90100
yourTier: "Твій тір",

0 commit comments

Comments
 (0)