diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 1c701d5d..90789c51 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -114,6 +114,10 @@ jobs: working-directory: src/frontend run: npm run type-check + - name: Check i18n translation parity + working-directory: src/frontend + run: npm run i18n:check + actionlint: runs-on: ubuntu-latest name: Workflow Lint (actionlint) diff --git a/docs/10-internationalization.md b/docs/10-internationalization.md new file mode 100644 index 00000000..08fdf36c --- /dev/null +++ b/docs/10-internationalization.md @@ -0,0 +1,113 @@ +# Chapter 10: Internationalization (i18n) + +The desktop/renderer UI is localized with [react-i18next](https://react.i18next.com/). +English (`en`) is the base language and French (`fr`) is the first translation. +This chapter covers how the translation system is wired and how to add or change +strings. + +## Overview + +- **Framework:** `i18next` + `react-i18next`, initialized in + `src/frontend/src/i18n/index.ts` and imported once for its side effects from + `src/frontend/index.js`. +- **Detection & persistence:** `i18next-browser-languagedetector` reads the + language from `localStorage` (falling back to `navigator.language`) and caches + the choice back to `localStorage`. Region subtags collapse to the base + language (`fr-FR` → `fr`) via `load: "languageOnly"`. +- **Supported languages:** declared in `SUPPORTED_LANGUAGES` (`["en", "fr"]`). +- **Bundled resources:** all locale JSON is imported statically and initialized + synchronously, so `react: { useSuspense: false }` is safe (the class-based + `ErrorBoundary` renders translations without an async gate). + +## Where the strings live + +Translations are namespaced JSON under +`src/frontend/src/i18n/locales//.json`: + +| Namespace | Covers | +| ------------ | ------------------------------------------------------------- | +| `common` | Shared UI, the Playground, language labels, error boundary | +| `settings` | Settings view and all its sections | +| `dashboard` | Dashboard view and the sidebar | +| `activity` | Activity (logs) view | +| `mappings` | Mappings view | +| `about` | About view | +| `onboarding` | Welcome / first-run modal | +| `modals` | CA-certificate setup and misclassification-report dialogs | + +Every namespace exists in both `en/` and `fr/`. To add a namespace, create the +JSON in both locales and register it in `NAMESPACES` + `resources` in +`src/i18n/index.ts`. + +## Using translations in components + +Function components use the hook; class components use the HOC (needed for +`ErrorBoundary`): + +```tsx +const { t } = useTranslation("dashboard"); +return

{t("title")}

