diff --git a/package-lock.json b/package-lock.json index 2477e00..837d9c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@preact/preset-vite": "^2.10.3", "@types/node": "^25.2.3", "autoprefixer": "^10.4.21", + "opencc-js": "^1.0.5", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", "tsx": "^4.21.0", @@ -3246,6 +3247,13 @@ "node": ">= 6" } }, + "node_modules/opencc-js": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/opencc-js/-/opencc-js-1.0.5.tgz", + "integrity": "sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg==", + "dev": true, + "license": "MIT" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", diff --git a/package.json b/package.json index 86a3744..4358d3e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dev": "wrangler dev -c wrangler.toml", "dev:kv": "wrangler dev -c wrangler.kv.toml", "build": "vite build --config webapp/vite.config.ts", + "i18n:generate:zh-tw": "node scripts/i18n-generate-zh-tw.cjs", + "i18n:validate": "node scripts/i18n-validate.cjs", "deploy": "wrangler deploy", "deploy:kv": "wrangler deploy -c wrangler.kv.toml" }, @@ -41,6 +43,7 @@ "@preact/preset-vite": "^2.10.3", "@types/node": "^25.2.3", "autoprefixer": "^10.4.21", + "opencc-js": "^1.0.5", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", "tsx": "^4.21.0", diff --git a/scripts/i18n-utils.cjs b/scripts/i18n-utils.cjs new file mode 100644 index 0000000..d4d4718 --- /dev/null +++ b/scripts/i18n-utils.cjs @@ -0,0 +1,32 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales'); + +function readLocale(fileName, variableName) { + let code = fs.readFileSync(path.join(localeDir, fileName), 'utf8'); + code = code + .replace(/const (\w+): Record =/g, 'const $1 =') + .replace(/export default \w+;\s*$/m, ''); + code += `\nresult = ${variableName};`; + const sandbox = { result: null }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox, { filename: fileName }); + return sandbox.result; +} + +function writeLocale(fileName, variableName, table, header) { + const body = JSON.stringify(table, null, 2); + fs.writeFileSync( + path.join(localeDir, fileName), + `${header}\nconst ${variableName}: Record = ${body};\n\nexport default ${variableName};\n`, + 'utf8' + ); +} + +module.exports = { + localeDir, + readLocale, + writeLocale, +}; diff --git a/scripts/i18n-validate.cjs b/scripts/i18n-validate.cjs new file mode 100644 index 0000000..02288b5 --- /dev/null +++ b/scripts/i18n-validate.cjs @@ -0,0 +1,42 @@ +const { readLocale } = require('./i18n-utils.cjs'); + +const localeFiles = [ + ['en', 'en.ts', 'en'], + ['zh-CN', 'zh-CN.ts', 'zhCN'], + ['zh-TW', 'zh-TW.ts', 'zhTW'], + ['ru', 'ru.ts', 'ru'], +]; + +const locales = Object.fromEntries( + localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)]) +); +const base = locales.en; +const baseKeys = Object.keys(base).sort(); +const placeholderRe = /\{\w+\}/g; +const errors = []; + +for (const [locale, table] of Object.entries(locales)) { + const keys = Object.keys(table).sort(); + const missing = baseKeys.filter((key) => !(key in table)); + const extra = keys.filter((key) => !baseKeys.includes(key)); + if (missing.length || extra.length) { + errors.push({ locale, missing, extra }); + } + + for (const key of baseKeys) { + const basePlaceholders = Array.from(String(base[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|'); + const localePlaceholders = Array.from(String(table[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|'); + if (basePlaceholders !== localePlaceholders) { + errors.push({ locale, key, basePlaceholders, localePlaceholders }); + } + } +} + +console.log(JSON.stringify({ + counts: Object.fromEntries(Object.entries(locales).map(([locale, table]) => [locale, Object.keys(table).length])), + errors, +}, null, 2)); + +if (errors.length) { + process.exit(1); +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 5d8ae87..a9331d0 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -13,6 +13,7 @@ import { clearProfileSnapshot, getCurrentDeviceIdentifier, getPasswordHint, + getProfile, loadProfileSnapshot, saveProfileSnapshot, revokeCurrentSession, @@ -70,6 +71,10 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export const IMPORT_ROUTE_ALIASES: ReadonlySet = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; + +function isAdminProfile(profile: Profile | null): profile is Profile { + return String(profile?.role || '').toLowerCase() === 'admin'; +} const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; @@ -770,16 +775,28 @@ export default function App() { enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'), staleTime: 30_000, }); + const profileQuery = useQuery({ + queryKey: ['profile', vaultCacheKey || session?.email], + queryFn: () => getProfile(authedFetch), + enabled: phase === 'app' && !!session?.accessToken, + staleTime: 30_000, + }); + useEffect(() => { + if (!profileQuery.data) return; + setProfile(profileQuery.data); + }, [profileQuery.data]); + + const isAdmin = isAdminProfile(profile); const usersQuery = useQuery({ queryKey: ['admin-users', vaultCacheKey], queryFn: () => listAdminUsers(authedFetch), - enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone, + enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); const invitesQuery = useQuery({ queryKey: ['admin-invites', vaultCacheKey], queryFn: () => listAdminInvites(authedFetch), - enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone, + enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); const totpStatusQuery = useQuery({ @@ -798,7 +815,7 @@ export default function App() { useEffect(() => { if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; if (!vaultInitialDecryptDone) return; - if (!profile?.role || profile.role !== 'admin') return; + if (!isAdminProfile(profile)) return; if (repairAttemptRef.current === session.accessToken) return; repairAttemptRef.current = session.accessToken; @@ -1148,10 +1165,10 @@ export default function App() { }, [phase, isImportHashRoute, location, navigate]); useEffect(() => { - if (phase === 'app' && profile?.role !== 'admin' && location === '/backup') { + if (phase === 'app' && !isAdminProfile(profile) && location === '/backup' && !profileQuery.isFetching) { navigate('/vault'); } - }, [phase, profile?.role, location, navigate]); + }, [phase, profile?.role, profileQuery.isFetching, location, navigate]); useEffect(() => { if (phase === 'app' && !mobileLayout && location === SETTINGS_HOME_ROUTE) { diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx index 2c19e35..1e86c3c 100644 --- a/webapp/src/components/AppAuthenticatedShell.tsx +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -25,8 +25,13 @@ interface AppAuthenticatedShellProps { mainRoutesProps: AppMainRoutesProps; } +function isAdminProfile(profile: Profile | null): boolean { + return String(profile?.role || '').toLowerCase() === 'admin'; +} + export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) { const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location; + const isAdmin = isAdminProfile(props.profile); return (
@@ -83,7 +88,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {t('nav_sends')} - {props.profile?.role === 'admin' && ( + {isAdmin && ( {t('nav_admin_panel')} @@ -97,7 +102,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {t('nav_device_management')} - {props.profile?.role === 'admin' && ( + {isAdmin && ( {t('nav_backup_strategy')} diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 20586f1..1e4cc3f 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -129,6 +129,7 @@ export interface AppMainRoutesProps { export default function AppMainRoutes(props: AppMainRoutesProps) { const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const; + const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin'; const importPageContent = ( }> {t('nav_import_export')} - {props.profile.role === 'admin' && ( + {isAdmin && ( {t('nav_admin_panel')} )} - {props.profile.role === 'admin' && ( + {isAdmin && ( {t('nav_backup_strategy')} @@ -340,7 +341,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { - {props.profile?.role === 'admin' ? ( + {isAdmin ? (
{props.mobileLayout && (
diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index 378fae7..450ee6d 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -1,9 +1,9 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; -import { Clipboard, KeyRound, Lightbulb, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact'; +import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact'; import { copyTextToClipboard } from '@/lib/clipboard'; import qrcode from 'qrcode-generator'; import type { Profile } from '@/lib/types'; -import { t } from '@/lib/i18n'; +import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n'; import ConfirmDialog from '@/components/ConfirmDialog'; interface SettingsPageProps { @@ -79,6 +79,7 @@ export default function SettingsPage(props: SettingsPageProps) { const [masterPasswordPrompt, setMasterPasswordPrompt] = useState(null); const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState(''); const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false); + const [selectedLocale, setSelectedLocale] = useState(() => getLocale()); useEffect(() => { clearLegacyTotpSetupSecrets(); @@ -167,6 +168,13 @@ export default function SettingsPage(props: SettingsPageProps) { return parsed.toLocaleString(); } + async function changeLocale(next: Locale): Promise { + if (next === getLocale()) return; + setSelectedLocale(next); + await setLocale(next); + window.location.reload(); + } + return (
@@ -200,9 +208,23 @@ export default function SettingsPage(props: SettingsPageProps) {
-
-