mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
fix(i18n): guard locale key parity and finish the OAuth consent page strings
Every non-en locale now exposes the exact same flat key set as en. Keys that had drifted out of sync are backfilled with the English source value (tagged en-fallback) so t() resolves a real string instead of relying on the silent runtime fallback; no existing translation was touched and no key was removed. Add a parity test that imports each aggregated locale bundle and asserts its key set matches en, with a diagnostic listing of any missing/extra keys. This complements the file-level check in shared/scripts by guarding the merged export the app actually serves. Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded English chrome strings now go through oauth.authorize.* keys (English source in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.
This commit is contained in:
@@ -20,7 +20,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||
{pageState === 'auto_approving' ? t('oauth.authorize.authorizing') : t('oauth.authorize.loading')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>{t('oauth.authorize.errorTitle')}</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,9 +45,9 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>{t('oauth.authorize.loginTitle')}</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||
{t('oauth.authorize.loginDescription', { client: validation?.client?.name || clientId })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -55,7 +55,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
<LogIn className="w-4 h-4" />
|
||||
Sign in to TREK
|
||||
{t('oauth.authorize.loginButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,19 +74,19 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>{t('oauth.authorize.requestLabel')}</p>
|
||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{validation?.client?.name || clientId}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
This application is requesting access to your TREK account.
|
||||
{t('oauth.authorize.requestDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Only grant access to applications you trust. Your data stays on your server.
|
||||
{t('oauth.authorize.trustNote')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => submitConsent(true)}
|
||||
@@ -94,19 +94,24 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{submitting
|
||||
? 'Authorizing…'
|
||||
? t('oauth.authorize.authorizing')
|
||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||
? 'Select at least one scope'
|
||||
? t('oauth.authorize.selectScope')
|
||||
: validation?.scopeSelectable
|
||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||
: 'Approve Access'}
|
||||
? t(
|
||||
selectedScopes.length !== 1
|
||||
? 'oauth.authorize.approveManyScopes'
|
||||
: 'oauth.authorize.approveOneScope',
|
||||
{ count: selectedScopes.length },
|
||||
)
|
||||
: t('oauth.authorize.approveAccess')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitConsent(false)}
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
Deny
|
||||
{t('oauth.authorize.deny')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +122,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
{Object.keys(scopesByGroup).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||
{validation?.scopeSelectable ? t('oauth.authorize.choosePermissions') : t('oauth.authorize.permissionsRequested')}
|
||||
</p>
|
||||
|
||||
{validation?.scopeSelectable ? (
|
||||
@@ -201,12 +206,12 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
{/* Always-available tools — granted regardless of scopes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Always included
|
||||
{t('oauth.authorize.alwaysIncluded')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||
{ name: 'list_trips', desc: t('oauth.authorize.alwaysTool.listTrips') },
|
||||
{ name: 'get_trip_summary', desc: t('oauth.authorize.alwaysTool.getTripSummary') },
|
||||
].map(({ name, desc }) => (
|
||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { TranslationStrings } from '@trek/shared/i18n'
|
||||
import en from '@trek/shared/i18n/en'
|
||||
import de from '@trek/shared/i18n/de'
|
||||
import es from '@trek/shared/i18n/es'
|
||||
import fr from '@trek/shared/i18n/fr'
|
||||
import hu from '@trek/shared/i18n/hu'
|
||||
import itIT from '@trek/shared/i18n/it'
|
||||
import tr from '@trek/shared/i18n/tr'
|
||||
import ru from '@trek/shared/i18n/ru'
|
||||
import zh from '@trek/shared/i18n/zh'
|
||||
import zhTW from '@trek/shared/i18n/zh-TW'
|
||||
import nl from '@trek/shared/i18n/nl'
|
||||
import idID from '@trek/shared/i18n/id'
|
||||
import ar from '@trek/shared/i18n/ar'
|
||||
import br from '@trek/shared/i18n/br'
|
||||
import cs from '@trek/shared/i18n/cs'
|
||||
import pl from '@trek/shared/i18n/pl'
|
||||
import ja from '@trek/shared/i18n/ja'
|
||||
import ko from '@trek/shared/i18n/ko'
|
||||
import uk from '@trek/shared/i18n/uk'
|
||||
import gr from '@trek/shared/i18n/gr'
|
||||
|
||||
// Runtime guard for the aggregated i18n bundles. `t()` resolves keys against the
|
||||
// active locale's flat dot-key map (see TranslationContext), so a key that is
|
||||
// present in en but missing in another locale silently falls back to English at
|
||||
// runtime — easy to ship, hard to notice. This test fails loudly when any locale
|
||||
// drifts away from the en key set so translators get an explicit, diagnostic list.
|
||||
//
|
||||
// The shared package also runs a file-level parity check (shared/scripts), but
|
||||
// that one only inspects per-domain source files; this one asserts the *merged*
|
||||
// export each locale actually serves to the app.
|
||||
|
||||
const NON_EN_LOCALES: Record<string, TranslationStrings> = {
|
||||
de, es, fr, hu, it: itIT, tr, ru, zh, 'zh-TW': zhTW, nl, id: idID,
|
||||
ar, br, cs, pl, ja, ko, uk, gr,
|
||||
}
|
||||
|
||||
const enKeys = new Set(Object.keys(en))
|
||||
|
||||
describe('i18n locale key parity', () => {
|
||||
it('covers every non-en locale', () => {
|
||||
// Keep the assertion set in lockstep with the supported language list minus en.
|
||||
expect(Object.keys(NON_EN_LOCALES)).toHaveLength(19)
|
||||
})
|
||||
|
||||
for (const [locale, strings] of Object.entries(NON_EN_LOCALES)) {
|
||||
it(`${locale} has the exact same key set as en`, () => {
|
||||
const localeKeys = new Set(Object.keys(strings))
|
||||
const missing = [...enKeys].filter((k) => !localeKeys.has(k))
|
||||
const extra = [...localeKeys].filter((k) => !enKeys.has(k))
|
||||
|
||||
const diagnostic =
|
||||
`Locale "${locale}" key drift vs en — ` +
|
||||
`missing ${missing.length}` +
|
||||
(missing.length ? ` (${missing.slice(0, 10).join(', ')}${missing.length > 10 ? ', …' : ''})` : '') +
|
||||
`; extra ${extra.length}` +
|
||||
(extra.length ? ` (${extra.slice(0, 10).join(', ')}${extra.length > 10 ? ', …' : ''})` : '')
|
||||
|
||||
expect(missing, diagnostic).toEqual([])
|
||||
expect(extra, diagnostic).toEqual([])
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user