Skip to content

Commit 25765d1

Browse files
atulmguptaCopilot
andcommitted
fix(web): repair shadowed-parent t() calls + add audit gate
My Activity page rendered 'key activity.myActivity.disabled (en) returned an object instead of string' as the empty-state body. Same class of bug in 3 other call sites: - activity.myActivity.disabled / .unauthorized (MyActivityPage) - admin.security.timeline (EventTimeline heading) - widget.chargeHistory (ChargeHistoryWidget title) - search.noResults / .placeholder / .label (SettingsSearch — also wrong namespace; CommandPalette owns the search.* tree, settings.search.* is the right namespace) Each call resolved its dotted key to an OBJECT (parent of .title / siblings) in en.json. i18next returns the object verbatim and ignores the fallback string, so users saw the raw error text in the UI. Fix: - Add .description / .title siblings under disabled, unauthorized, admin.security.timeline, widget.chargeHistory - Repoint SettingsSearch to settings.search.* (correct namespace) - New scripts/audit-i18n-shadowed-keys.mjs walks every t('a.b.c') call, resolves against en.json, fails on any leaf-pointing-to-object. Wired into npm run lint so this can't regress. tsc clean; vitest 1592/1592 pass; audit:i18n-shadowed exits 0; audit_code clean on all 4 touched components. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1cb5722 commit 25765d1

7 files changed

Lines changed: 90 additions & 10 deletions

File tree