; +``` + +- **Interpolation:** `t("kpi.requestsToday", { value: fmt(n) })` with + `"{{value}} today"`. Pre-format numbers/dates and pass them as plain vars so + formatting is preserved; avoid the reserved `count` var unless you want + pluralization. +- **Pluralization:** use `key_one` / `key_other` (English) with `{{count}}`: + `t("entryCount", { count: total })`. See the plural note below for French. +- **Embedded markup** (links, bold): use `` with a `components` map, e.g. + the CA-cert instructions in `modals.json` render ``, ``, ``, + and `` tags. Keep the tag pairs balanced in every locale. +- **Cross-namespace keys:** reference another namespace with a prefix, e.g. + `t("common:actions.close")`. + +Brand names (`Kiji`, `OpenAI`, …), shell commands, certificate paths, and other +technical literals are intentionally left untranslated. + +### Plurals and the French `many` category + +English needs only `one` and `other`. French cardinal rules add a `many` +category (triggered by multiples of 1,000,000), and **i18next does not fall back +from `many` to `other`** — a missing `_many` renders the raw key. So every +pluralized French key must provide `_one`, `_many`, and `_other`. The parity +check (below) enforces exactly the categories each locale's CLDR rules require. + +## Language selector and the Electron menu + +- The in-app selector lives in `src/components/settings/LanguageSection.tsx` and + is shown in Settings for both the web and desktop builds. It calls + `i18n.changeLanguage(...)`; persistence is handled by the detector's + `localStorage` cache. +- The native application and tray menus are built in the Electron **main** + process, which has no access to react-i18next. Their strings live in + `src/electron/menu-i18n.js`. The renderer pushes its resolved language to the + main process over the `set-language` IPC channel (on init and on every + `languageChanged`); the main process persists it and rebuilds the menus, and + seeds the menu language from the persisted config at startup. + +## Adding or changing strings + +1. Add the key to the **English** namespace JSON (the base/source of truth). +2. Add the same key to the **French** JSON with the translation. French drafts + are machine-translated pending native review — flag anything uncertain. +3. Reference it from the component with `t(...)` (or `` for markup). +4. Run the checks: + + ```bash + cd src/frontend + npm run i18n:check # plural-aware en↔fr parity (hard gate, CI) + npm run lint # includes an advisory no-literal-string warning + npm run type-check + ``` + +### Tooling + +- **`scripts/check-i18n-parity.js`** (`npm run i18n:check`) — the hard gate, run + in CI. Per namespace it verifies: base-key parity between locales (plural + suffixes normalized away), plural completeness against each locale's CLDR + categories, matching `{{placeholder}}` sets, and matching/balanced `` + tags. +- **`eslint-plugin-i18next`** — `no-literal-string` runs in `jsx-text-only` mode + as a **warning** over `src/**/*.{jsx,tsx}`, surfacing un-extracted visible text + without gating CI (attributes/expressions and technical literals are out of + scope). diff --git a/docs/README.md b/docs/README.md index d0fc73d2..49ca4fa6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -162,6 +162,22 @@ Route terminal coding agents through the proxy so PII in prompts, code, and tool --- +### [Chapter 10: Internationalization (i18n)](10-internationalization.md) + +Localize the desktop/renderer UI with react-i18next. English is the base +language; French is the first translation. + +**Topics:** +- How i18next is wired (namespaces, detection, persistence) +- Using `t()` and ``, interpolation, and pluralization +- The French `many` plural category and why it matters +- Language selector and the Electron native-menu sync +- Adding/changing strings and the parity + lint tooling + +**Start here if you're:** Adding UI strings, adding a language, or reviewing translations. + +--- + ## Quick Links ### Getting Started diff --git a/package-lock.json b/package-lock.json index 3e60dc72..f27364e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -287,7 +287,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -6155,6 +6154,19 @@ } } }, + "node_modules/eslint-plugin-i18next": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.5.tgz", + "integrity": "sha512-xCTfstbK9ZpQ6UFT5S1s6zbZMhKn2o2jRSQYiU2UkV7wt8y1m3WjkUYGkGlipgRdFInYI9+LAqE+JQU/L73HmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "requireindex": "~1.1.0" + }, + "engines": { + "node": ">=18.10.0" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "dev": true, @@ -7293,6 +7305,15 @@ "node": ">= 12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.7", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz", @@ -7451,6 +7472,44 @@ "node": ">=10.18" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -10110,6 +10169,33 @@ "node": ">=0.10.0" } }, + "node_modules/react-i18next": { + "version": "17.0.9", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz", + "integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "dev": true, @@ -10337,6 +10423,16 @@ "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, + "node_modules/requireindex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", + "integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.5" + } + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -11842,7 +11938,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -12028,6 +12124,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -12052,6 +12157,15 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -12639,9 +12753,12 @@ "electron-updater": "^6.8.9", "express": "^5.2.1", "http-proxy-middleware": "^4.2.0", + "i18next": "^26.3.4", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.23.0", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-i18next": "^17.0.8" }, "devDependencies": { "@babel/core": "^8.0.1", @@ -12661,6 +12778,7 @@ "electron": "^43.0.0", "electron-builder": "^26.15.3", "eslint": "^9.39.5", + "eslint-plugin-i18next": "^6.1.5", "eslint-plugin-react": "^7.33.0", "eslint-plugin-react-hooks": "^7.1.1", "html-webpack-plugin": "^5.6.7", diff --git a/src/frontend/eslint.config.js b/src/frontend/eslint.config.js index c2095389..fd4cf6de 100644 --- a/src/frontend/eslint.config.js +++ b/src/frontend/eslint.config.js @@ -2,6 +2,7 @@ const tsPlugin = require("@typescript-eslint/eslint-plugin"); const tsParser = require("@typescript-eslint/parser"); const reactPlugin = require("eslint-plugin-react"); const reactHooksPlugin = require("eslint-plugin-react-hooks"); +const i18nextPlugin = require("eslint-plugin-i18next"); module.exports = [ // Global ignores (replaces .eslintignore) @@ -118,4 +119,18 @@ module.exports = [ "@typescript-eslint/no-require-imports": "off", }, }, + + // i18n: flag un-extracted UI copy in React components. Scoped to the + // renderer's JSX/TSX and limited to `jsx-text-only` (visible text between + // tags) so it does not flag brand names, class names, or technical literals + // in attributes/expressions. Advisory (warn) so it guides incremental + // extraction without gating CI — the hard i18n gate is the plural-aware + // parity check (scripts/check-i18n-parity.js, run as `npm run i18n:check`). + { + files: ["src/**/*.{jsx,tsx}"], + plugins: { i18next: i18nextPlugin }, + rules: { + "i18next/no-literal-string": ["warn", { mode: "jsx-text-only" }], + }, + }, ]; diff --git a/src/frontend/index.js b/src/frontend/index.js index 93d5a280..96b3e538 100644 --- a/src/frontend/index.js +++ b/src/frontend/index.js @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./src/styles/styles.css"; +import "./src/i18n"; import AppShell from "./src/components/AppShell.tsx"; import ErrorBoundary from "./src/components/ErrorBoundary.tsx"; import * as Sentry from "@sentry/electron/renderer"; diff --git a/src/frontend/package.json b/src/frontend/package.json index ff0f5df2..6183888d 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -17,6 +17,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "type-check": "tsc --noEmit", + "i18n:check": "node scripts/check-i18n-parity.js", "changeset": "changeset", "version": "changeset version", "release": "changeset publish" @@ -28,9 +29,12 @@ "electron-updater": "^6.8.9", "express": "^5.2.1", "http-proxy-middleware": "^4.2.0", + "i18next": "^26.3.4", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.23.0", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-i18next": "^17.0.8" }, "devDependencies": { "@babel/core": "^8.0.1", @@ -50,6 +54,7 @@ "electron": "^43.0.0", "electron-builder": "^26.15.3", "eslint": "^9.39.5", + "eslint-plugin-i18next": "^6.1.5", "eslint-plugin-react": "^7.33.0", "eslint-plugin-react-hooks": "^7.1.1", "html-webpack-plugin": "^5.6.7", diff --git a/src/frontend/scripts/check-i18n-parity.js b/src/frontend/scripts/check-i18n-parity.js new file mode 100644 index 00000000..39b403b0 --- /dev/null +++ b/src/frontend/scripts/check-i18n-parity.js @@ -0,0 +1,217 @@ +#!/usr/bin/env node +/* + * i18n parity check (plural-aware). + * + * Validates that the English base and every translation stay structurally in + * sync. Run from src/frontend as `node scripts/check-i18n-parity.js` (wired to + * `npm run i18n:check` and CI). + * + * What it checks, per namespace, for each non-English locale against English: + * 1. Base-key parity — the set of keys, with CLDR plural suffixes stripped, + * must match exactly. Missing or stray keys fail. + * 2. Plural completeness — a key that is pluralized must provide exactly the + * plural categories the locale's own CLDR rules require. English needs + * {one, other}; French needs {one, many, other}. This is why a naive + * key-for-key diff is wrong: the correct French file legitimately has + * `_many` variants English does not. + * 3. Interpolation parity — a key present in both locales must use the same + * set of {{placeholders}}. + * 4. Markup parity — names used by strings must match across + * locales, and open/close tags must balance within every string. + */ + +const fs = require("fs"); +const path = require("path"); + +const LOCALES_DIR = path.join(__dirname, "..", "src", "i18n", "locales"); +const BASE_LOCALE = "en"; +const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"]; +const PLURAL_RE = new RegExp(`_(${PLURAL_SUFFIXES.join("|")})$`); + +// The plural categories each locale must supply, taken from the locale's own +// CLDR cardinal rules (e.g. en -> [one, other], fr -> [one, many, other]). +function requiredCategories(locale) { + return new Set( + new Intl.PluralRules(locale).resolvedOptions().pluralCategories + ); +} + +function baseKey(key) { + return key.replace(PLURAL_RE, ""); +} + +function pluralCategory(key) { + const m = key.match(PLURAL_RE); + return m ? m[1] : null; +} + +// Flatten a nested JSON object into dot-joined leaf keys. +function flatten(obj, prefix = "", out = {}) { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) { + flatten(v, key, out); + } else { + out[key] = v; + } + } + return out; +} + +function placeholders(value) { + if (typeof value !== "string") return new Set(); + return new Set([...value.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])); +} + +function tagNames(value) { + if (typeof value !== "string") return []; + return [...value.matchAll(/<\/?(\w+)\s*\/?>/g)].map((m) => m[1]); +} + +function tagsBalanced(value) { + if (typeof value !== "string") return true; + const opens = [...value.matchAll(/<(\w+)>/g)].map((m) => m[1]).sort(); + const closes = [...value.matchAll(/<\/(\w+)>/g)].map((m) => m[1]).sort(); + return JSON.stringify(opens) === JSON.stringify(closes); +} + +function eq(a, b) { + return a.size === b.size && [...a].every((x) => b.has(x)); +} + +function listNamespaces() { + return fs + .readdirSync(path.join(LOCALES_DIR, BASE_LOCALE)) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); +} + +function listLocales() { + return fs + .readdirSync(LOCALES_DIR) + .filter((f) => fs.statSync(path.join(LOCALES_DIR, f)).isDirectory()) + .filter((l) => l !== BASE_LOCALE); +} + +function load(locale, ns) { + return flatten( + JSON.parse( + fs.readFileSync(path.join(LOCALES_DIR, locale, `${ns}.json`), "utf8") + ) + ); +} + +function baseKeySet(flat) { + return new Set(Object.keys(flat).map(baseKey)); +} + +// Map base key -> set of plural categories present for it in a flat locale. +function pluralCategoriesByBase(flat) { + const map = new Map(); + for (const key of Object.keys(flat)) { + const cat = pluralCategory(key); + if (!cat) continue; + const b = baseKey(key); + if (!map.has(b)) map.set(b, new Set()); + map.get(b).add(cat); + } + return map; +} + +function checkNamespace(locale, ns, errors) { + const en = load(BASE_LOCALE, ns); + let loc; + try { + loc = load(locale, ns); + } catch { + errors.push(`[${locale}/${ns}] missing or unreadable translation file`); + return; + } + + const enBases = baseKeySet(en); + const locBases = baseKeySet(loc); + + for (const b of enBases) { + if (!locBases.has(b)) errors.push(`[${locale}/${ns}] missing key: ${b}`); + } + for (const b of locBases) { + if (!enBases.has(b)) errors.push(`[${locale}/${ns}] stray key: ${b}`); + } + + // Plural completeness for keys pluralized in either locale. + const enPlurals = pluralCategoriesByBase(en); + const locPlurals = pluralCategoriesByBase(loc); + const pluralBases = new Set([...enPlurals.keys(), ...locPlurals.keys()]); + const enReq = requiredCategories(BASE_LOCALE); + const locReq = requiredCategories(locale); + + for (const b of pluralBases) { + if (enBases.has(b)) { + const have = enPlurals.get(b) || new Set(); + for (const cat of enReq) { + if (!have.has(cat)) { + errors.push(`[${BASE_LOCALE}/${ns}] ${b}: missing plural _${cat}`); + } + } + } + if (locBases.has(b)) { + const have = locPlurals.get(b) || new Set(); + for (const cat of locReq) { + if (!have.has(cat)) { + errors.push(`[${locale}/${ns}] ${b}: missing plural _${cat}`); + } + } + } + } + + // Interpolation + markup parity for keys present in both locales. + for (const key of Object.keys(en)) { + if (!(key in loc)) continue; + if (!eq(placeholders(en[key]), placeholders(loc[key]))) { + errors.push(`[${locale}/${ns}] ${key}: placeholder mismatch`); + } + const enTags = new Set(tagNames(en[key])); + const locTags = new Set(tagNames(loc[key])); + if (!eq(enTags, locTags)) { + errors.push(`[${locale}/${ns}] ${key}: markup mismatch`); + } + if (!tagsBalanced(loc[key])) { + errors.push(`[${locale}/${ns}] ${key}: unbalanced markup tags`); + } + } + // English strings should have balanced markup too. + for (const key of Object.keys(en)) { + if (!tagsBalanced(en[key])) { + errors.push(`[${BASE_LOCALE}/${ns}] ${key}: unbalanced markup tags`); + } + } +} + +function main() { + const namespaces = listNamespaces(); + const locales = listLocales(); + const errors = []; + + for (const locale of locales) { + for (const ns of namespaces) { + checkNamespace(locale, ns, errors); + } + } + + if (errors.length > 0) { + console.error( + `i18n parity check failed with ${errors.length} issue(s):\n` + + errors.map((e) => ` - ${e}`).join("\n") + ); + process.exit(1); + } + + console.log( + `i18n parity OK: ${namespaces.length} namespace(s) across locales [${[ + BASE_LOCALE, + ...locales, + ].join(", ")}].` + ); +} + +main(); diff --git a/src/frontend/src/components/AppShell.tsx b/src/frontend/src/components/AppShell.tsx index 849ff0e3..d5c5d59d 100644 --- a/src/frontend/src/components/AppShell.tsx +++ b/src/frontend/src/components/AppShell.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { apiUrl, isElectron } from "../utils/providerHelpers"; +import { isElectron } from "../utils/providerHelpers"; +import { useIsAdmin } from "../hooks/useIsAdmin"; import { useServerHealth } from "../hooks/useServerHealth"; import Sidebar, { ViewId } from "./dashboard/Sidebar"; import DashboardView from "./dashboard/DashboardView"; @@ -22,43 +23,21 @@ import { ADMIN_ROLE_CHOSEN_EVENT } from "./onboarding/WelcomeModal"; * load their data fresh. */ export default function AppShell() { - // The launch screen is role-dependent. `admin` is read async over IPC, so the - // view starts unresolved (null) and the workspace stays empty until the role - // arrives — this avoids briefly flashing the wrong screen on first paint. + // The launch screen is role-dependent. `isAdmin` is read async (IPC on the + // desktop, /api/auth/status on the web), so it starts unresolved (null) and + // the view stays empty until the role arrives — this avoids briefly flashing + // the wrong screen on first paint. + const isAdmin = useIsAdmin(); const [view, setView] = useState(null); - useEffect(() => { - let cancelled = false; - const resolveInitialView = async () => { - let isAdmin = false; - if (isElectron && window.electronAPI) { - // Desktop: the role chosen during onboarding. - try { - isAdmin = await window.electronAPI.getAdmin(); - } catch (error) { - console.error("Failed to read admin preference:", error); - } - } else { - // Web: a configured username + password (HTTP Basic Auth) is the admin - // signal — whoever set the credentials and can load the gated UI is the - // admin. /api/auth/status exposes only this boolean, never the secrets. - try { - const res = await fetch(apiUrl("/api/auth/status", isElectron)); - if (res.ok) { - const data = await res.json(); - isAdmin = data.basicAuthActive === true; - } - } catch (error) { - console.error("Failed to read auth status:", error); - } - } - if (!cancelled) setView(isAdmin ? "dashboard" : "playground"); - }; - resolveInitialView(); - return () => { - cancelled = true; - }; - }, []); + // Once the role resolves, admins default to the Dashboard and everyone else to + // the Playground. This default is derived during render rather than synced via + // an effect, so an explicit `view` — set by the user or the onboarding event + // below — always wins and we never clobber a view already navigated to. Until + // the role arrives (`isAdmin === null`) the view stays empty, which avoids + // briefly flashing the wrong screen on first paint. + const resolvedView: ViewId | null = + view ?? (isAdmin === null ? null : isAdmin ? "dashboard" : "playground"); // The initial view is resolved once on mount, but onboarding can choose the // admin role afterwards (WelcomeModal lives under the Playground). When that @@ -93,23 +72,23 @@ export default function AppShell() { return (
- +
- {view === "dashboard" && ( + {resolvedView === "dashboard" && ( setView("activity")} /> )} - {view === "activity" && ( + {resolvedView === "activity" && ( )} - {view === "mappings" && } - {view === "settings" && ( + {resolvedView === "mappings" && } + {resolvedView === "settings" && ( setSettingsReloadN((n) => n + 1)} /> )} - {view === "about" && } + {resolvedView === "about" && } {/* Kept mounted so Playground state persists across navigation */} -