diff --git a/ui/package-lock.json b/ui/package-lock.json index ae8ec9a..3730f92 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,9 +12,11 @@ "@fontsource-variable/jetbrains-mono": "^5.1.1", "@fontsource-variable/public-sans": "^5.1.0", "@tanstack/react-query": "^5.51.1", + "i18next": "^26.3.6", "lucide-react": "^0.408.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", "react-router-dom": "^6.24.0" }, "devDependencies": { @@ -2556,6 +2558,15 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -2569,6 +2580,34 @@ "node": ">=8.0.0" } }, + "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", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3160,6 +3199,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.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-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3567,7 +3633,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3608,6 +3674,15 @@ "browserslist": ">= 4.21.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/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index 9ced4db..77990bb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,9 +14,11 @@ "@fontsource-variable/jetbrains-mono": "^5.1.1", "@fontsource-variable/public-sans": "^5.1.0", "@tanstack/react-query": "^5.51.1", + "i18next": "^26.3.6", "lucide-react": "^0.408.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", "react-router-dom": "^6.24.0" }, "devDependencies": { diff --git a/ui/src/auth/ChangePasswordForm.tsx b/ui/src/auth/ChangePasswordForm.tsx index 87d630c..9b4c90c 100644 --- a/ui/src/auth/ChangePasswordForm.tsx +++ b/ui/src/auth/ChangePasswordForm.tsx @@ -1,5 +1,7 @@ import { useState, type FormEvent } from 'react' +import { useTranslation } from 'react-i18next' import { ApiError } from '../api/client' +import { apiErrorMessage } from '../i18n/apiError' import { Button, Field, Input } from '../components/ui' import { useAuth } from './AuthContext' @@ -11,12 +13,13 @@ const MIN_LENGTH = 12 * and the voluntary change modal. */ export function ChangePasswordForm({ - submitLabel = 'Update password', + submitLabel, onSuccess, }: { submitLabel?: string onSuccess?: () => void }) { + const { t } = useTranslation() const { changePassword } = useAuth() const [current, setCurrent] = useState('') const [next, setNext] = useState('') @@ -37,14 +40,14 @@ export function ChangePasswordForm({ await changePassword(current, next) onSuccess?.() } catch (err) { - setError(err instanceof ApiError ? err.message : 'Could not change the password.') + setError(err instanceof ApiError ? apiErrorMessage(err, t) : t('auth.password.failed')) setBusy(false) } } return (
- + )} ) diff --git a/ui/src/auth/ChangePasswordModal.tsx b/ui/src/auth/ChangePasswordModal.tsx index ee854e4..5bb4d54 100644 --- a/ui/src/auth/ChangePasswordModal.tsx +++ b/ui/src/auth/ChangePasswordModal.tsx @@ -1,15 +1,17 @@ +import { useTranslation } from 'react-i18next' import { Modal, useToast } from '../components/ui' import { ChangePasswordForm } from './ChangePasswordForm' /** Voluntary password change, launched from the Topbar (dismissable, unlike the forced screen). */ export function ChangePasswordModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const { t } = useTranslation() const toast = useToast() return ( - + { - toast.success('Password updated.') + toast.success(t('auth.password.updated')) onClose() }} /> diff --git a/ui/src/auth/ChangePasswordScreen.tsx b/ui/src/auth/ChangePasswordScreen.tsx index b09f50b..ee42389 100644 --- a/ui/src/auth/ChangePasswordScreen.tsx +++ b/ui/src/auth/ChangePasswordScreen.tsx @@ -1,4 +1,5 @@ import { KeyRound } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { ChangePasswordForm } from './ChangePasswordForm' import { useAuth } from './AuthContext' @@ -8,6 +9,7 @@ import { useAuth } from './AuthContext' * `changePassword` clears the flag and the app renders. */ export function ChangePasswordScreen() { + const { t } = useTranslation() const { currentUser, logout } = useAuth() return (
@@ -16,19 +18,23 @@ export function ChangePasswordScreen() {
-

Set a new password

+

+ {t('auth.password.setTitle')} +

- {currentUser?.username ? `Signed in as ${currentUser.username}. ` : ''} - Choose a new password to finish signing in. + {currentUser?.username + ? t('auth.password.signedInAs', { name: currentUser.username }) + : ''} + {t('auth.password.chooseNew')}

- + diff --git a/ui/src/auth/LoginScreen.tsx b/ui/src/auth/LoginScreen.tsx index 0977ccf..b86aefb 100644 --- a/ui/src/auth/LoginScreen.tsx +++ b/ui/src/auth/LoginScreen.tsx @@ -1,17 +1,18 @@ import { useEffect, useRef, useState, type FormEvent } from 'react' import { LogIn } from 'lucide-react' +import type { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' import { ApiError } from '../api/client' +import { apiErrorMessage } from '../i18n/apiError' import { Button } from '../components/ui/Button' import { Input } from '../components/ui/Input' import { useAuth } from './AuthContext' import { ApiReachLine, UnlockScreen, useApiReach } from './UnlockScreen' /** Maps a login failure to a coarse, credential-safe message. */ -function loginError(err: unknown): string { - if (err instanceof ApiError) { - return err.status === 401 ? 'Invalid username or password.' : err.message - } - return 'Could not reach the API. Is the stack running?' +function loginError(err: unknown, t: TFunction): string { + if (err instanceof ApiError && err.status === 401) return t('auth.login.invalid') + return apiErrorMessage(err, t) } /** @@ -19,6 +20,7 @@ function loginError(err: unknown): string { * swaps in the bootstrap {@link UnlockScreen} (break-glass admin access). */ export function LoginScreen() { + const { t } = useTranslation() const { login } = useAuth() const { reach, health } = useApiReach() const [useMaster, setUseMaster] = useState(false) @@ -42,7 +44,7 @@ export function LoginScreen() { try { await login(username.trim(), password) } catch (err) { - setError(loginError(err)) + setError(loginError(err, t)) setBusy(false) } } @@ -55,23 +57,23 @@ export function LoginScreen() {

EmbedBase

-

Sign in to your account.

+

{t('auth.login.subtitle')}

setUsername(e.target.value)} /> setPassword(e.target.value)} @@ -87,7 +89,7 @@ export function LoginScreen() { disabled={!username.trim() || !password} className="w-full" > - {busy ? 'Signing in…' : 'Sign in'} + {busy ? t('auth.login.submitting') : t('auth.login.submit')}
@@ -96,7 +98,7 @@ export function LoginScreen() { onClick={() => setUseMaster(true)} className="mt-4 w-full text-center text-xs text-ink-faint hover:text-ink-muted" > - Use master key instead + {t('auth.login.useMaster')} diff --git a/ui/src/auth/UnlockScreen.tsx b/ui/src/auth/UnlockScreen.tsx index 380ea42..edd55b4 100644 --- a/ui/src/auth/UnlockScreen.tsx +++ b/ui/src/auth/UnlockScreen.tsx @@ -1,6 +1,9 @@ import { useEffect, useRef, useState, type FormEvent } from 'react' import { KeyRound, Loader2, ShieldCheck, ShieldAlert } from 'lucide-react' +import type { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' import { api, ApiError } from '../api/client' +import { apiErrorMessage } from '../i18n/apiError' import type { Health } from '../api/types' import { useAuth } from './AuthContext' import { Button } from '../components/ui/Button' @@ -26,13 +29,9 @@ export function useApiReach(): { reach: ApiReach; health: Health | null } { } /** Maps an unlock failure to a key-safe message (never echoes the key). */ -function unlockError(err: unknown): string { - if (err instanceof ApiError) { - return err.status === 401 - ? 'That key was rejected. Check it and try again.' - : err.message - } - return 'Could not reach the API. Is the stack running?' +function unlockError(err: unknown, t: TFunction): string { + if (err instanceof ApiError && err.status === 401) return t('auth.unlock.rejected') + return apiErrorMessage(err, t) } /** @@ -41,6 +40,7 @@ function unlockError(err: unknown): string { * toggle. Submitting verifies the key against `/workspaces`. */ export function UnlockScreen({ onBack }: { onBack?: () => void }) { + const { t } = useTranslation() const { unlock } = useAuth() const { reach, health } = useApiReach() const [key, setKey] = useState('') @@ -60,7 +60,7 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) { try { await unlock(key.trim()) } catch (err) { - setError(unlockError(err)) + setError(unlockError(err, t)) setBusy(false) } } @@ -73,9 +73,7 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) {

EmbedBase

-

- Enter your master key to unlock the console. -

+

{t('auth.unlock.subtitle')}

@@ -84,8 +82,8 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) { type="password" autoComplete="off" spellCheck={false} - placeholder="Master key" - aria-label="Master key" + placeholder={t('auth.unlock.masterKey')} + aria-label={t('auth.unlock.masterKey')} aria-invalid={error != null} value={key} onChange={(e) => setKey(e.target.value)} @@ -97,7 +95,7 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) {

)}
@@ -107,7 +105,7 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) { onClick={onBack} className="mt-4 w-full text-center text-xs text-ink-faint hover:text-ink-muted" > - Back to sign in + {t('auth.unlock.back')} )} @@ -119,11 +117,12 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) { /** Subtle API-reachability footer beneath the unlock/login form. */ export function ApiReachLine({ reach, health }: { reach: ApiReach; health: Health | null }) { + const { t } = useTranslation() if (reach === 'checking') { return (

- Checking API… + {t('auth.api.checking')}

) } @@ -131,14 +130,14 @@ export function ApiReachLine({ reach, health }: { reach: ApiReach; health: Healt return (

- API unreachable + {t('auth.api.unreachable')}

) } return (

- API ready · {health?.embedding_model ?? 'model'} + {t('auth.api.ready', { model: health?.embedding_model ?? t('auth.api.modelFallback') })}

) } diff --git a/ui/src/components/Pager.tsx b/ui/src/components/Pager.tsx index cb37343..a8c60c8 100644 --- a/ui/src/components/Pager.tsx +++ b/ui/src/components/Pager.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { cn } from '../lib/cn' import { Button } from './ui' @@ -21,6 +22,7 @@ export function Pager({ * end of it first. Assumes the page gutter both callers share (see the class note below). */ sticky?: boolean }) { + const { t } = useTranslation() if (total === 0) return null const start = page * pageSize + 1 const end = Math.min((page + 1) * pageSize, total) @@ -34,13 +36,7 @@ export function Pager({ sticky && 'sticky bottom-0 z-10 -mx-8 border-t border-border bg-canvas px-8 py-3', )} > - - Showing{' '} - - {start}–{end} - {' '} - of {total} - + {t('ui.pager.range', { from: start, to: end, total })}
- - Page {page + 1} of {lastPage + 1} - + {t('ui.pager.page', { page: page + 1, pages: lastPage + 1 })}
diff --git a/ui/src/components/collections/CollectionFormModal.tsx b/ui/src/components/collections/CollectionFormModal.tsx index dbca492..d1c7c41 100644 --- a/ui/src/components/collections/CollectionFormModal.tsx +++ b/ui/src/components/collections/CollectionFormModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import type { Collection } from '../../api/types' import { Button, @@ -54,6 +55,7 @@ export function CollectionFormModal({ onSubmit: (values: CollectionFormValues) => void onClose: () => void }) { + const { t } = useTranslation() const [values, setValues] = useState(DEFAULTS) // Reseed every time the modal opens so stale edits never leak between rows. @@ -75,20 +77,20 @@ export function CollectionFormModal({ } >
- + { if (e.key === 'Enter') submit() }} - placeholder="e.g. Q3 Reports" + placeholder={t('collections.namePlaceholder')} /> - +