Skip to content

Commit 75f1cdf

Browse files
authored
fix(auth): localize the ADR-0069 remediation gate and the auth split-panel (#2870) (#2875)
`RemediationOverlay` had no i18n at all. It is the full-screen gate mounted unconditionally at `ConsoleShell` (`fixed inset-0 z-[200]`, shown on `PASSWORD_EXPIRED` / `MFA_REQUIRED`) with no route around it, so a user who could not read English could not get back into the product. - New `auth.remediation.*` in all ten locale packs, covering the expired-password and MFA-enrolment branches plus the shared sign-out exit. - Failure messages are translated where they are raised, since they are held in component state; the server-authored message is left untouched and only its empty fallback is localized. - `auth.layout.*` for the auth split-panel, whose forms were already localized so the panel rendered half in the user's language and half in English. Both copies of `AuthPageLayout` are fixed — the one the auth pages import and the byte-identical one re-exported from the package root. Adds a locale-parity test over both namespaces (identical key set across all ten packs, non-empty leaves, prose differing from English), mutation-verified in both directions. Closes #2870.
1 parent 5141617 commit 75f1cdf

15 files changed

Lines changed: 495 additions & 25 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/app-shell": patch
4+
---
5+
6+
fix(auth): localize the ADR-0069 remediation gate and the auth split-panel (#2870)
7+
8+
`RemediationOverlay` had no i18n at all. It is the full-screen gate mounted
9+
unconditionally at `ConsoleShell` (`fixed inset-0 z-[200]`) that a user hits
10+
when the backend returns `PASSWORD_EXPIRED` or `MFA_REQUIRED` — there is no
11+
route around it, so a user who could not read English could not get back into
12+
the product. That makes it a usability block rather than a cosmetic gap.
13+
14+
- New `auth.remediation.*` namespace in all ten locale packs, covering both
15+
branches of the gate: expired-password (title, three field labels, submit /
16+
submitting, mismatch and failure messages) and MFA enrolment (password step,
17+
QR scan copy, backup-code disclosure, code entry, verify / verifying, and the
18+
enrolment and invalid-code failures), plus the shared "sign out instead" exit.
19+
- Validation and failure messages are translated where they are raised, since
20+
they are held in component state and rendered later.
21+
- The server-provided `remediationRequired.message` is left untouched; only the
22+
empty-message fallback is localized.
23+
- `AuthPageLayout`'s two marketing strings move to `auth.layout.*`. The forms it
24+
wraps were already localized, so the split-panel had been rendering half in
25+
the user's language and half in English.
26+
27+
Adds a locale-parity test over both namespaces, asserting an identical key set
28+
across all ten packs, a non-empty string at every leaf, and that prose differs
29+
from English (short labels like "Continue" legitimately collide). i18next falls
30+
back to `en` silently and its missing-key handler is dev-only, so a key added to
31+
one pack and forgotten elsewhere is invisible in whichever locales get tested by
32+
hand.

packages/app-shell/src/console/RemediationOverlay.tsx

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { useState } from 'react';
1010
import { useAuth } from '@object-ui/auth';
1111
import { Button, Input, Label } from '@object-ui/components';
12+
import { useObjectTranslation } from '@object-ui/i18n';
1213
import { toCanvas } from 'qrcode';
1314

1415
/**
@@ -38,19 +39,21 @@ export function RemediationOverlay() {
3839

3940
function SignOutLink() {
4041
const { signOut } = useAuth();
42+
const { t } = useObjectTranslation();
4143
return (
4244
<button
4345
type="button"
4446
onClick={() => { void signOut(); }}
4547
className="mt-4 w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline"
4648
>
47-
Sign out instead
49+
{t('auth.remediation.signOut')}
4850
</button>
4951
);
5052
}
5153

5254
function ExpiredPasswordForm({ message }: { message: string }) {
5355
const { changePassword, setRemediationRequired } = useAuth();
56+
const { t } = useObjectTranslation();
5457
const [current, setCurrent] = useState('');
5558
const [next, setNext] = useState('');
5659
const [confirm, setConfirm] = useState('');
@@ -60,44 +63,46 @@ function ExpiredPasswordForm({ message }: { message: string }) {
6063
const submit = async (e: React.FormEvent) => {
6164
e.preventDefault();
6265
setError(null);
63-
if (next !== confirm) { setError('New passwords do not match.'); return; }
66+
if (next !== confirm) { setError(t('auth.remediation.password.mismatch')); return; }
6467
setBusy(true);
6568
try {
6669
await changePassword(current, next);
6770
setRemediationRequired(null);
6871
window.location.reload();
6972
} catch (err) {
70-
setError(err instanceof Error ? err.message : 'Could not change password.');
73+
setError(err instanceof Error ? err.message : t('auth.remediation.password.failed'));
7174
setBusy(false);
7275
}
7376
};
7477

7578
return (
7679
<form onSubmit={submit} className="space-y-4">
7780
<div>
78-
<h2 className="text-lg font-semibold">Your password has expired</h2>
81+
<h2 className="text-lg font-semibold">{t('auth.remediation.password.title')}</h2>
82+
{/* `message` is server-authored (localized upstream or not at all); only
83+
the empty-message fallback is ours to translate. */}
7984
<p className="mt-1 text-sm text-muted-foreground">
80-
{message || 'Please set a new password to continue.'}
85+
{message || t('auth.remediation.password.fallbackMessage')}
8186
</p>
8287
</div>
8388
<div className="space-y-2">
84-
<Label htmlFor="rem-cur">Current password</Label>
89+
<Label htmlFor="rem-cur">{t('auth.remediation.password.current')}</Label>
8590
<Input id="rem-cur" type="password" autoComplete="current-password" value={current}
8691
onChange={(e) => setCurrent(e.target.value)} required />
8792
</div>
8893
<div className="space-y-2">
89-
<Label htmlFor="rem-new">New password</Label>
94+
<Label htmlFor="rem-new">{t('auth.remediation.password.next')}</Label>
9095
<Input id="rem-new" type="password" autoComplete="new-password" value={next}
9196
onChange={(e) => setNext(e.target.value)} required />
9297
</div>
9398
<div className="space-y-2">
94-
<Label htmlFor="rem-conf">Confirm new password</Label>
99+
<Label htmlFor="rem-conf">{t('auth.remediation.password.confirm')}</Label>
95100
<Input id="rem-conf" type="password" autoComplete="new-password" value={confirm}
96101
onChange={(e) => setConfirm(e.target.value)} required />
97102
</div>
98103
{error ? <p className="text-sm text-destructive">{error}</p> : null}
99104
<Button type="submit" className="w-full" disabled={busy}>
100-
{busy ? 'Updating…' : 'Change password & continue'}
105+
{busy ? t('auth.remediation.password.submitting') : t('auth.remediation.password.submit')}
101106
</Button>
102107
<SignOutLink />
103108
</form>
@@ -119,6 +124,7 @@ function TotpQr({ uri }: { uri: string }) {
119124

120125
function MfaEnrollForm({ message }: { message: string }) {
121126
const { enrollTotp, verifyTotp, setRemediationRequired } = useAuth();
127+
const { t } = useObjectTranslation();
122128
const [step, setStep] = useState<'password' | 'verify'>('password');
123129
const [password, setPassword] = useState('');
124130
const [code, setCode] = useState('');
@@ -136,7 +142,7 @@ function MfaEnrollForm({ message }: { message: string }) {
136142
setBackupCodes(codes ?? []);
137143
setStep('verify');
138144
} catch (err) {
139-
setError(err instanceof Error ? err.message : 'Could not start enrollment.');
145+
setError(err instanceof Error ? err.message : t('auth.remediation.mfa.enrollFailed'));
140146
} finally {
141147
setBusy(false);
142148
}
@@ -150,7 +156,7 @@ function MfaEnrollForm({ message }: { message: string }) {
150156
setRemediationRequired(null);
151157
window.location.reload();
152158
} catch (err) {
153-
setError(err instanceof Error ? err.message : 'Invalid code. Try again.');
159+
setError(err instanceof Error ? err.message : t('auth.remediation.mfa.invalidCode'));
154160
setBusy(false);
155161
}
156162
};
@@ -159,19 +165,19 @@ function MfaEnrollForm({ message }: { message: string }) {
159165
return (
160166
<form onSubmit={start} className="space-y-4">
161167
<div>
162-
<h2 className="text-lg font-semibold">Set up two-factor authentication</h2>
168+
<h2 className="text-lg font-semibold">{t('auth.remediation.mfa.title')}</h2>
163169
<p className="mt-1 text-sm text-muted-foreground">
164-
{message || 'Your organization requires an authenticator app to continue.'}
170+
{message || t('auth.remediation.mfa.fallbackMessage')}
165171
</p>
166172
</div>
167173
<div className="space-y-2">
168-
<Label htmlFor="rem-pw">Confirm your password</Label>
174+
<Label htmlFor="rem-pw">{t('auth.remediation.mfa.confirmPassword')}</Label>
169175
<Input id="rem-pw" type="password" autoComplete="current-password" value={password}
170176
onChange={(e) => setPassword(e.target.value)} required />
171177
</div>
172178
{error ? <p className="text-sm text-destructive">{error}</p> : null}
173179
<Button type="submit" className="w-full" disabled={busy}>
174-
{busy ? 'Preparing…' : 'Continue'}
180+
{busy ? t('auth.remediation.mfa.preparing') : t('auth.remediation.mfa.continue')}
175181
</Button>
176182
<SignOutLink />
177183
</form>
@@ -181,29 +187,29 @@ function MfaEnrollForm({ message }: { message: string }) {
181187
return (
182188
<form onSubmit={verify} className="space-y-4">
183189
<div>
184-
<h2 className="text-lg font-semibold">Scan with your authenticator</h2>
190+
<h2 className="text-lg font-semibold">{t('auth.remediation.mfa.scanTitle')}</h2>
185191
<p className="mt-1 text-sm text-muted-foreground">
186-
Scan this QR code with Google Authenticator, 1Password, Authy, etc., then enter the 6-digit code.
192+
{t('auth.remediation.mfa.scanBody')}
187193
</p>
188194
</div>
189195
{totpUri ? <TotpQr uri={totpUri} /> : null}
190196
{backupCodes.length > 0 ? (
191197
<details className="rounded-md border bg-muted/40 p-3 text-xs">
192-
<summary className="cursor-pointer font-medium">Save your backup codes</summary>
193-
<p className="mt-1 text-muted-foreground">Store these somewhere safe — each can be used once if you lose your device.</p>
198+
<summary className="cursor-pointer font-medium">{t('auth.remediation.mfa.backupTitle')}</summary>
199+
<p className="mt-1 text-muted-foreground">{t('auth.remediation.mfa.backupBody')}</p>
194200
<div className="mt-2 grid grid-cols-2 gap-1 font-mono">
195201
{backupCodes.map((c) => <span key={c}>{c}</span>)}
196202
</div>
197203
</details>
198204
) : null}
199205
<div className="space-y-2">
200-
<Label htmlFor="rem-code">6-digit code</Label>
206+
<Label htmlFor="rem-code">{t('auth.remediation.mfa.codeLabel')}</Label>
201207
<Input id="rem-code" inputMode="numeric" autoComplete="one-time-code" maxLength={8}
202208
placeholder="123456" value={code} onChange={(e) => setCode(e.target.value)} required />
203209
</div>
204210
{error ? <p className="text-sm text-destructive">{error}</p> : null}
205211
<Button type="submit" className="w-full" disabled={busy}>
206-
{busy ? 'Verifying…' : 'Verify & continue'}
212+
{busy ? t('auth.remediation.mfa.verifying') : t('auth.remediation.mfa.verify')}
207213
</Button>
208214
<SignOutLink />
209215
</form>

packages/app-shell/src/console/auth/AuthPageLayout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
*/
66

77
import type React from 'react';
8+
import { useObjectTranslation } from '@object-ui/i18n';
89
import { getProductName, getLogoUrl } from '../../runtime-config';
910

1011
export function AuthPageLayout({ children }: { children: React.ReactNode }) {
12+
const { t } = useObjectTranslation();
1113
const productName = getProductName();
1214
const logoUrl = getLogoUrl();
1315
return (
@@ -38,10 +40,10 @@ export function AuthPageLayout({ children }: { children: React.ReactNode }) {
3840
<span className="text-2xl font-bold">{productName}</span>
3941
</div>
4042
<h2 className="text-3xl font-bold leading-tight">
41-
Build powerful business applications, faster.
43+
{t('auth.layout.headline')}
4244
</h2>
4345
<p className="text-lg opacity-90">
44-
The universal platform for enterprise data management, workflows, and analytics.
46+
{t('auth.layout.subhead')}
4547
</p>
4648
</div>
4749
</div>

packages/app-shell/src/layout/AuthPageLayout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
*/
66

77
import type React from 'react';
8+
import { useObjectTranslation } from '@object-ui/i18n';
89
import { getProductName, getLogoUrl } from '../runtime-config';
910

1011
export function AuthPageLayout({ children }: { children: React.ReactNode }) {
12+
const { t } = useObjectTranslation();
1113
const productName = getProductName();
1214
const logoUrl = getLogoUrl();
1315
return (
@@ -38,10 +40,10 @@ export function AuthPageLayout({ children }: { children: React.ReactNode }) {
3840
<span className="text-2xl font-bold">{productName}</span>
3941
</div>
4042
<h2 className="text-3xl font-bold leading-tight">
41-
Build powerful business applications, faster.
43+
{t('auth.layout.headline')}
4244
</h2>
4345
<p className="text-lg opacity-90">
44-
The universal platform for enterprise data management, workflows, and analytics.
46+
{t('auth.layout.subhead')}
4547
</p>
4648
</div>
4749
</div>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* auth.remediation / auth.layout locale parity (#2870).
3+
*
4+
* `RemediationOverlay` is the ADR-0069 full-screen gate: when the backend
5+
* returns `PASSWORD_EXPIRED` or `MFA_REQUIRED`, it covers the app
6+
* (`fixed inset-0 z-[200]`) and there is no route around it. A user who cannot
7+
* read it cannot get back into the product — so a missing key here is not
8+
* cosmetic the way a missing page title is.
9+
*
10+
* i18next falls back to `en` silently (`fallbackLng: 'en'`, and the
11+
* missing-key handler is dev-only), so a key added to one pack and forgotten
12+
* in the others looks fine in whichever two locales get tested by hand. This
13+
* pins the whole namespace across all ten packs.
14+
*/
15+
import { describe, it, expect } from 'vitest';
16+
import { builtInLocales } from '../locales';
17+
18+
/** Flatten a nested translation node into sorted dot-paths. */
19+
function keyPaths(node: unknown, prefix = ''): string[] {
20+
if (node === null || typeof node !== 'object') return [prefix];
21+
return Object.entries(node as Record<string, unknown>)
22+
.flatMap(([k, v]) => keyPaths(v, prefix ? `${prefix}.${k}` : k))
23+
.sort();
24+
}
25+
26+
/** Read a dot-path out of a nested translation node. */
27+
function readPath(node: unknown, path: string): unknown {
28+
return path.split('.').reduce<unknown>(
29+
(acc, seg) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[seg] : undefined),
30+
node,
31+
);
32+
}
33+
34+
type LocaleCode = keyof typeof builtInLocales;
35+
36+
const LOCALES = Object.keys(builtInLocales) as LocaleCode[];
37+
const NAMESPACES = ['auth.remediation', 'auth.layout'] as const;
38+
39+
const nodeOf = (code: LocaleCode, ns: string): unknown =>
40+
readPath(builtInLocales[code], ns);
41+
42+
describe.each(NAMESPACES)('%s translations', (ns) => {
43+
it('is present in every built-in locale pack', () => {
44+
const missing = LOCALES.filter((code) => !nodeOf(code, ns));
45+
expect(missing).toEqual([]);
46+
});
47+
48+
it('exposes an identical key set in every locale', () => {
49+
const expected = keyPaths(nodeOf('en', ns));
50+
// Guard against the flattener silently returning nothing.
51+
expect(expected.length).toBeGreaterThan(1);
52+
53+
for (const code of LOCALES) {
54+
expect({ code, keys: keyPaths(nodeOf(code, ns)) }).toEqual({ code, keys: expected });
55+
}
56+
});
57+
58+
it('has a non-empty string at every leaf', () => {
59+
for (const code of LOCALES) {
60+
const node = nodeOf(code, ns);
61+
for (const path of keyPaths(node)) {
62+
const value = readPath(node, path);
63+
expect({ code, path, ok: typeof value === 'string' && value.trim().length > 0 })
64+
.toEqual({ code, path, ok: true });
65+
}
66+
}
67+
});
68+
69+
it('actually translates the prose, rather than copying English', () => {
70+
// Short labels legitimately collide across languages ("Continue" is
71+
// "Continue" in French); only prose is checked, where a verbatim match
72+
// means the pack was never translated.
73+
const prose = keyPaths(nodeOf('en', ns)).filter((p) => {
74+
const v = readPath(nodeOf('en', ns), p);
75+
return typeof v === 'string' && v.length > 40;
76+
});
77+
expect(prose.length).toBeGreaterThan(0);
78+
79+
for (const code of LOCALES.filter((c) => c !== 'en')) {
80+
for (const path of prose) {
81+
expect({ code, path, sameAsEnglish: readPath(nodeOf(code, ns), path) === readPath(nodeOf('en', ns), path) })
82+
.toEqual({ code, path, sameAsEnglish: false });
83+
}
84+
}
85+
});
86+
});

packages/i18n/src/locales/ar.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,6 +1275,40 @@ const ar = {
12751275
shell: {
12761276
tenantHostHint: "تسجل الدخول إلى مساحة العمل هذه",
12771277
},
1278+
layout: {
1279+
headline: "أنشئ تطبيقات أعمال قوية بسرعة أكبر.",
1280+
subhead: "المنصة الموحدة لإدارة بيانات المؤسسات وسير العمل والتحليلات.",
1281+
},
1282+
remediation: {
1283+
signOut: "تسجيل الخروج بدلاً من ذلك",
1284+
password: {
1285+
title: "انتهت صلاحية كلمة المرور",
1286+
fallbackMessage: "يرجى تعيين كلمة مرور جديدة للمتابعة.",
1287+
current: "كلمة المرور الحالية",
1288+
next: "كلمة المرور الجديدة",
1289+
confirm: "تأكيد كلمة المرور الجديدة",
1290+
submit: "تغيير كلمة المرور والمتابعة",
1291+
submitting: "جارٍ التحديث…",
1292+
mismatch: "كلمتا المرور الجديدتان غير متطابقتين.",
1293+
failed: "تعذّر تغيير كلمة المرور.",
1294+
},
1295+
mfa: {
1296+
title: "إعداد المصادقة الثنائية",
1297+
fallbackMessage: "تتطلب مؤسستك تطبيق مصادقة للمتابعة.",
1298+
confirmPassword: "أكّد كلمة المرور",
1299+
continue: "متابعة",
1300+
preparing: "جارٍ التحضير…",
1301+
enrollFailed: "تعذّر بدء التسجيل.",
1302+
scanTitle: "امسح الرمز بتطبيق المصادقة",
1303+
scanBody: "امسح رمز QR هذا باستخدام Google Authenticator أو 1Password أو Authy وغيرها، ثم أدخل الرمز المكوّن من 6 أرقام.",
1304+
backupTitle: "احفظ رموز النسخ الاحتياطي",
1305+
backupBody: "احتفظ بها في مكان آمن — يمكن استخدام كل رمز مرة واحدة إذا فقدت جهازك.",
1306+
codeLabel: "رمز مكوّن من 6 أرقام",
1307+
verify: "تحقّق وتابع",
1308+
verifying: "جارٍ التحقق…",
1309+
invalidCode: "رمز غير صالح. حاول مرة أخرى.",
1310+
},
1311+
},
12781312
},
12791313
profile: {
12801314
title: "الملف الشخصي",

0 commit comments

Comments
 (0)