web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
"build": "tsc && vite build",
1212
"postbuild": "node scripts/check-bundle-size.mjs",
1313
"preview": "vite preview",
14-
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 && npm run lint:i18n && npm run lint:light-mode && npm run audit:empty-state && npm run audit:tooltip-text",
14+
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 && npm run lint:i18n && npm run lint:light-mode && npm run audit:empty-state && npm run audit:tooltip-text && npm run audit:i18n-shadowed",
1515
"lint:i18n": "node scripts/lint-i18n-no-template-literals.mjs",
1616
"lint:light-mode": "node scripts/audit-light-mode-parity.mjs",
1717
"audit:empty-state": "node scripts/audit-empty-state-cta.mjs",
1818
"audit:tooltip-text": "node scripts/audit-tooltip-text-color.mjs",
19+
"audit:i18n-shadowed": "node scripts/audit-i18n-shadowed-keys.mjs",
1920
"audit:skeletons": "node scripts/auditSkeletons.mjs",
2021
"audit:motion": "node scripts/auditMotionTokens.mjs",
2122
"audit:palette": "node scripts/auditChartPalette.mjs",
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Diagnostic + CI gate: find t('a.b.c') calls where a.b.c resolves to an
2+
// object (not a string) in en.json. Such calls return the object — i18next
3+
// logs "returned an object instead of string" and the fallback string is
4+
// IGNORED — so the UI shows that error message instead of the intended copy.
5+
//
6+
// Fix the offending call sites by either:
7+
// 1. Calling the leaf key directly: t('a.b.c.title', 'fallback')
8+
// 2. Adding a sibling string under a.b.c (e.g. .description) and calling
9+
// t('a.b.c.description', 'fallback')
10+
//
11+
// Exits 1 on any violation so it can run in CI / npm run lint.
12+
import fs from 'node:fs';
13+
import path from 'node:path';
14+
import { fileURLToPath } from 'node:url';
15+
16+
const here = path.dirname(fileURLToPath(import.meta.url));
17+
const root = path.resolve(here, '..');
18+
const en = JSON.parse(fs.readFileSync(path.join(root, 'src/i18n/en.json'), 'utf8'));
19+
20+
function get(obj, key) {
21+
return key.split('.').reduce((a, p) => (a && typeof a === 'object' ? a[p] : undefined), obj);
22+
}
23+
24+
function walk(dir, acc) {
25+
for (const f of fs.readdirSync(dir)) {
26+
const p = path.join(dir, f);
27+
const s = fs.statSync(p);
28+
if (s.isDirectory()) {
29+
if (f === 'node_modules' || f === 'dist' || f.startsWith('.')) continue;
30+
walk(p, acc);
31+
} else if (/\.(tsx?|jsx?)$/.test(f)) {
32+
acc.push(p);
33+
}
34+
}
35+
return acc;
36+
}
37+
38+
const files = walk(path.join(root, 'src'), []);
39+
// Match t('key.path' or t("key.path" — keep keys to dotted ASCII identifiers.
40+
// Skip test files (they may intentionally exercise fallback behavior).
41+
const re = /[^a-zA-Z_]t\(\s*['"]([a-zA-Z0-9_.]+)['"]/g;
42+
const broken = new Map();
43+
for (const f of files) {
44+
if (/[\\/]__tests__[\\/]|\.test\.|\.spec\./.test(f)) continue;
45+
const txt = fs.readFileSync(f, 'utf8');
46+
let m;
47+
while ((m = re.exec(txt)) !== null) {
48+
const key = m[1];
49+
if (!key.includes('.')) continue;
50+
const v = get(en, key);
51+
if (v && typeof v === 'object' && !Array.isArray(v)) {
52+
const line = txt.slice(0, m.index).split('\n').length;
53+
if (!broken.has(key)) broken.set(key, []);
54+
broken.get(key).push(path.relative(root, f) + ':' + line);
55+
}
56+
}
57+
}
58+
59+
if (broken.size === 0) {
60+
console.log('✅ audit:i18n-shadowed-keys — no violations');
61+
process.exit(0);
62+
}
63+
64+
console.error('❌ audit:i18n-shadowed-keys — found bare t() calls that resolve to an OBJECT in en.json:');
65+
console.error(' (i18next will log "returned an object instead of string" and ignore the fallback)');
66+
console.error('');
67+
for (const [k, locs] of broken) {
68+
console.error(` ${k}`);
69+
for (const l of locs) console.error(` at ${l}`);
70+
}
71+
console.error('');
72+
console.error(`total broken keys: ${broken.size}`);
73+
console.error('');
74+
console.error('Fix: either call a leaf key (e.g. .title / .description) or add a sibling string under the parent.');
75+
process.exit(1);

web/src/features/admin/components/security-access/EventTimeline.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export function EventTimeline({ timelineEvents }: EventTimelineProps) {
8484
<FadeIn delay={0.35}>
8585
<GlassPanel className="p-4">
8686
<h2 className="text-lg font-semibold text-gray-200 mb-4">
87-
{t('admin.security.timeline', 'Security Event Timeline')}
87+
{t('admin.security.timeline.title', 'Security Event Timeline')}
8888
</h2>
8989
{timelineEvents.length > 0 ? (
9090
<div className="space-y-3 max-h-96 overflow-y-auto pr-1">

web/src/features/dashboard/widgets/ChargeHistoryWidget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export default function ChargeHistoryWidget({ vehicleId, size }: WidgetProps) {
6969

7070
return (
7171
<WidgetShell
72-
title={t('widget.chargeHistory', 'Charge History')}
72+
title={t('widget.chargeHistory.title', 'Charge History')}
7373
icon={<BarChart3 className="h-3.5 w-3.5 text-neon-green" />}
7474
loading={isLoading}
7575
updatedAt={dataUpdatedAt}

web/src/features/settings/components/SettingsSearch.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ export function SettingsSearch({ className }: SettingsSearchProps) {
102102
}
103103

104104
const showDropdown = open && query.length > 0;
105-
const placeholder = t('search.placeholder', 'Search settings…');
106-
const ariaLabel = t('search.label', 'Search settings');
105+
const placeholder = t('settings.search.placeholder', 'Search settings…');
106+
const ariaLabel = t('settings.search.label', 'Search settings');
107107

108108
return (
109109
<div ref={wrapperRef} className={cn('relative', className)}>
@@ -147,7 +147,7 @@ export function SettingsSearch({ className }: SettingsSearchProps) {
147147
aria-disabled
148148
className="px-4 py-3 text-xs text-[var(--text-muted)]"
149149
>
150-
{t('search.noResults', 'No matching settings.')}
150+
{t('settings.search.noResults', 'No matching settings.')}
151151
</li>
152152
)}
153153
{matches.map((entry, idx) => {

web/src/features/system/pages/MyActivityPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export default function MyActivityPage() {
9090
icon={<Icons.securityCheck className="h-8 w-8" />}
9191
title={t('activity.myActivity.disabled.title', 'Activity feed disabled')}
9292
message={t(
93-
'activity.myActivity.disabled',
93+
'activity.myActivity.disabled.description',
9494
'Per-user activity is only available when TeslaSync is deployed behind an identity provider (ForwardAuth). Ask your administrator to configure AUTH_FORWARD_HEADER.',
9595
)}
9696
/>
@@ -99,7 +99,7 @@ export default function MyActivityPage() {
9999
icon={<Icons.user className="h-8 w-8" />}
100100
title={t('activity.myActivity.unauthorized.title', 'Identity required')}
101101
message={t(
102-
'activity.myActivity.unauthorized',
102+
'activity.myActivity.unauthorized.description',
103103
'Your request did not include an identity header. Sign in through your identity provider and try again.',
104104
)}
105105
/>

web/src/i18n/en.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,6 +1890,7 @@
18901890
"off": "Off",
18911891
"eventHistory": "Security Event History",
18921892
"timeline": {
1893+
"title": "Security Event Timeline",
18931894
"lock": {
18941895
"positive": "Vehicle Locked",
18951896
"negative": "Vehicle Unlocked",
@@ -3576,10 +3577,12 @@
35763577
"subtitle": "Recent actions you have taken in TeslaSync.",
35773578
"empty": "No recent activity in this window.",
35783579
"disabled": {
3579-
"title": "Activity feed disabled"
3580+
"title": "Activity feed disabled",
3581+
"description": "Per-user activity is only available when TeslaSync is deployed behind an identity provider (ForwardAuth). Ask your administrator to configure AUTH_FORWARD_HEADER."
35803582
},
35813583
"unauthorized": {
3582-
"title": "Identity required"
3584+
"title": "Identity required",
3585+
"description": "Your request did not include an identity header. Sign in through your identity provider and try again."
35833586
},
35843587
"error": {
35853588
"title": "Could not load activity"
@@ -3838,6 +3841,7 @@
38383841
"saved": "Saved {{amount}} vs gas"
38393842
},
38403843
"chargeHistory": {
3844+
"title": "Charge History",
38413845
"total": "Total",
38423846
"avg": "Avg"
38433847
},

0 commit comments

Comments
 (0)