From ad8ed88519128ff80e69cced1cf72ab359c11f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 04:57:48 +0000 Subject: [PATCH] profile settings keyed (425 -> 352) ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the model defaults. Language names now come from Intl.DisplayNames rather than the catalog. The runtime already knows "German" in English and "alemao" in Portuguese, so 28 names would have been 56 hand-written entries that Intl gets right for free -- including for locales not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays hardcoded: which languages you may claim to speak is a product choice. ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale -- the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now. Scanner: an Authorization header value like "Bearer " is not a sentence. Excluded by auth-scheme prefix; verified the delta is exactly the four such credentials across both remaining areas. --- scripts/i18n/scan-hardcoded.ts | 4 + .../Settings/ProfileSettings/AIModels.tsx | 18 ++-- .../Settings/ProfileSettings/ApiKeys.tsx | 86 +++++++++------ .../Settings/ProfileSettings/DockSettings.tsx | 14 ++- .../Settings/ProfileSettings/Languages.tsx | 101 +++++++++++------- .../Settings/ProfileSettings/TaskDefaults.tsx | 10 +- .../Settings/ProfileSettings/UserData.tsx | 20 ++-- .../ProfileSettings/VoicePreference.tsx | 14 +-- src/apps/officer-web/locales/en-GB.json | 93 ++++++++++++---- src/apps/officer-web/locales/pt-PT.json | 97 ++++++++++++----- 10 files changed, 301 insertions(+), 156 deletions(-) diff --git a/scripts/i18n/scan-hardcoded.ts b/scripts/i18n/scan-hardcoded.ts index 1420f063..8b09b8b3 100755 --- a/scripts/i18n/scan-hardcoded.ts +++ b/scripts/i18n/scan-hardcoded.ts @@ -81,6 +81,10 @@ function isHuman(value: string | undefined): value is string { if (/^[a-z0-9\-_./:]+$/.test(s)) return false; if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false; if (/^[A-Z0-9_]+$/.test(s)) return false; + // `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string + // pattern because it starts with a capital and interpolates; excluded here because an auth scheme + // followed by a credential is never something a person reads. + if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false; // Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's // `description: 'group-[.toast]:text-muted-foreground'`. // diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx index a1e10c7f..23b35d97 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/select'; import { useSettings } from 'state/useSettings'; import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels'; +import { useTranslation } from '@/lib/i18n'; function buildGroups(models: ModelOption[]) { const groups: Record = {}; @@ -29,6 +30,7 @@ function buildGroups(models: ModelOption[]) { const NONE = '__none__'; export const AIModels = () => { + const { t } = useTranslation(); const { settings, saveSettings } = useSettings(); const piModels = useUserVisibleModels(); const [isSaving, setIsSaving] = useState(false); @@ -54,9 +56,9 @@ export const AIModels = () => { chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel }, tasks: { ...settings.tasks, defaultModel: taskModel }, }); - toast.success('AI model defaults saved'); + toast.success(t('settings.profile.models.aiDefaultsSaved')); } catch { - toast.error('Failed to save settings'); + toast.error(t('settings.profile.models.saveFailed')); } finally { setIsSaving(false); } @@ -86,25 +88,25 @@ export const AIModels = () => { return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ApiKeys.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ApiKeys.tsx index 500d2026..9a0f8d2f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ApiKeys.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ApiKeys.tsx @@ -2,6 +2,8 @@ import type { FormEvent } from 'react'; import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { useFormat } from 'i18n'; +import { useTranslation } from '@/lib/i18n'; import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Input } from '@/components/ui/input'; @@ -36,19 +38,22 @@ type Minted = { key: string; name: string }; const API_KEYS_QUERY_KEY = ['API_KEYS']; -const formatDate = (value: string | null) => - value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null; - -const copy = async (text: string) => { - try { - await copyToClipboard(text); - toast.success('Key copied'); - } catch { - toast.error('Could not copy — select and copy manually'); - } -}; +// `toLocaleDateString(undefined, …)` followed the BROWSER's locale, so a Portuguese UI showed the +// browser's dates. `useFormat` binds it to the locale the rest of the app is in (D7). export const ApiKeys = () => { + const { t } = useTranslation(); + const { date: formatDate } = useFormat(); + + const copy = async (text: string) => { + try { + await copyToClipboard(text); + toast.success(t('settings.profile.apiKeys.copied')); + } catch { + toast.error(t('settings.profile.apiKeys.copyFailed')); + } + }; + const client = useClient(); const queryClient = useQueryClient(); const [name, setName] = useState(''); @@ -74,7 +79,7 @@ export const ApiKeys = () => { setName(''); await refresh(); } catch (ex) { - toast.error(ex instanceof Error ? ex.message : 'Could not create the key'); + toast.error(ex instanceof Error ? ex.message : t('settings.profile.apiKeys.createFailed')); } finally { setIsCreating(false); } @@ -85,17 +90,17 @@ export const ApiKeys = () => { await client.delete(`/api-keys/${id}/record`); await refresh(); } catch { - toast.error('Could not remove it'); + toast.error(t('settings.profile.apiKeys.forgetFailed')); } }; const revoke = async (id: number) => { try { await client.delete(`/api-keys/${id}`); - toast.success('Revoked — anything using that key is signed out'); + toast.success(t('settings.profile.apiKeys.revokeDone')); await refresh(); } catch { - toast.error('Could not revoke'); + toast.error(t('settings.profile.apiKeys.revokeFailed')); } }; @@ -116,7 +121,7 @@ export const ApiKeys = () => { if (isLoading) { return (
- Loading keys… + {t('settings.profile.apiKeys.loading')}
); } @@ -124,15 +129,13 @@ export const ApiKeys = () => { return (

- An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not - tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '} - Authorization: Bearer …. + {t('settings.profile.apiKeys.intro')} Authorization: Bearer ….

{minted && (
- Key for “{minted.name}” — shown once + {t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}

This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it, @@ -143,30 +146,35 @@ export const ApiKeys = () => { {minted.key} -

)}
@@ -174,14 +182,16 @@ export const ApiKeys = () => {
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence that would send somebody looking for a bug in the list. */} - {allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'} + {allKeys.length === 0 ? t('settings.profile.apiKeys.noneYet') : t('settings.profile.apiKeys.noneInUse')}
) : (
{keys.map((entry) => { const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now(); const dead = !!entry.revokedAt || expired; - const lastUsed = formatDate(entry.lastUsedAt); + const lastUsed = entry.lastUsedAt + ? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' }) + : null; return (
{
{entry.name} - {entry.revokedAt && revoked} - {!entry.revokedAt && expired && expired} + {entry.revokedAt && ( + {t('settings.profile.apiKeys.revoked')} + )} + {!entry.revokedAt && expired && ( + {t('settings.profile.apiKeys.expired')} + )}
{entry.prefix}… {' · '} {/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */} - {lastUsed ? `last used ${lastUsed}` : 'never used'} + {lastUsed + ? t('settings.profile.apiKeys.lastUsed', { date: lastUsed }) + : t('settings.profile.apiKeys.neverUsed')}
{dead ? ( @@ -212,7 +228,7 @@ export const ApiKeys = () => { variant="ghost" size="sm" onClick={() => void forget(entry.id)} - title="Remove this record" + title={t('settings.profile.apiKeys.forget')} className="hover:text-red-500" > @@ -222,7 +238,7 @@ export const ApiKeys = () => { variant="ghost" size="sm" onClick={() => void revoke(entry.id)} - title="Revoke" + title={t('settings.profile.apiKeys.revoke')} className="hover:text-red-500" > @@ -241,8 +257,8 @@ export const ApiKeys = () => { className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70" > {showRevoked - ? 'Hide revoked and expired' - : `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`} + ? t('settings.profile.apiKeys.hideDead') + : t('settings.profile.apiKeys.showDead', { count: deadKeys.length })} )}
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx index 5aaab053..369c87f9 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { useDock } from 'officerdev'; import { usePermissions } from 'hooks/usePermissions'; import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock'; +import { useTranslation } from '@/lib/i18n'; type DockPillProps = { label: string; @@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => { }; export const DockSettings = () => { + const { t } = useTranslation(); // Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone // pin a tile that cannot appear, which reads as the setting being broken. const { plugins } = usePermissions(); @@ -193,10 +195,10 @@ export const DockSettings = () => { return (
- + {items.length === 0 && ( - Drag items here to show in dock + {t('settings.profile.dock.dragToShow')} )} {items.map((item) => ( @@ -216,9 +218,11 @@ export const DockSettings = () => { ))} - + {hiddenItems.length === 0 && ( - All items visible + + {t('settings.profile.dock.allVisible')} + )} {hiddenItems.map((item) => ( {
); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx index c9a1d5a2..13f54854 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx @@ -1,46 +1,67 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { X } from 'lucide-react'; import { Label } from '@/components/ui/label'; import { useSettings } from 'state/useSettings'; import { useTranslation } from '@/lib/i18n'; import { SHIPPED_LOCALES } from '@/lib/locales'; -const LANGUAGES = [ - { code: 'en', label: 'English' }, - { code: 'pt', label: 'Portuguese' }, - { code: 'es', label: 'Spanish' }, - { code: 'fr', label: 'French' }, - { code: 'de', label: 'German' }, - { code: 'it', label: 'Italian' }, - { code: 'nl', label: 'Dutch' }, - { code: 'ru', label: 'Russian' }, - { code: 'zh', label: 'Chinese' }, - { code: 'ja', label: 'Japanese' }, - { code: 'ko', label: 'Korean' }, - { code: 'ar', label: 'Arabic' }, - { code: 'hi', label: 'Hindi' }, - { code: 'tr', label: 'Turkish' }, - { code: 'pl', label: 'Polish' }, - { code: 'sv', label: 'Swedish' }, - { code: 'da', label: 'Danish' }, - { code: 'no', label: 'Norwegian' }, - { code: 'fi', label: 'Finnish' }, - { code: 'uk', label: 'Ukrainian' }, - { code: 'cs', label: 'Czech' }, - { code: 'ro', label: 'Romanian' }, - { code: 'el', label: 'Greek' }, - { code: 'he', label: 'Hebrew' }, - { code: 'th', label: 'Thai' }, - { code: 'vi', label: 'Vietnamese' }, - { code: 'id', label: 'Indonesian' }, - { code: 'ms', label: 'Malay' }, -]; +/** + * Language names come from `Intl.DisplayNames`, not from the catalog. + * + * The runtime already knows every language's name in every locale — "German" in English, "alemão" in + * Portuguese — so 28 names would have been 56 hand-written catalog entries that `Intl` gets right for + * free, including for locales we have not shipped yet. Same reasoning as D7 for dates and numbers. + * + * The tags themselves stay a hardcoded list: this is "which languages may I claim to speak", a product + * choice, not something to enumerate from the runtime. + */ +const SPOKEN_LANGUAGE_TAGS = [ + 'en', + 'pt', + 'es', + 'fr', + 'de', + 'it', + 'nl', + 'ru', + 'zh', + 'ja', + 'ko', + 'ar', + 'hi', + 'tr', + 'pl', + 'sv', + 'da', + 'no', + 'fi', + 'uk', + 'cs', + 'ro', + 'el', + 'he', + 'th', + 'vi', + 'id', + 'ms', +] as const; -const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label ?? code; +/** `Intl` yields "alemão" in pt; a picker row reads better capitalized. */ +const useLanguageName = () => { + const { locale } = useTranslation(); + return useMemo(() => { + const names = new Intl.DisplayNames([locale], { type: 'language', fallback: 'code' }); + return (code: string) => { + const name = names.of(code) ?? code; + return name.charAt(0).toUpperCase() + name.slice(1); + }; + }, [locale]); +}; export const Languages = () => { const { settings, saveSettings } = useSettings(); const { t } = useTranslation(); + const languageName = useLanguageName(); const { spoken, default: defaultLang, translateTo } = settings.languages; const [addingLang, setAddingLang] = useState(''); @@ -63,7 +84,7 @@ export const Languages = () => { save(updates); }; - const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code)); + const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code)); return (
@@ -76,7 +97,7 @@ export const Languages = () => { key={code} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium" > - {getLabel(code)} + {languageName(code)} {spoken.length > 1 && (
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx index a063d529..2468ca79 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/select'; import { useClient } from 'hooks/useClient'; import { useSettings } from 'state/useSettings'; +import { useTranslation } from '@/lib/i18n'; type VoiceGroup = { label: string; voices: string[] }; @@ -26,6 +27,7 @@ type TtsConfig = { const SERVER_DEFAULT = '__server_default__'; export const VoicePreference = () => { + const { t } = useTranslation(); const client = useClient(); const { settings, saveSettings } = useSettings(); const [voices, setVoices] = useState([]); @@ -128,13 +130,13 @@ export const VoicePreference = () => {