Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 ff647fba0a release 2026.08.23.04
The web UI can be read in Portuguese. The shell and shared components are fully
keyed, 581 keys across two complete catalogues, with the build failing on a
missing one. Plus an endpoint for spawning an agent, and spawned agents becoming
visible in both directions.

No schema change and no dependency change. db:push should report nothing, and the
plan says to stop and report it if it does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:51:50 +00:00
pastilhasandClaude Opus 5 2f8a2b60d5 localization: the 'what exists today' table described a world three phases ago
Every row was wrong — bare en.json, 46 keys, three components, 5% deployed — and
it is the first thing whoever picks up phase 3 reads. platform-59 flagged it and
left it alone rather than colliding with me mid-edit, which was the right call.

Kept as a before/now table rather than replaced. The shape of the gap is the
argument for the phases, and deleting the 'before' column would leave them looking
arbitrary.

Also records two things the plan did not anticipate, both found by doing the work:
TranslationKeys walked into a plural object so t('key', {count}) — the whole point
of D7 — would not compile while the workaround did, which no test could have caught
because phase 1 had no plurals. And the typed wrappers rebuilt t every render,
which is the trap CLAUDE.md already documents against useClient().

And the shape this catalogue format cannot express: a sentence with an inline link
splits into a key whose Portuguese value is '.'. Flagged rather than solved, since
a node-level interpolation helper is a design decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:43:49 +00:00
pastilhas 454a0f4244 hold officer-web at zero hardcoded strings
Phase 2 is complete: 555 -> 0. Verified the guard both ways -- exit 1 naming the
string and file when one is reintroduced, exit 0 once it is gone.

officerdev is still at 578 and stays out of ZEROED; that is phase 3.
2026-08-23 05:40:01 +00:00
pastilhas a48a7ff696 the last two shell strings were the composer's pre-filled prompt (2 -> 0)
CapabilityPage puts a suggestion in the chat composer, which the reader sees and
edits before sending -- user-facing, so translated. The hidden promptFrontmatter
beside it is NOT: that one instructs the agent and carries file paths. The scanner
never flagged it, correctly, because it opens with a tag rather than a capital.

officer-web is now at zero convertible strings.
2026-08-23 05:38:17 +00:00
pastilhas bde59eb74e activity, capabilities, chat history and the locale picker (21 -> 2)
The locale picker's labels are now AUTONYMS computed from Intl -- each language named
in its own language, so "Português europeu" reads as Portuguese whichever locale the
UI is in. That is the convention for a language picker and the useful behaviour:
somebody who cannot read the current language still has to find theirs.

CapabilityPage interpolated the capability KIND into six strings by lowercasing it in
code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now.

What is left is two strings, both deliberate: see the next commit message.
2026-08-23 05:37:04 +00:00
pastilhas 0b85d42197 jobs keyed (73 -> 21)
The three job detail panes and the jobs page. Status labels share one jobs.status.*
group across all three panes rather than three copies of "Running".

statusBadge in two of the panes is a module-level helper, not a component, so it
takes `t` as a parameter -- the alternative is calling a hook outside a component
body. ScriptJobDetail's version built "Failed (exit N)" by string concatenation;
that is a key with a parameter now.
2026-08-23 05:30:45 +00:00
pastilhas 2e846933e9 shortcut registry keyed (96 -> 73)
shortcuts.ts is data with no component around it, so it carries catalog keys and
ShortcutsModal resolves them -- same shape as the settings sections.

gen-platform-skill.ts also reads this registry to generate the agent-facing SKILL.md,
which docs:check validates. That doc stays in one language, so the generator resolves
against en-GB directly and throws if a key is missing rather than emitting a dot-path
into a file an agent will quote at somebody. The regenerated skill is byte-identical,
which is the check that keying the registry changed nothing an agent reads.
2026-08-23 05:26:43 +00:00
pastilhas 5e032898f8 integrations keyed, and the Google guide's foreign labels marked (163 -> 96)
Applies the rule added to docs/localization.md in 91b9bec9: a label quoted from
another product's interface is an identifier, so the prose around it is translated
and the label itself is left in English and NOT keyed.

Gives that decision a name in the code. <Untranslated reason="foreign-ui-label">
states it at each of the ~30 call sites, where whoever next edits the sentence will
see it, and gives the scanner something exact to skip -- without which labels that
MUST stay English count as debt forever and the area can never be held at zero. That
is the same trap as counting new Error() messages. `reason` is a closed union so a
second excuse has to be argued for rather than smuggled in as free text.

The sentences needed rephrasing, not just splitting. "Go to the [New Project] page."
decomposes into "Go to the" + link + "page." -- and the Portuguese value of that
second key is "." A key that holds a full stop is the fragmentation smell, so the
English now puts the label at a clause boundary ("Open the project creation page:"
then the link), which reads naturally in both languages and needs no node-level
interpolation. Worth knowing for phase 3: sentences with an inline link are the one
shape this catalog format cannot express, and rephrasing is the cheap way out.

Scanner: the existing SCREAMING_CONSTANT rule now allows . and - so Google's
client-secret prefix example GOCSPX-... falls under it. Same rule, wider class.
2026-08-23 05:24:32 +00:00
pastilhasandClaude Opus 5 91b9bec926 localization: a label quoted from another product's UI is an identifier
Sonnet stopped phase 2 on GoogleOAuthConfig.tsx, where 55 of 67 strings are
quoted from Google's own console — 'Click Enable', 'set App name'. It called it an
editorial choice and asked. It is not a choice; it is already covered by the
principle the never-translate list is built on, and the list just did not say so.

Those labels read like prose and behave like identifiers: their whole job is to
match what the reader sees on Google's screen, and Google localises by the reader's
Google account language rather than ours. Translating them sends somebody hunting
for 'Ativar' in a console that says 'Enable'.

So: translate the prose around them, leave the label in English, and do not key it
with identical values on both sides — a key whose two halves can never legitimately
differ is a maintenance cost bought for nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:18:14 +00:00
pastilhas b978e09327 authentication and calendar-sync passwords keyed (214 -> 163)
Authentication reaches zero -- sign-in, bootstrap, forgot and reset password. These
are the first words a new account reads, which is why the brief ranked them above
the larger screens.

DavAppPasswords had the third toLocaleDateString(undefined, ...) in the shell; it now
uses useFormat().date like the other two.

DebugPanel's three labels are keyed too. Worth knowing rather than acting on: that
panel is rendered behind showDebug, which Background.tsx hardcodes to false, so it
never appears. Keyed rather than deleted because removing it is not my call.
2026-08-23 05:15:22 +00:00
pastilhas 7f8455f1af user management keyed (267 -> 214)
Accounts, permissions and the create-user form. The confirm dialogs for removing an
account and resetting a password were multi-line JSX the scanner cannot see, so they
were converted on sight rather than from its list.

Cancel comes from ui.button.cancel in all four places it appears here.
2026-08-23 05:10:17 +00:00
pastilhasandClaude Opus 5 f6b9ddc877 a spawned session says who started it
The other half of making a spawned agent visible. The commander's pane now lists
its children; opening one gave no way back — no link to the conversation that owns
it, and nothing to tell it apart from a chat somebody had started themselves and
forgotten.

`parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from
the other end. So this needed no table and no write path: a commander's children
were recorded because deleting one has to take them with it, and that record
answers "who is my parent" for free. Nothing had asked.

`GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line
above the transcript linking back to the commander. Null for almost every session,
which is the normal case rather than a miss — a chat somebody opened has no parent
— so the bar renders nothing at all rather than saying so. Never fatal: a session
must open when this lookup will not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:09:28 +00:00
pastilhas 44f5246e38 server settings keyed (352 -> 267)
ServerSettings reaches zero: OCR, STT, TTS, SMTP, maintenance and the AI harnesses.

Two more hand-rolled plurals became real plural entries -- maintenance said
"commit" with a ternary on length === 1, and both read correctly at 0, 1 and 2 in
both locales now. The three Cancel buttons in AIHarnessesSection take ui.button.cancel
rather than adding a fourth app-level copy of the word, which is what phase 1 put it
there for.

Scanner: a single token carrying digits and punctuation is a name, not a sentence --
the OCR placeholder is the example model file Qwen2.5-VL-7B-Instruct-q4_k_m.gguf.
Prose has spaces, and requiring whitespace to be absent is what keeps this from
eating real one-word labels. Verified the delta is exactly that one string.

Note for the owner: another session has src/servers/api/sub-agents/router.ts in
flight with three type errors. Untouched by me and excluded from my gate; the full
tree will not typecheck clean until they land it.
2026-08-23 05:04:22 +00:00
pastilhasandClaude Opus 5 9b0376ec39 todo: close the spawning endpoint
Both modes verified against the live server, not reasoned about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:02:45 +00:00
pastilhasandClaude Opus 5 66234cd3e0 an endpoint for spawning an agent, so a caller that is not a chat can
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.

So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.

The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.

`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.

Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.

Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:02:29 +00:00
pastilhas ad8ed88519 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 <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
2026-08-23 04:57:48 +00:00
pastilhasandClaude Opus 5 7ce5af88aa todo: close the invisible spawned agent
The endpoint, table and ingest all existed and nothing called them, so the table
read empty and the feature looked unbuilt. Ingest on read means an empty table was
itself the symptom: nobody had looked.

The proposed second fix — the loop echoing the transcript id — turned out
unnecessary. The ids are already in the loop's spawned record, which the endpoint
ingests, so they reach the UI without the loop narrating anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:55:11 +00:00
pastilhasandClaude Opus 5 6bfc1d38f7 a spawned agent is a link, not a rumour
The endpoint, the table and the ingest all existed and nothing had ever called
them — so `command_hq_spawned` sat empty and a spawned agent stayed invisible even
though its transcript was readable the whole time. It writes an ordinary transcript
under the project directory for its cwd, so `/chat/<id>` has always rendered it
live. What was missing was anybody being told the id existed: the job panel showed
its output as a text stream in a small box with no link, so finding the session
meant knowing to look.

Now the commander's own pane lists them, each a link to its transcript, with a dot
on the ones still running. Ingest happens on read, so the list is right without a
timer that can stop working quietly.

Cost is stated as a floor when any child did not report one — `$1.20+` and a count
of unknowns, never a confident total. A commander's line reading $0.28 for a night
that spent several dollars is the specific understatement this exists to stop.

Renders nothing when there are none. A commander that has not fired yet is the
normal case and does not need a box explaining that.

Closes todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:54:54 +00:00
pastilhas c10dae3e6e scanner: text inside code, kbd, pre and samp is code, not prose
ApiKeys.tsx documents the header to send as <code>Authorization: Bearer …</code>.
That is an identifier crossing a boundary on the doc's never-translate list, and
translating it would break the thing it documents. Those four elements exist to mark
content as code, so the scanner now honours them.

Verified the delta across both remaining areas is exactly that one string.
2026-08-23 04:47:05 +00:00
pastilhasandClaude Opus 5 41fffbebe9 todo: close delivering a turn to a named session — answered from both ends
SendMessage does agent-to-agent with notify_when_idle instead of a poll, proven
tonight when one session took over the localisation hand-off from the owner. The
nudger does backend-to-session on a schedule, which is how a commander now reports
into its own conversation rather than a job panel.

Kept open deliberately: spawning a NEW session from a non-chat caller. The harness
Agent tool does not close that — it is available to a chat agent and nothing else,
which is why a commander's loop still shells out to claude -p.

Also kept: 'a spawned agent is invisible in the interface'. That is a defect in
Officer's own UI with a one-echo fix, and deleting it because an affordance now
describes coordination is exactly what docs/affordances.md warns about — an
affordance describing a defect calcifies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:47:03 +00:00
pastilhas c3c62828e3 settings framework and section metadata keyed (487 -> 425)
SettingsSection now carries titleKey/descriptionKey instead of title/description, and
the sidebar and content pane resolve them. The section arrays are module-level consts
holding JSX content, so they cannot call a hook — the data holds keys and the renderers
translate. Sidebar search matches the RESOLVED words, so it works in the language on
screen.

Capital K in titleKey is load-bearing: docs-check-settings.ts greps key:\s*'…' for
route segments, and a lowercase title_key would have fed it twenty bogus paths.
Verified 0 bogus, 26 declared, 20 offered, 0 missing.

Section titles now share one key with the Spotlight rows that name the same section,
per 'one key per meaning'. Two casings had to converge, so Settings shows Spotlight's:
'Change Password' now reads 'Change password'.

Settings sidebar search placeholder was 'Search...'; it is now 'Search settings…',
which is what the skeleton catalog already said and what distinguishes it from the
global Spotlight.
2026-08-23 04:46:07 +00:00
pastilhas 926034750f keyed the dock, header and shell chrome (555 -> 487)
Layout reaches zero. The dock, header, user menu, theme switcher, spotlight,
shortcuts modal, screen-error fallback, rescan and bug report.

Three things worth knowing:

CORE_DOCK_ITEMS now carries labelKey instead of label, and one useDockItems() hook
resolves the composed list. DashboardLayout, Spotlight and DockSettings each spread
the same two sources before, so each would have had to resolve labels itself.
Plugin tiles keep the name from their own manifest — a plugin's strings are not ours.

Spotlight resolves labels INSIDE the hook, so rank() still matches what the reader
can see rather than a dot-path. Its to: '/settings/...' literals are untouched, which
is what docs:check parses — verified still 20 offered, 0 missing.

The rescan toast said 'N skill(s)'. That is English giving up on a plural, so it is
now a real plural entry per noun and reads correctly at 0, 1 and 2 in both locales.

ThemeSwitcher used t as its theme loop variable; renamed to entry.
2026-08-23 04:40:39 +00:00
pastilhasandClaude Opus 5 6d8709c05a plan: roles as data, teams as scopes, and the owner as an identity
Design for tomorrow, written down so the work starts from a spec. Phase 0 is
already built; everything after it is plan.

The load-bearing decision is the smallest one. Ownership is answered today by
users.role === 'Super Admin', which was fine while roles were a hardcoded union
and nothing could write a new one — and becomes a privilege-escalation path the
moment roles are editable data, because then adding a role and becoming the owner
are the same feature. So phase 1 is just that: the owner is OWNER_USER_ID, every
existing call site keeps meaning owner, and nothing downstream starts until it
lands.

Teams union rather than restrict, which is the only coherent resolution and has a
consequence worth stating: a team can only ever grant. Deny rules are named under
what must not be built, along with a role that confers ownership and team-scoped
execution capabilities.

And the thing a reader would otherwise discover at the end of phase 4: there is
still no way to create a second account, so this is a permission model for
accounts that cannot yet exist. Sequenced last because it is orthogonal, flagged
because it may be worth doing first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:40:27 +00:00
pastilhas 2765e241d3 TranslationKeys stops at a plural entry instead of walking into it
A phase 0 defect that could only surface once a real catalog used a plural. The type
walked into { one, other } and contributed 'foo.one'/'foo.other' to the key union, so
t('foo', { count }) — the whole point of D7 — did not compile, while t('foo.one') did.

Found by the type checker on the first genuine plural in phase 2 (the rescan toast).
Mirrors isPluralForms at runtime: every key a CLDR category, every value a string.
Pinned with type-level @ts-expect-error assertions, since nothing else would notice.
2026-08-23 04:38:26 +00:00
pastilhas 36e18f935d scanner: do not count commented-out code
Header.tsx has a disabled dev-console button inside a JSX comment and
IntegrationsSettings has a commented-out section literal. Four hits in
officer-web, all of them code nobody ships — and an area cannot reach zero while
a comment counts against it.

Only two shapes stripped, both unambiguous: block comments, and lines whose first
non-space characters are //. Stripping every // would eat the tail of any string
holding a URL. Verified the delta is exactly those four.
2026-08-23 04:32:06 +00:00
pastilhas 0ddcdcbdbf memoize t in the typed wrappers, and give app code typed access to ui.*
Each wrapper built a fresh closure every render, so `t` in a dependency array
recomputed forever. That is the documented useClient()/Jellyfin trap in
platform/CLAUDE.md, and phase 2 needs t in useMemo deps immediately.

useTranslation in officer-web now also returns tUi, typed against the shared
components' catalog. 'Cancel' and 'Close' recur across the shell and already exist
in ui.* — the alternative was importing two hooks in a hundred files.
2026-08-23 04:30:07 +00:00
pastilhas 99a78f3c7f note the provider requirement shared components now carry, under open question 3
Found while converting them in phase 1: they resolve against whatever provider is
above them and render dot-paths with none. Safe today only because the second React
root imports nothing from components/. Written where the plugin-contract decision
will be made rather than solved here.
2026-08-23 04:27:04 +00:00
pastilhasandClaude Opus 5 845699e697 deploy: arming for the first time has nothing to rehearse against
A commander read the general doctrine — run the action once before arming — and
proposed replaying the last release against it. Fair reading of what was written,
and wrong here: every tag already has a success record, so the claim refuses, and
the only way to manufacture a trigger is to tag a release, which it must never do.

So the deploy procedure now says it outright: arm, and state that the action has
never executed under you. The next real release is the first run.

This belongs in deploy documentation rather than commander doctrine. The general
rule is sound; what is specific to this job is that its triggers are all spent, and
the agent should not have to be told that by the owner after the fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:23:43 +00:00
pastilhas fe000541b7 hold shared components at zero hardcoded strings
The area reached zero convertible strings, so it goes in ZEROED and the scanner's
--check now fails if a future feature reintroduces one. Verified both ways: exit 1
naming the string and file when one is added, exit 0 when it is gone.

Enabled per area rather than globally, as the doc says — officer-web and
officerdev are still at 559 and 578, and would be red for weeks.
2026-08-23 04:19:41 +00:00
pastilhasandClaude Opus 5 dfbf9be61c the two messages are an obligation in the brief, not advice in the skill
A commander read the skill section on telling the owner and did not mention it in
its read-back, so the owner would have had to remind it — which is the failure, not
the remedy. Doctrine in the skill is advice a commander may or may not weigh;
the brief is the prompt and always arrives. Same lesson as moving the output
constraints there earlier tonight.

So the brief now states it as owed: two messages per event, one when the thing
happens and one when the work is done, and never having to be asked. The skill
still carries the how.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:18:32 +00:00
pastilhas be29ae7aa7 delete two unreferenced components from another codebase
MarkdownEditor and ui/start-confetti-button had no importers anywhere in the repo
and both carried a different product's content: MarkdownEditor hardcoded
https://app.pertento.ai/logo.png with alt="Pertento", and start-confetti-button
spoke about 'the experiment' and 'Draft/Created' statuses.

Translating either would have put feature words into ui.*, which D6 forbids —
a shared namespace may not name a feature.

todo/inputs-outside-forms named MarkdownEditor as deliberately-unwrapped, so that
entry is updated rather than left pointing at a deleted file.
2026-08-23 04:18:08 +00:00
pastilhas e3e790fd2b command block and colour picker keyed
ColorPicker's one string was placeholder="Font Weight" on an <input type="color">,
where a placeholder is both wrong text and inert — browsers do not render one on a
colour input. Keyed it as ui.colorPicker.placeholder and moved it to the hex text
field beside it, which is a real text input and had none. Translating it in place
would have created a key guaranteed never to be seen.

Both files still have no importers; CommandBlock stays because commands the user
might run need a copy button by convention.
2026-08-23 04:16:54 +00:00
pastilhas 2b0b671895 scanner: separate never-translate hits from convertible ones, and stop counting tailwind as prose
The --check guard compared an area's whole hit count against zero, including
'thrown errors' — which the doc's never-translate list forbids converting. An
area holding one legitimate new Error() could therefore never be added to
ZEROED, so the debt guard could never be switched on for it at all. Categories
now carry neverTranslate, are still reported, and are excluded from --check.

Also drops sonner's 'group-[.toast]:text-muted-foreground', matched as an object
label. The filter tests per token rather than per string: the first attempt
tested the whole string and ate a real label, 'Application type:'.

--check now prints the offending strings and files instead of only a count.
2026-08-23 04:13:21 +00:00
pastilhas 2ec3f11cdd shadcn primitives and logos keyed: dialog, sheet, pagination, carousel, breadcrumb, theme toggles
These are the strings a user meets everywhere, mostly sr-only: the dialog and
sheet close buttons, pagination labels, carousel arrows, the breadcrumb ellipsis
and both colour-mode toggles. nav aria-label='pagination' became a real key too —
a screen reader announces it, so it is user-facing.

Logo alt text keeps the product name and translates only around it
('Logótipo Officer'). Arrow-button insets became logical (-start-12/-end-12).

The three 'useX must be used within <Y>' Errors are deliberately untouched:
never-translate, per the doc's list.
2026-08-23 04:10:39 +00:00
pastilhas 5ce1a2928e inputs and data views keyed: search, select, tabs, state blocks, data table
The DataTable's pagination carries the only interpolated strings in this area
('Showing page {{current}} of {{total}}', '{{size}} / page'). No plural forms:
nothing here is a count-varying noun, so inventing one would only add a key
nobody uses.

DataTable.test.tsx now renders inside an I18nProvider with the real en-GB
catalog, so it still asserts the English a user sees rather than a dot-path.

Left/right utilities became logical (ms-/me-/ps-/pe-/start-) per D3.
2026-08-23 04:10:33 +00:00
pastilhas 339d31f05c ui.* catalog and the dialogs: error dialogs, confirm and paper dialogs keyed
Establishes the ui.* namespace for phases 2 and 3. Both catalogs complete, so the
key-parity test holds. Two English copy defects fixed while keying, because the
Portuguese must not inherit them: 'Your are not authorized' -> 'You are', and
'Please Signin again' -> 'Please sign in again'.

The server's own message in ServerError/CustomError stays verbatim — it is a
response, not UI copy (D1).
2026-08-23 04:10:27 +00:00
pastilhas a444280cb4 deploy report 2026.08.23.03 2026-08-23 04:02:58 +00:00
pastilhasandClaude Opus 5 c0788d15c0 release 2026.08.23.03
Roles become a table and localization phase 0 lands. Both groundwork; nothing in
the UI changes and nothing reads the new table yet.

Schema change, additive only — the roles table is new and no constraint is added
to an existing column, so it cannot fail on existing data. postdb:push seeds it.
No dependency change; the two lockfile lines are the local i18n workspace being
declared as a dependency of officerdev.

The plan names the check that matters: four rows in roles, per host, by query —
db:push can fail and exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:56:07 +00:00
pastilhasandClaude Opus 5 7704867e05 roles are a table, seeded, with nothing depending on it yet
Phase 0 of moving roles out of a TypeScript union. The four the code names become
rows, marked isSystem because each is referenced by name somewhere:
rolePermitsDevTools tests for 'Developer', bootstrap writes 'Super Admin',
users.role defaults to 'Member', and 'Admin' is what rolePermitsDevTools
deliberately excludes. Renaming or deleting one of those is a code change, so the
flag is there for a future delete path to refuse on rather than for the failure to
surface later as an account that cannot log in.

DELIBERATELY NO FOREIGN KEY on users.role. That change cannot go through db:push:
push creates the table empty and adds the constraint in the same run, so every
existing account violates it. Verified against a scratch database — push reported
the violation, created the table, skipped the constraint, and exited 0. The FK is
a separate release, once every host holds these rows.

So this phase is additive and cannot fail, which is what makes it safe to hand to
an unattended rollout across five hosts. Nothing reads the table yet; USER_ROLES
is still the type and authorize.ts is untouched.

The seed runs from postdb:push, so a host gets the rows on its next schema push
with no extra step in a deploy plan. It never overwrites: a description edited on
one server must not be reverted by the next deploy.

Verified here the way the deploy doc now requires — by query, not by exit code.
Four rows present, seed idempotent on a second run, and a second push is a no-op,
which is the proof that the schema and the database agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:52:38 +00:00
pastilhasandClaude Opus 5 04a4ab33dd deploy: db:push can fail and exit 0, so check the database instead
Verified against a scratch database on 2026-08-23. A push whose foreign key
violated existing rows printed the Postgres error, created the new table, left the
constraint uncreated, and returned zero. `bun db:push` wraps it, so a plan step
that reads `bun db:push` can report success over a half-applied schema.

The rollout already survived this twice only because the agent decided on its own
to verify per host by query rather than trusting the exit code. That instinct is
now the instruction.

Also documents the shape that cannot go through push at all — a new table plus a
foreign key onto it from an existing column, where the seed has to happen between
the two halves — and what direct SQL is allowed to look like when a plan calls for
it: the Drizzle schema stays the source of truth, the SQL comes from the plan
rather than from the agent, it is idempotent, push is run afterwards and expected
to be a no-op as proof the two agree, and every host is verified by query.

And one refusal: never `--force`. Its job is answering the destructive prompts
unattended, which on five hosts holding real data is not a convenience.

This is deploy documentation, not commander doctrine — a developer running
something similar should be able to reach the same conclusions, and the commander
architecture does not need to carry it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:49:48 +00:00
pastilhas b640ec486d i18n: default-language picker offers only shipped locales; widen stored bare codes on read 2026-08-23 03:43:35 +00:00
pastilhas 0bd8c6c16a i18n: officer-web catalog goes regional (en-GB, pt-PT), bridge deep-merges app/panel/ui and sets dir 2026-08-23 03:43:01 +00:00
pastilhas 6ac9187c52 i18n: scaffold panel.* and ui.* locale catalogs, wire panelCatalogs into officerdev's barrel 2026-08-23 03:42:53 +00:00
pastilhas dcd11a3b17 i18n: plural selection via Intl.PluralRules, dev-mode miss warning, useFormat, key-parity helper 2026-08-23 03:42:46 +00:00
pastilhasandClaude Opus 5 3dd37d053b localization: every locale ships a complete catalog, no regional overlays
D2 had pt-BR inherit from pt so a base carried the shared bulk and a regional file
carried the differences. Conventional, and rejected by the owner for a product
reason that outranks the engineering one.

The failure mode of an overlay is a missing European Portuguese key falling
through to Brazilian — and the two differ most in exactly the technical vocabulary
this UI is full of. The platform sells into European Portuguese first, so that
failure is a reader getting a neighbouring variant's words on whichever strings
nobody remembered to override, silently.

So every locale is a whole file, translated, or it does not ship. The drift
objection is answered by promoting the key-parity test from advisory to
load-bearing: a locale missing a key `en` has fails the build, so an incomplete
catalog cannot ship at all. Completeness is enforced rather than papered over.

It is also less code — resolveKey looks in one catalog and then English, with no
chain to walk and no merge order — and it removes the question of which file a
given string lives in.

Consequence for the work: a new locale is a full machine-translated pass reviewed
on its own, not a diff against a base. pt-PT and pt-BR get one each, separately,
because the whole point is that neither borrows from the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:21:21 +00:00
pastilhas 64894ad96e deploy report 2026.08.23.02 2026-08-23 02:59:30 +00:00
145 changed files with 4822 additions and 1609 deletions
+4 -1
View File
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
@@ -477,6 +477,9 @@ explaining why it was safe.
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
+2
View File
@@ -210,6 +210,7 @@
"dotenv": "^16.4.7",
"embla-carousel-react": "^8.5.1",
"hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.4",
@@ -271,6 +272,7 @@
"version": "0.0.1",
"dependencies": {
"hooks": "workspace:*",
"i18n": "workspace:*",
"state": "workspace:*",
"widgets": "workspace:*",
},
+69
View File
@@ -0,0 +1,69 @@
# 2026.08.23.02 — deploy report
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
## Per host
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
| `officer-dev` | — | — | — | — | — | SKIPPED |
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
`sendoff_senders` are new tables, so the diff was additive.
## What restarted, beyond what the plan asked for
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
not a fault, and it is the larger part of what moved:
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
- `fofos` — one (`officer-gitea`).
- `production-pertento-bridge` — one (`officer-gitea`).
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
- `macbookpro` — none; no plugins are installed there.
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
on any host was `errored`.
## Hosts the plan flagged
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
steps, with `pm2` run separately through `$SHELL -ic`.
## Things worth knowing, none of them failures
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
it, and it was left alone.
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
## What was not done
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
+64
View File
@@ -0,0 +1,64 @@
# 2026.08.23.03
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
changes and nothing reads the new table yet.
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
existing data.
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
`bun install`.**
## What is in it
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
`db:push` and is a later release.
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
step here. It never overwrites an existing row.
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
(`en` → `en-GB`), with no database migration.
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
create.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
exists and holds four rows on that host. `db:push` can fail and still exit 0.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## The check that matters this release
```sql
select count(*) from roles; -- expect 4
```
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
per host in the report.
No direct SQL is needed and none is authorised for this release.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
## `officer-dev` — skip it
The rollout runs from here. It already holds the release and the four rows, verified locally.
+42
View File
@@ -0,0 +1,42 @@
# 2026.08.23.03 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
attempted, nothing spent on it).
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
authorised and none was needed.
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
## Per host
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
`online`** — not just `officer`, every entry.
## Notes, and what was not done
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
restarting `officer` on it is the owner's deliberate last step.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
never have collided with it, so it is not a reason to skip a host.
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
before `officer` itself was touched.
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
- **Nothing was retried and nothing was repaired.** No step failed on any host.
+61
View File
@@ -0,0 +1,61 @@
# 2026.08.23.04
The web UI can be read in Portuguese. The shell and the shared components are fully keyed — 581 keys across
two locales, with the build failing on a missing one — plus an endpoint for spawning an agent, and spawned
agents becoming visible in both directions.
**No schema change.** Nothing under `src/databases/**/schema.ts` moved across
`deploy/2026.08.23.03..HEAD`. `bun db:push` is still a step because it is cheap and it verifies the
database agrees with the code, but it should report no changes.
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
## What is in it
- **Localization, phases 0 through 2.** `src/apps/officer-web` and `src/workspaces/components` have no
hardcoded user-facing strings left: 555 and 47 occurrences respectively, both now zero. `en-GB` and
`pt-PT` are complete and in step at 581 keys.
- **Every locale ships a complete catalogue.** There is no fallback between regional variants, so a
Portuguese reader is never served Brazilian wording. What enforces that is a **key-parity test that fails
the build** on a gap — verified by removing one key and watching it go red.
- **The language picker offers only locales that exist.** It listed about twenty codes, eighteen of which
silently rendered English. Stored bare codes widen on read (`en` → `en-GB`), with no database migration.
- **Dates, numbers and plurals follow the account's locale**, not the browser's, through `useFormat()` and
`Intl.PluralRules`. Roughly 23 call sites moved.
- **`POST /api/sub-agents/spawn`** — an endpoint for starting an agent from a prompt, for callers that are
not a chat and so cannot reach the harness's own tool. Budget-checked before spawning, and it refuses with
a 429 naming the window rather than burning the last of one.
- **Spawned agents are visible in both directions.** A commander's pane lists its children as links; opening
one shows which commander started it, linking back.
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
error — so answering a question during a restart window vanished and the card spun forever.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.04`
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: that means
this tree and that host's database disagree about something this release did not touch.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## What to check, this release
The visible change is the language picker in profile settings. It should offer exactly **English (UK)** and
**Português (Portugal)** and nothing else. A host still offering twenty languages did not pick up the
frontend bundle.
No direct SQL is needed and none is authorised.
## Hosts
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
- **`production-pertento-bridge`** — deployed cleanly last release after being two behind. Nothing expected.
## `officer-dev` — skip it
The rollout runs from here. It is already at this commit and `officer` has been restarted on it.
+108 -53
View File
@@ -25,46 +25,55 @@ the server does instead, which is the one decision that reaches outside this bou
## What exists today, and what it cannot do
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
also about **5% deployed**, and that gap is the whole job.
**Updated 2026-08-23, after phases 0, 1 and 2.** This section described the pre-phase-0 world for long
enough that the next reader would have started from a false picture — bare `en.json`, 46 keys, three
components. All of that is now history and is kept below only as the "before" column, because the shape of
the gap is the argument for the plan and deleting it would leave the phases looking arbitrary.
| | |
| --- | --- |
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
| | before phase 0 | now |
| -------------------------- | ---------------------- | --------------------------------------------- |
| catalogs | `en.json`, `pt.json` | `en-GB.json`, `pt-PT.json` per workspace |
| keys | 46 | **581** — 538 `app.*`, 43 `ui.*` |
| workspaces with a catalog | 1 (`officer-web`) | 3 (`officer-web`, `components`, `officerdev`) |
| components using it | 3 | all of `officer-web` and `components` |
| plural rules | none | `Intl.PluralRules`, per locale |
| dates and numbers | browser locale | `useFormat()`, the account's locale |
| a missing key | rendered its dot-path | falls back to English, warns in dev |
| locales offered vs shipped | ~20 offered, 2 existed | only shipped locales are offered |
| parity enforcement | none | **a gap fails the build** |
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
typecheck failure, which is worth more than it sounds when one agent is editing 400 files.
Still standing from the original description, and still true: the package is hand-rolled, has no npm
dependency, and gives type-safe keys — a wrong key is a typecheck failure, which is worth more than it
sounds when one agent is editing hundreds of files.
**What it cannot do yet**, each of which is a task below:
**What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
wired into the bridge, and empty.
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
runtime and unused.
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
the *browser's* locale, not the user's chosen one, so a Portuguese UI shows American dates.
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
from `pt`.
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
`officerdev` and the 91 in `components` have no catalog of their own.
5. **Silent, ugly misses.** A missing key renders its own dot-path (`settings.profile.foo`) to the
user. No warning, no test.
6. **Twenty languages offered, two that exist.** The picker in `Languages.tsx` lists ~20 codes
including Hebrew and Greek. Choosing any of the eighteen silently yields English.
Two things learned doing phases 1 and 2 that the plan did not anticipate:
- **`TranslationKeys` walked into a plural object**, so `t('key', { count })` — the entire point of D7 —
did not compile while `t('key.one')` did. The documented feature was uncompilable and the workaround was
not, which no test would ever have noticed because phase 1 contained no plurals. Fixed with type-level
assertions.
- **The typed wrappers rebuilt `t` on every render**, which is the trap `CLAUDE.md` already records against
`useClient()` and the Jellyfin player. It matters the moment `t` lands in a `useMemo` dependency list,
which phase 2 needed immediately. Memoised.
**A shape this format cannot express:** a sentence with an inline link. Splitting around the anchor gives a
key whose Portuguese value is `"."`. The cheap fix is rephrasing so the link falls at a clause boundary;
there is no node-level interpolation helper and adding one is a design decision, not a patch. Worth
raising if a phase turns out to be full of them rather than absorbing it forty times.
### The size of the remaining work
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
| area | files | occurrences |
| --- | --- | --- |
| `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 |
| **total** | **436** | **1170** |
| area | files | occurrences |
| --------------------- | ------- | ----------- |
| `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 |
| **total** | **436** | **1170** |
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
@@ -101,29 +110,46 @@ precise to quote. Prose drifts; a code does not. And the server stops needing to
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
the prose was careful not to be. When in doubt, one code for one *response*, not one per branch.
the prose was careful not to be. When in doubt, one code for one _response_, not one per branch.
### D2. Regional locales, BCP 47, with fallback chains
### D2. Regional locales, BCP 47, and every catalog is complete
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it,
and it is the owner's own language, so getting it wrong is felt immediately.
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
it is the owner's own language, so getting it wrong is felt immediately.
Resolution walks a chain and stops at the first hit:
**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
every key the UI has, translated for that locale, or it does not ship.
```
pt-BR → pt → en
en-GB → en
```
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
carried the shared bulk and a regional file carried only the differences. That is the conventional design
and it was rejected for a product reason that outranks the engineering one:
So a **base catalog carries the shared bulk and a regional overlay carries only the differences.**
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says
differently. Two near-identical full catalogs would drift within a month.
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
variant's words.
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
by the test rather than by a fallback quietly papering over a gap.
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
base-versus-overlay merge order, and no question about which file a given string lives in.
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer
widening on read over a database migration — a stale bare code should resolve, not fail.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
over a database migration — a stale bare code should resolve, not fail.
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
the other.
### D3. RTL — prepare, do not ship
@@ -148,7 +174,7 @@ The gap is smaller than it looks, because the hard parts are in the runtime alre
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
lines of glue, listed in "What it cannot do yet".
Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms.
Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms.
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
### D5. Keys, not English source text
@@ -223,7 +249,7 @@ app.errors.MINESWEEPER_NOT_RUNNING
- `namespace` — `app` / `panel` / `ui`, per D6.
- `area` — the route or panel it belongs to; matches the directory.
- `component` — the file, lowercased.
- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
Rules that keep 1200 keys navigable:
@@ -245,7 +271,19 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error *codes*
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
account language, not by ours. Translating them sends somebody hunting for "Ativar" in a console that
says "Enable".
So translate the connective prose around them and leave the quoted label in English. Do not key it with
identical values in both catalogs either — that is a key whose two sides can never legitimately differ,
which is a maintenance cost bought for nothing.
Decided 2026-08-23 on `GoogleOAuthConfig.tsx`, where 55 of the file's 67 strings are of this kind.
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
names, database values.
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
@@ -304,9 +342,13 @@ Without these, 1200 keys rot. All three are small and belong in phase 0.
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
meter: the count goes down as work lands.
**A key-parity test.** For each shipped locale, every key that `en` has must resolve through the
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the
one guard that stops a locale silently degrading into English.
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
`bun docs:check` fails on a stale number.
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
@@ -339,7 +381,20 @@ else will ever notice.
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
manifest. Their strings are not counted above.
4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not
**One constraint that contract has to answer, found while converting the shared components
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
provider-absent fallback inside the `components` package was considered and rejected: it would have
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
+210
View File
@@ -0,0 +1,210 @@
# Roles and teams
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
design, written down so the work starts from a spec rather than from a conversation.
## What this is
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
a homelab and it cannot express a company, where the same person is an administrator of one area and an
ordinary member of another.
Two things are wanted, and they are separable:
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
provisioning.
- **Teams**, so a user belongs to several and holds a different role in each.
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
uses both. Nothing below may make teams mandatory to reach a feature.
---
## What already landed — phase 0, 2026-08-23
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
Verified against a scratch database: push reported the violation, created the table, skipped the
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
---
## Decisions
### D1. The owner is an identity. It is never a role.
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
whatever UI manages roles.
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
else can wait behind it.
### D2. Several Super Admins are allowed, and the role provisions sudo
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
a company has an owner and several administrators, and a homelab owner may add one or never do.
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
they left behind.
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
the UI to say what it is rather than presenting it as one more row in a dropdown.
### D3. `Admin` is app-wide authority with no operating-system privilege
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
"administering the platform is not developing on it". This decision only writes that down.
### D4. A role may be deleted, but not while anyone holds it
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
nobody asked for, and "absence denies" is the posture everywhere else in this system.
`isSystem` roles refuse both delete and rename, because the code names them by name:
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
`Member`. Renaming one is a code change that has not happened yet.
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
rename cascades to every user and every grant, atomically, with no code change.
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
### D6. A team is a scope. Permissions UNION across a user's teams.
Admin in marketing and member in developers means marketing's admin level does not reach developers'
files — but a user's effective permission for a given capability is the **highest level any of their teams
grants**, plus their global role.
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
should be refused rather than half-built.
### D7. The global role remains, and answers a different question
Two questions, two mechanisms:
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
- **"What may this account do in this area?"** — team membership with a role in that team.
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
has no natural team to ask it about. A homelab uses only the first and never makes a team.
### D8. Per-role provisioning is a script the role carries
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
in code that should become a row.
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
rootless specifically. `usermod -aG docker` is root by a longer route —
`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet seed. Any
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
---
## Phases
### Phase 0 — roles are a table ✅ DONE 2026-08-23
Additive only. Nothing reads it.
### Phase 1 — the owner stops being a role
The load-bearing phase, and small.
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
"owner or Super Admin" is a separate, reviewable change per route.
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
Reviewable on its own, and nothing downstream can start until ownership is an identity.
### Phase 2 — the foreign key, and role CRUD
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
replace what the compiler was doing.
### Phase 3 — per-role provisioning
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
D8's warning in front of whoever writes the first script.
### Phase 4 — teams
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
one table with a nullable `team_id`.
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
4. Teams UI, and the account screen showing which teams a user is in and as what.
### Phase 5 — the thing that actually unblocks all of it
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
for accounts that cannot yet exist.
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
find nothing to test it with. **It may be worth doing first.**
---
## What must not be built
- **Deny rules.** See D6. Teams grant; they never restrict.
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
the answer is a `confined` grant with a Linux user, which is per-account by nature.
## Verification
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
binary directly.
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
## Open questions
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
or a self-service one.
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
holds globally; the same question exists per team and is not answered here.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.23.02",
"version": "2026.08.23.04",
"private": true,
"type": "module",
"workspaces": [
@@ -30,7 +30,7 @@
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
"db:gen": "cd src/databases/officer_db && bun run generate",
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts",
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
"db:migrate": "cd src/databases/officer_db && bun run migrate",
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
+66
View File
@@ -9,6 +9,27 @@ one.
---
## Arming for the first time — there is nothing to rehearse against
General commander doctrine says to run the action once before arming, so it is known to work unattended.
**That does not apply here, and reading it as "replay the last release" is wrong.**
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
release, which you must never do.
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
fights the claim or invents an occasion.
Two things follow from it, and they are the reason this is fine rather than a gap:
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
What is unproven is this loop, not the rollout.
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
## What wakes you
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
@@ -192,6 +213,51 @@ assemble at the end from memory is a report that will be wrong about the host th
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
one that stopped in the middle and said nothing about the four hosts it never reached.
### Schema changes, and why `db:push` cannot be trusted
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
success while the schema is half-applied.
So on any release whose plan says the schema changed:
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
a guess.
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
table empty and adds the constraint in the same run, so every existing row violates it. There is no
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
This is allowed and it is not a licence to improvise. The rules, in order:
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
that is a code change and it is not yours to make on five production hosts at 3am.
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
the fleet has diverged in a way nothing will detect.
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
forensic exercise.
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
finding for the report rather than something to push past.
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
the specific check, and its answer, for each.
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
repair you invented.
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
### Skip the host you are running on
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
+16 -4
View File
@@ -38,6 +38,16 @@ const KIND_MEANING: Record<string, string> = {
export async function renderPlatformSkill(): Promise<string> {
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
const enCatalog = (await import('../src/apps/officer-web/locales/en-GB.json')).default as Record<string, unknown>;
/** Resolve a dot-path against the English catalog. This doc is for agents and stays in one language. */
const enString = (key: string): string => {
const value = key.split('.').reduce<unknown>((node, part) => {
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
return undefined;
}, enCatalog);
if (typeof value !== 'string') throw new Error(`gen-platform-skill: no English string for "${key}"`);
return value;
};
const byKind = new Map<string, typeof PERMISSIONS>();
for (const p of PERMISSIONS) {
@@ -112,14 +122,16 @@ export async function renderPlatformSkill(): Promise<string> {
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
out('list rather than remembering one.');
out();
// The registry holds catalog keys since the shell was localized; this doc is agent-facing and stays
// English, so it resolves against en-GB rather than the reader's locale.
for (const section of SHORTCUT_SECTIONS) {
out(`## ${section.title}`);
if (section.note) {
out(`## ${enString(section.titleKey)}`);
if (section.noteKey) {
out();
out(section.note);
out(enString(section.noteKey));
}
out();
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`);
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${enString(s.descriptionKey)}`);
out();
}
+102 -11
View File
@@ -26,29 +26,52 @@ const AREAS = [
/**
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
* zero — enabling every area on day one would just be red for weeks and get ignored.
*
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
* that must NEVER be translated; they are reported but excluded from the check, which is why the
* neverTranslate split below has to exist for this entry to be possible at all.
*
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
* `stripUntranslated` skips for the same reason.
*/
const ZEROED: string[] = [];
const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
const PATTERNS: { kind: string; rx: RegExp }[] = [
/**
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
*
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
*/
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
// >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends
{
kind: 'attributes',
rx: new RegExp(String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q, 'g'),
rx: new RegExp(
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
'g',
),
},
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
{
kind: 'object labels',
rx: new RegExp(String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q, 'g'),
rx: new RegExp(
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
'g',
),
},
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g') },
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
];
@@ -61,10 +84,60 @@ function isHuman(value: string | undefined): value is string {
if (!/[A-Za-z]{3}/.test(s)) return false;
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;
// Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
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;
// A single token carrying digits AND punctuation is a name, not a sentence — the OCR section's
// placeholder is the example model file `Qwen2.5-VL-7B-Instruct-q4_k_m.gguf`. Prose has spaces; the
// whitespace test is what keeps this from eating real one-word labels, which never look like this.
if (!/\s/.test(s) && /\d/.test(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'`.
//
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
const tokens = s.split(/\s+/);
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
return true;
}
/**
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
*
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
*/
const stripComments = (source: string): string =>
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
/**
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
* thing it documents.
*/
const stripCodeElements = (source: string): string =>
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
/**
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
* another product's UI, which docs/localization.md classifies as an identifier.
*
* Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
* doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
* carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
* it and say why.
*/
const stripUntranslated = (source: string): string =>
source.replace(/<Untranslated\b[^>]*>[\s\S]*?<\/Untranslated>/g, '<Untranslated/>');
function* walk(dir: string): Generator<string> {
let entries: string[];
try {
@@ -96,7 +169,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
files++;
let source: string;
try {
source = readFileSync(file, 'utf8');
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
} catch {
continue;
}
@@ -118,7 +191,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n);
console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`);
console.log(
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
);
for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
@@ -128,9 +203,19 @@ const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`);
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
console.log(
`${'TOTAL'.padEnd(22)} ${pad(
results.reduce((n, r) => n + r.files, 0),
5,
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
);
console.log();
console.log(`occurrences ${all.length}`);
console.log(` convertible ${convertible(all).length}`);
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
console.log(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`);
@@ -149,10 +234,16 @@ if (listPrefix) {
}
if (args.has('--check')) {
const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0);
const regressed = results
.filter((r) => ZEROED.includes(r.label))
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
.filter((r) => r.hits.length > 0);
if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:');
for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
for (const r of regressed) {
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
}
console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Seed the `roles` table with the roles the platform's own code names.
*
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
* mechanism `backfill-session-models.ts` relies on.
*
* ── Why these four are `isSystem` ──
*
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
*
* ── What this deliberately does NOT do ──
*
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
* and adds the constraint in the same run, so every existing account violates it — verified against a
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
*
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
* unattended.
*
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
*/
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
const SEED: { name: string; description: string }[] = [
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
];
async function main(): Promise<void> {
await waitForDatabase();
let created = 0;
for (const role of SEED) {
if (await seedRole({ ...role, isSystem: true })) created += 1;
}
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
}
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
// a successful push look like a failed one — the rows are additive and the next push tries again.
main()
.catch((err: unknown) => {
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
})
.finally(() => process.exit(0));
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useTranslation } from '@/lib/i18n';
export function ForgotPassword() {
const { t } = useTranslation();
const client = useClient('/api/auth');
const [isSubmitting, setIsSubmitting] = useState(false);
const [done, setDone] = useState(false);
@@ -34,29 +36,29 @@ export function ForgotPassword() {
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
{done ? (
<div className="text-center">
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
<h2 className="text-2xl font-bold text-duck-dark mb-2">{t('auth.checkEmail')}</h2>
<div className="text-sm text-duck-dark/60">
<p>
A password reset link has been sent to <strong>{state.email}</strong>.
</p>
<p>Click it to reset your password.</p>
<p>{t('auth.checkEmailHint')}</p>
</div>
</div>
) : (
<>
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div>
<div className="text-duck-dark/60">Enter your email to receive a reset link</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.forgotTitle')}</div>
<div className="text-duck-dark/60">{t('auth.forgotSubtitle')}</div>
</div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<span className="text-duck-dark/70">{t('auth.email')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
name="email"
placeholder="you@example.com"
placeholder={t('auth.emailPlaceholder')}
autoComplete="email"
/>
</Label>
@@ -74,7 +76,7 @@ export function ForgotPassword() {
<div className="text-center text-sm text-duck-dark/60">
<p>
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
Back to login
{t('auth.backToLogin')}
</Link>
</p>
</div>
@@ -4,8 +4,10 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { cn } from '@/lib/utils';
import { useResetPassword } from './useResetPassword';
import { useTranslation } from '@/lib/i18n';
export function ResetPassword() {
const { t } = useTranslation();
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
const loading = tokenStatus === 'loading';
const invalid = tokenStatus === 'invalid';
@@ -15,45 +17,45 @@ export function ResetPassword() {
<>
{loading && (
<Card className="py-12 px-24 flex flex-col gap-6">
<div className="text-center text-duck-dark/60">Verifying...</div>
<div className="text-center text-duck-dark/60">{t('auth.verifying')}</div>
</Card>
)}
{invalid && (
<Card className="py-12 px-24 flex flex-col gap-6">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.invalidLink')}</div>
<div className="text-duck-dark/60">{t('auth.invalidLinkHint')}</div>
</div>
</Card>
)}
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
<div className="text-duck-dark/60">Enter your new password</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.resetTitle')}</div>
<div className="text-duck-dark/60">{t('auth.resetSubtitle')}</div>
</div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<div className="grid md:flex gap-4">
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span>
<span className="text-duck-dark/70">{t('auth.password')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Min 12 characters"
placeholder={t('auth.minChars')}
autoComplete="new-password"
/>
</Label>
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span>
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder="Confirm your password"
placeholder={t('auth.confirmPlaceholder')}
autoComplete="new-password"
/>
</Label>
@@ -65,7 +67,7 @@ export function ResetPassword() {
disabled={!isValid || isSubmitting}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
{isSubmitting ? 'Resetting...' : t('auth.resetTitle')}
</Button>
</div>
</form>
@@ -4,8 +4,10 @@ import { toast } from 'sonner';
import { useMounted } from 'hooks/useMounted';
import { useForm } from 'hooks/useForm';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
export const useResetPassword = () => {
const { t } = useTranslation();
const isMounted = useMounted();
const navigate = useNavigate();
const client = useClient('/api/auth');
@@ -44,7 +46,7 @@ export const useResetPassword = () => {
password: state.password,
verificationCode,
});
toast.success('Password reset successfully. Please sign in.');
toast.success(t('auth.resetOk'));
navigate('/');
} catch (ex) {
const error = ex as { message?: string };
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/Card';
import { useForm } from 'hooks/useForm';
import { useTranslation } from '@/lib/i18n';
export function Bootstrap() {
const { t } = useTranslation();
const authClient = useClient('/api/auth');
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -30,7 +32,7 @@ export function Bootstrap() {
password: state.password,
confirmPassword: state.confirmPassword,
});
toast.success('Administrator account created. Please sign in.');
toast.success(t('auth.bootstrapOk'));
navigate('/');
} catch (ex) {
const error = ex as { message?: string };
@@ -43,35 +45,35 @@ export function Bootstrap() {
return (
<Card className="md:min-w-[480px] flex flex-col gap-6">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Welcome, Admin</div>
<div className="text-duck-dark/60">Create your administrator account</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.bootstrapTitle')}</div>
<div className="text-duck-dark/60">{t('auth.bootstrapSubtitle')}</div>
</div>
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<span className="text-duck-dark/70">{t('auth.name')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="name"
placeholder="Your name"
placeholder={t('auth.namePlaceholder')}
autoComplete="name"
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<span className="text-duck-dark/70">{t('auth.email')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
name="email"
placeholder="admin@example.com"
placeholder={t('auth.adminEmailPlaceholder')}
autoComplete="email"
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span>
<span className="text-duck-dark/70">{t('auth.username')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
@@ -83,23 +85,23 @@ export function Bootstrap() {
<div className="grid md:flex gap-4">
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span>
<span className="text-duck-dark/70">{t('auth.password')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Min 12 characters"
placeholder={t('auth.minChars')}
autoComplete="new-password"
/>
</Label>
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span>
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder="Confirm your password"
placeholder={t('auth.confirmPlaceholder')}
autoComplete="new-password"
/>
</Label>
@@ -8,12 +8,14 @@ import { toast } from 'sonner';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
import { useGlobal } from 'hooks/useGlobal';
import { useTranslation } from '@/lib/i18n';
const initialState: LoginFormState = {
// email: 'pastilhas@pastilhas.dev',
// password: '',
};
export function Login() {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
const { signin } = useAuth();
@@ -40,20 +42,20 @@ export function Login() {
return (
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
<div className="text-duck-dark/60">Sign in to your account</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.signInTitle')}</div>
<div className="text-duck-dark/60">{t('auth.signInSubtitle')}</div>
</div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email or username</span>
<span className="text-duck-dark/70">{t('auth.emailOrUsername')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
// `text`, not `email`: the browser refuses to submit a username under type="email", so the
// field would have validated the user out of an account they have.
type="text"
name="email"
placeholder="you@example.com"
placeholder={t('auth.emailPlaceholder')}
// `username` rather than `email`, which is what password managers expect for a field that
// takes either — and it is the value they already store beside the password.
autoComplete="username"
@@ -61,12 +63,12 @@ export function Login() {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Password</span>
<span className="text-duck-dark/70">{t('auth.password')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Your password"
placeholder={t('auth.passwordPlaceholder')}
autoComplete="current-password"
/>
</Label>
@@ -90,7 +92,7 @@ export function Login() {
{/* </p> */}
<p>
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
Forgot password?
{t('auth.forgotPassword')}
</Link>
</p>
</div>
@@ -1,3 +1,5 @@
import { useTranslation } from '@/lib/i18n';
type DebugPanelProps = {
fullControlMode: boolean;
cameraInfo: {
@@ -13,9 +15,10 @@ type DebugPanelProps = {
};
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
const { t } = useTranslation();
return (
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
<div className="font-bold text-blue-400">Controls:</div>
<div className="font-bold text-blue-400">{t('auth.debug.controls')}</div>
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
</div>
@@ -29,12 +32,12 @@ export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPane
<div>* Ctrl+Alt+Enter: Toggle full control</div>
<div>* Ctrl+Alt+D: Toggle debug panel</div>
<div className="font-bold text-green-400 pt-2">Camera:</div>
<div className="font-bold text-green-400 pt-2">{t('auth.debug.camera')}</div>
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
<div className="font-bold text-yellow-400 pt-2">Model:</div>
<div className="font-bold text-yellow-400 pt-2">{t('auth.debug.model')}</div>
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
import { useTranslation } from '@/lib/i18n';
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
@@ -15,6 +16,7 @@ const MAX_LINES = 600;
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
// the screen re-derives the param from the registry row — one address shape for both kinds.
export const ActivityScreen = () => {
const { t } = useTranslation();
const { token, get } = useClient();
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
const [regLoaded, setRegLoaded] = useState(false);
@@ -87,30 +89,32 @@ export const ActivityScreen = () => {
<div className="flex h-full w-full">
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<ActivityIcon size={16} className="text-primary" /> Activity
<ActivityIcon size={16} className="text-primary" /> {t('activity.title')}
</div>
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Background tasks
{t('activity.backgroundTasks')}
</div>
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
{reg.tasks.map((t) => (
{reg.tasks.length === 0 && (
<p className="px-2 py-1 text-xs text-muted-foreground">{t('activity.noneRunning')}</p>
)}
{reg.tasks.map((task) => (
<Link
key={t.id}
to={`/activity/${encodeURIComponent(t.id)}`}
className={rowCls(selectedId === t.id)}
title={t.cwd}
key={task.id}
to={`/activity/${encodeURIComponent(task.id)}`}
className={rowCls(selectedId === task.id)}
title={task.cwd}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
className={`inline-block h-2 w-2 shrink-0 rounded-full ${task.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
/>
<span className="truncate font-mono text-xs">{t.id}</span>
<span className="truncate font-mono text-xs">{task.id}</span>
</Link>
))}
{reg.detached.length > 0 && (
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Detached
{t('activity.detached')}
</div>
)}
{reg.detached.map((d) => (
@@ -169,7 +173,7 @@ export const ActivityScreen = () => {
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
it never started.{' '}
<Link to="/activity" className="underline">
Back to the list
{t('activity.backToList')}
</Link>
</>
) : (
@@ -187,7 +191,7 @@ export const ActivityScreen = () => {
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a task to follow its live output
{t('activity.selectTask')}
</div>
)}
</main>
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
import { useChat, EmbeddableChat } from 'officerdev';
import { useTranslation } from '@/lib/i18n';
type CapabilitySummary = {
dirName: string;
name: string;
@@ -277,12 +278,18 @@ export const CapabilityChat = ({
description,
onResponseEnd,
}: CapabilityChatProps) => {
const { t } = useTranslation();
const seedFile = `${kind.toUpperCase()}.md`;
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix;
/*
The composer's PRE-FILLED text, which the reader sees and edits — so it is translated. The hidden
`promptFrontmatter` above is not: that one instructs the agent and carries file paths, and is
machine-facing rather than something anybody reads.
*/
const defaultInput = isNew
? (description ?? `Help me create the content for this new ${kind} file`)
: `Help me understand and improve this ${kind} file`;
? (description ?? t('capability.createPrompt', { kind: kind.toLowerCase() }))
: t('capability.improvePrompt', { kind: kind.toLowerCase() });
const chat = useChat(undefined, undefined, { replaceUrl: false });
@@ -301,6 +308,7 @@ export const CapabilityChat = ({
};
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
@@ -310,7 +318,7 @@ export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
<span className="text-xs font-medium">Frontmatter</span>
<span className="text-xs font-medium">{t('capability.frontmatter')}</span>
</button>
{open && (
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
@@ -330,6 +338,7 @@ export const CapabilityList = ({
showCreate,
onShowCreateChange,
}: CapabilityListProps) => {
const { t, tUi } = useTranslation();
const client = useClient();
const qc = useQueryClient();
const [internalCreating, setInternalCreating] = useState(false);
@@ -356,7 +365,7 @@ export const CapabilityList = ({
setNewName('');
onCreate(res.dirName);
} catch {
toast.error(`Failed to create ${kind}`);
toast.error(t('capability.createFailed', { kind: kind.toLowerCase() }));
}
};
@@ -427,7 +436,7 @@ export const CapabilityList = ({
<input
value={internalSearch}
onChange={(ev) => setInternalSearch(ev.target.value)}
placeholder={`Search ${kind.toLowerCase()}s...`}
placeholder={t('capability.searchPlaceholder', { kind: `${kind.toLowerCase()}s` })}
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
@@ -452,7 +461,7 @@ export const CapabilityList = ({
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
)}
{items.length > 0 && filtered.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">{t('capability.noMatches')}</p>
)}
</div>
</>
@@ -469,6 +478,7 @@ export const CapabilityList = ({
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
*/
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
const { t, tUi } = useTranslation();
const client = useClient();
const qc = useQueryClient();
const navigate = useNavigate();
@@ -497,7 +507,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
navigate(basePath, { replace: true });
await qc.invalidateQueries({ queryKey: [queryKey] });
} catch {
toast.error(`Failed to delete ${kind}`);
toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() }));
}
};
@@ -526,7 +536,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</Link>
<span className="text-sm font-medium text-duck-dark/70 flex-1">
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
</span>
{detail && (
<>
@@ -554,10 +564,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
</ReactMarkdown>
</article>
) : detail ? (
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
<p className="text-sm text-duck-dark/40 text-center mt-12">{t('capability.emptyFile')}</p>
) : (
<p className="text-sm text-duck-dark/40 text-center mt-12">
Select a {kind.toLowerCase()} to view its contents
{t('capability.selectToView', { kind: kind.toLowerCase() })}
</p>
)}
</div>
@@ -606,10 +616,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
</DialogHeader>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
Cancel
{tUi('button.cancel')}
</Button>
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
Delete
{t('capability.delete')}
</Button>
</div>
</DialogContent>
@@ -9,6 +9,7 @@ import { errorText } from 'helpers/error-text';
import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import { defaultLayout, hasAppType } from './defaultLayout';
import { useTranslation } from '@/lib/i18n';
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
const CHAT_TAIL = 20;
@@ -18,6 +19,7 @@ type SessionListPageProps = {
};
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { t } = useTranslation();
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
const groupCwd = cwdFromSplat(splat);
@@ -122,7 +124,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
// no hint that the transcript could not be read, which is indistinguishable from a new session.
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') }));
setSelected({ id: sessionId });
}
})();
@@ -1,12 +1,16 @@
import { CodeEditorView } from 'officerdev';
import { Widget } from 'widgets/Widget';
import { useTranslation } from '@/lib/i18n';
export const CodeEditor = () => (
<div className="h-full w-full flex items-center justify-center">
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
export const CodeEditor = () => {
const { t } = useTranslation();
return (
<div className="h-full w-full flex items-center justify-center">
<Widget title={t('nav.codeEditor')} className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
wrapper deliberately does not pass this: a dashboard can hold two editors. */}
<CodeEditorView className="h-full w-full" urlState />
</Widget>
</div>
);
<CodeEditorView className="h-full w-full" urlState />
</Widget>
</div>
);
};
@@ -3,6 +3,9 @@ import { useParams, Link } from 'react-router';
import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { useTranslation } from '@/lib/i18n';
type TFn = ReturnType<typeof useTranslation>['t'];
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
// from the job's persisted progress (the executor emits counter snapshots, not per-item events).
@@ -29,26 +32,40 @@ type DownloadJob = {
const POLL_MS = 1500;
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
const statusBadge = (status: string) => {
// Takes `t` because it is a module-level helper, not a component — the alternative is a hook
// call outside a component body.
const statusBadge = (status: string, t: TFn) => {
switch (status) {
case 'running':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
label: 'Running',
label: t('jobs.status.running'),
cls: 'text-amber-600 dark:text-amber-500',
};
case 'pending':
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
return {
icon: <Clock className="h-4 w-4" />,
label: t('jobs.status.queued'),
cls: 'text-blue-600 dark:text-blue-400',
};
case 'completed':
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
case 'failed':
return { icon: <XCircle className="h-4 w-4" />, label: 'Failed', cls: 'text-red-600 dark:text-red-400' };
return {
icon: <XCircle className="h-4 w-4" />,
label: t('jobs.status.failed'),
cls: 'text-red-600 dark:text-red-400',
};
case 'stopped':
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
return {
icon: <Ban className="h-4 w-4" />,
label: t('jobs.status.stopped'),
cls: 'text-duck-dark/50 dark:text-foreground/50',
};
default:
return {
icon: <XCircle className="h-4 w-4" />,
label: 'Interrupted',
label: t('jobs.status.interrupted'),
cls: 'text-orange-600 dark:text-orange-400',
};
}
@@ -100,6 +117,7 @@ export const PhaseBar = ({
};
export const DownloadJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
const [job, setJob] = useState<DownloadJob | null>(null);
@@ -134,9 +152,9 @@ export const DownloadJobDetail = () => {
if (!id) return;
try {
await client.post(`/jobs/${id}/stop`, {});
toast.success('Stop requested');
toast.success(t('jobs.stopRequested'));
} catch {
toast.error('Failed to stop');
toast.error(t('jobs.stopFailed'));
}
};
@@ -149,14 +167,14 @@ export const DownloadJobDetail = () => {
if (!job)
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
<span>Job not found.</span>
<span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal hover:underline">
Back to jobs
{t('jobs.backToJobs')}
</Link>
</div>
);
const badge = statusBadge(job.status);
const badge = statusBadge(job.status, t);
const running = !isTerminal(job.status);
const p = job.progress;
const isAudio = job.inputs?.format !== 'video';
@@ -197,7 +215,7 @@ export const DownloadJobDetail = () => {
onClick={stop}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
>
<Square className="h-3 w-3" /> Stop
<Square className="h-3 w-3" /> {t('jobs.stop')}
</button>
)}
</div>
@@ -206,14 +224,14 @@ export const DownloadJobDetail = () => {
<div className="mx-auto flex max-w-md flex-col gap-5">
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
<PhaseBar
label="Titles"
label={t('jobs.titles')}
c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
active={p?.phase === 'metadata'}
savedLabel="found"
failedLabel="skipped"
/>
<PhaseBar
label="Download"
label={t('jobs.download')}
c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
active={p?.phase === 'download'}
savedLabel="saved"
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { WorkspaceLayout } from 'officerdev';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { randomId } from 'helpers/random-id';
import { useTranslation } from '@/lib/i18n';
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
@@ -162,6 +163,7 @@ type ToolCallEntryProps = {
};
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
return (
@@ -183,14 +185,14 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
{expanded && (
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
<div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Input</div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.input')}</div>
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
{JSON.stringify(entry.toolInput, null, 2)}
</pre>
</div>
{entry.output !== undefined && (
<div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.output')}</div>
<pre
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
>
@@ -236,6 +238,7 @@ const DEFAULT_LAYOUT: LayoutNode = {
};
const StepsPanel = () => {
const { t } = useTranslation();
const ctx = useJobPanel();
const {
displaySteps,
@@ -255,7 +258,7 @@ const StepsPanel = () => {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-3 py-2 border-b border-duck-dark/10">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Steps</h2>
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.steps')}</h2>
</div>
<div className="flex-1 overflow-y-auto">
{displaySteps.map((step, i) => {
@@ -336,6 +339,7 @@ const StepsPanel = () => {
};
const OutputPanel = () => {
const { t } = useTranslation();
const ctx = useJobPanel();
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
@@ -345,7 +349,7 @@ const OutputPanel = () => {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.output')}</h2>
{selectedKey && (
<span className="text-xs text-duck-dark/40 truncate">
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
@@ -355,12 +359,12 @@ const OutputPanel = () => {
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
{!selectedKey && (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
Select a step or sub-task to view output
{t('jobs.selectStep')}
</div>
)}
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
No output yet
{t('jobs.noOutput')}
</div>
)}
{selectedOutputs.map((entry) => {
@@ -385,6 +389,7 @@ const OutputPanel = () => {
};
export const PipelineJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
@@ -792,15 +797,15 @@ export const PipelineJobDetail = () => {
);
if (isLoading) {
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">{t('jobs.loading')}</div>;
}
if (!job) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
<span>Job not found</span>
<span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
Back to jobs
{t('jobs.backToJobs')}
</Link>
</div>
);
@@ -821,7 +826,11 @@ export const PipelineJobDetail = () => {
<Card className="px-4 py-3 shrink-0">
<div className="flex items-center gap-3">
{/* The other two job detail panes already do this; this was the last imperative back in Jobs. */}
<Link to="/jobs" aria-label="Back to jobs" className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
<Link
to="/jobs"
aria-label={t('jobs.backToJobs')}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer"
>
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</Link>
<div className="flex-1 min-w-0">
@@ -837,7 +846,7 @@ export const PipelineJobDetail = () => {
{isRunning && (
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
<Square className="h-3 w-3" />
Stop
{t('jobs.stop')}
</Button>
)}
</div>
@@ -852,13 +861,13 @@ export const PipelineJobDetail = () => {
{displayCost && (
<>
<div className="flex items-center gap-1.5 text-duck-dark/60">
<span className="text-duck-dark/40">Tokens:</span>
<span className="text-duck-dark/40">{t('jobs.tokens')}</span>
<span className="font-mono tabular-nums">
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
</span>
</div>
<div className="flex items-center gap-1.5 text-duck-dark/60">
<span className="text-duck-dark/40">Cost:</span>
<span className="text-duck-dark/40">{t('jobs.cost')}</span>
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
</div>
</>
@@ -22,6 +22,7 @@ import { Card } from '@/components/Card';
import { ScriptJobDetail } from './ScriptJobDetail';
import { DownloadJobDetail } from './DownloadJobDetail';
import { PipelineJobDetail } from './JobDetail';
import { useTranslation } from '@/lib/i18n';
type JobSummary = {
id: string;
@@ -99,6 +100,7 @@ const useJobsData = () => {
type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void };
const JobRow = ({ job, onAction }: JobRowProps) => {
const { t } = useTranslation();
const { id: activeId } = useParams<{ id: string }>();
const isRunning = job.status === 'running';
const target = basename(job.target);
@@ -126,7 +128,7 @@ const JobRow = ({ job, onAction }: JobRowProps) => {
</Link>
<button
onClick={(ev) => onAction(ev, job)}
title={isRunning ? 'Stop' : 'Delete'}
title={isRunning ? t('jobs.stop') : 'Delete'}
className="shrink-0 mr-2 p-1.5 rounded-md text-duck-dark/40 hover:text-red-500 hover:bg-red-500/10 md:opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
{isRunning ? <Square className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />}
@@ -144,6 +146,7 @@ const PanelHeader = ({ children }: { children: ReactNode }) => (
// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord
// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it.
const TestNotificationButton = () => {
const { t } = useTranslation();
const client = useClient();
const [sending, setSending] = useState(false);
@@ -157,12 +160,12 @@ const TestNotificationButton = () => {
);
const results = res?.results ?? [];
if (results.length === 0) {
toast.warning('No channels configured');
toast.warning(t('jobs.noChannels'));
} else {
toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', '));
}
} catch {
toast.error('Failed to send notification');
toast.error(t('jobs.notifyFailed'));
} finally {
setSending(false);
}
@@ -172,7 +175,7 @@ const TestNotificationButton = () => {
<button
onClick={send}
disabled={sending}
title="Send a test notification to every configured channel"
title={t('jobs.testNotification')}
className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40"
>
<Bell className="h-3.5 w-3.5" />
@@ -182,6 +185,7 @@ const TestNotificationButton = () => {
};
const ActiveJobsPanel = () => {
const { t } = useTranslation();
const { jobs, isLoading, act } = useJobsData();
const active = [
...jobs.filter((j) => j.status === 'running'),
@@ -191,15 +195,17 @@ const ActiveJobsPanel = () => {
<div className="h-full p-2">
<Card className="h-full flex flex-col overflow-hidden">
<PanelHeader>
<h2 className="text-sm font-semibold text-duck-dark">Running &amp; Queued</h2>
<h2 className="text-sm font-semibold text-duck-dark">{t('jobs.runningQueued')}</h2>
{active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>}
<TestNotificationButton />
</PanelHeader>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
) : active.length === 0 ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Nothing running</div>
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
{t('jobs.nothingRunning')}
</div>
) : (
active.map((job) => <JobRow key={job.id} job={job} onAction={act} />)
)}
@@ -211,6 +217,7 @@ const ActiveJobsPanel = () => {
// Bottom-left panel: finished / failed / stopped, newest first, searchable.
const HistoryJobsPanel = () => {
const { t } = useTranslation();
const { jobs, isLoading, act, clearHistory } = useJobsData();
const [search, setSearch] = useState('');
const history = jobs
@@ -224,12 +231,12 @@ const HistoryJobsPanel = () => {
<div className="h-full p-2">
<Card className="h-full flex flex-col overflow-hidden">
<PanelHeader>
<h2 className="text-sm font-semibold text-duck-dark shrink-0">History</h2>
<h2 className="text-sm font-semibold text-duck-dark shrink-0">{t('jobs.history')}</h2>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
<input
type="text"
placeholder="Search…"
placeholder={t('jobs.search')}
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
@@ -238,16 +245,16 @@ const HistoryJobsPanel = () => {
{history.length > 0 && (
<button
onClick={clearHistory}
title="Delete all finished jobs"
title={t('jobs.deleteFinished')}
className="shrink-0 text-xs font-medium text-duck-dark/50 hover:text-red-500 cursor-pointer"
>
Clear all
{t('jobs.clearAll')}
</button>
)}
</PanelHeader>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
) : history.length === 0 ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
{search ? 'No matches' : 'No finished jobs'}
@@ -263,6 +270,7 @@ const HistoryJobsPanel = () => {
// ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ──
const JobDetailPanel = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
const [mode, setMode] = useState<string | null>(null);
@@ -284,7 +292,7 @@ const JobDetailPanel = () => {
<div className="h-full p-2">
<Card className="h-full flex flex-col items-center justify-center gap-2 text-duck-dark/30">
<Inbox className="h-8 w-8" />
<span className="text-sm">Select a job</span>
<span className="text-sm">{t('jobs.selectJob')}</span>
</Card>
</div>
);
@@ -301,7 +309,7 @@ const JobDetailPanel = () => {
if (mode === 'notfound') {
return (
<div className="h-full p-2">
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">Job not found.</Card>
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">{t('jobs.notFound')}</Card>
</div>
);
}
@@ -4,6 +4,9 @@ import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'l
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { PhaseBar, type DownloadProgress } from './DownloadJobDetail';
import { useTranslation } from '@/lib/i18n';
type TFn = ReturnType<typeof useTranslation>['t'];
type ScriptJob = {
id: string;
@@ -27,36 +30,46 @@ const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === '
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
const statusBadge = (status: string, exitCode: number | null) => {
// Takes `t` because it is a module-level helper, not a component.
const statusBadge = (status: string, exitCode: number | null, t: TFn) => {
switch (status) {
case 'running':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
label: 'Running',
label: t('jobs.status.running'),
cls: 'text-amber-600 dark:text-amber-500',
};
case 'pending':
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
return {
icon: <Clock className="h-4 w-4" />,
label: t('jobs.status.queued'),
cls: 'text-blue-600 dark:text-blue-400',
};
case 'completed':
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
case 'failed':
return {
icon: <XCircle className="h-4 w-4" />,
label: `Failed${exitCode != null ? ` (exit ${exitCode})` : ''}`,
label: exitCode != null ? t('jobs.failedWithCode', { code: exitCode }) : t('jobs.status.failed'),
cls: 'text-red-600 dark:text-red-400',
};
case 'stopped':
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
return {
icon: <Ban className="h-4 w-4" />,
label: t('jobs.status.stopped'),
cls: 'text-duck-dark/50 dark:text-foreground/50',
};
default:
return {
icon: <XCircle className="h-4 w-4" />,
label: 'Interrupted',
label: t('jobs.status.interrupted'),
cls: 'text-orange-600 dark:text-orange-400',
};
}
};
export const ScriptJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
@@ -137,9 +150,9 @@ export const ScriptJobDetail = () => {
if (!id) return;
try {
await client.post(`/jobs/${id}/stop`, {});
toast.success('Stop requested');
toast.success(t('jobs.stopRequested'));
} catch {
toast.error('Failed to stop');
toast.error(t('jobs.stopFailed'));
}
};
@@ -153,15 +166,15 @@ export const ScriptJobDetail = () => {
if (!job) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
<span>Job not found.</span>
<span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal hover:underline">
Back to jobs
{t('jobs.backToJobs')}
</Link>
</div>
);
}
const badge = statusBadge(job.status, job.exitCode);
const badge = statusBadge(job.status, job.exitCode, t);
const running = !isTerminal(job.status);
return (
@@ -185,7 +198,7 @@ export const ScriptJobDetail = () => {
onClick={stop}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
>
<Square className="h-3 w-3" /> Stop
<Square className="h-3 w-3" /> {t('jobs.stop')}
</button>
)}
</div>
@@ -193,14 +206,14 @@ export const ScriptJobDetail = () => {
{isDownloadProgress(job.progress) && (
<div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4">
<PhaseBar
label="Titles"
label={t('jobs.titles')}
c={job.progress.meta}
active={job.progress.phase === 'metadata'}
savedLabel="found"
failedLabel="skipped"
/>
<PhaseBar
label="Download"
label={t('jobs.download')}
c={job.progress.dl}
active={job.progress.phase === 'download'}
savedLabel="saved"
@@ -1,16 +1,18 @@
import { Bug } from 'lucide-react';
import { useBugReport } from './use-bug-report';
import { useTranslation } from '@/lib/i18n';
import { BugReportDialog } from './BugReportDialog';
export const BugReportButton = () => {
const bugReport = useBugReport();
const { t } = useTranslation();
return (
<>
<button
onClick={bugReport.handleOpen}
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
aria-label="Report a bug"
aria-label={t('bugReport.label')}
>
<Bug size={16} />
</button>
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { useTranslation } from '@/lib/i18n';
type BugReportDialogProps = {
open: boolean;
@@ -21,6 +22,7 @@ export const BugReportDialog = ({
onClose,
onSubmit,
}: BugReportDialogProps) => {
const { t, tUi } = useTranslation();
const [description, setDescription] = useState('');
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
@@ -36,7 +38,7 @@ export const BugReportDialog = ({
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Report a Bug</DialogTitle>
<DialogTitle>{t('bugReport.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -46,34 +48,32 @@ export const BugReportDialog = ({
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : previewUrl ? (
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" />
<img src={previewUrl} alt={t('bugReport.screenshot')} className="max-h-48 w-full object-contain" />
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
No screenshot captured
{t('bugReport.noScreenshot')}
</div>
)}
</div>
<Textarea
placeholder="Describe what went wrong..."
placeholder={t('bugReport.describePlaceholder')}
value={description}
onChange={(ev) => setDescription(ev.target.value)}
rows={4}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Console logs, browser info, and recent API errors are collected automatically.
</p>
<p className="text-xs text-muted-foreground">{t('bugReport.collectedNote')}</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Cancel
{tUi('button.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Submit
{submitting ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
{t('bugReport.submit')}
</Button>
</DialogFooter>
</DialogContent>
@@ -6,6 +6,7 @@ import { useGlobal } from 'hooks/useGlobal';
import type { ErrorDetails } from 'hooks/useClient';
import { getConsoleBuffer } from '@/lib/console-buffer';
import { toast } from 'sonner';
import { useTranslation } from '@/lib/i18n';
export const useBugReport = () => {
const [open, setOpen] = useState(false);
@@ -14,6 +15,7 @@ export const useBugReport = () => {
const [submitting, setSubmitting] = useState(false);
const client = useClient();
const { user } = useAuth();
const { t } = useTranslation();
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
const handleOpen = useCallback(async () => {
@@ -57,15 +59,15 @@ export const useBugReport = () => {
}
await client.post('/bug-report', formData);
toast.success('Bug report submitted');
toast.success(t('bugReport.submitted'));
handleClose();
} catch {
toast.error('Failed to submit bug report');
toast.error(t('bugReport.failed'));
} finally {
setSubmitting(false);
}
},
[screenshot, client, user, apiError, handleClose],
[screenshot, client, user, apiError, handleClose, t],
);
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
@@ -6,7 +6,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
import { Dock, useDockItems, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight';
import { ShortcutsModal } from './ShortcutsModal';
import { useIsTouch } from './useIsTouch';
@@ -24,10 +24,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
// will then slam is worse than one that never showed it.
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
const permitted = useMemo(
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
[canVisit, plugins],
);
const allDockItems = useDockItems(plugins);
const permitted = useMemo(() => allDockItems.filter((item) => canVisit(item.to)), [canVisit, allDockItems]);
const { items: visibleItems } = useDock(
permitted,
useMemo(() => defaultDockPaths(plugins), [plugins]),
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { resolveIcon } from 'officerdev';
import type { PluginManifest } from 'hooks/usePermissions';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
export type DockItem = {
/** Already resolved for the active locale — see `useDockItems`. */
label: string;
to: string;
color: string;
@@ -169,22 +171,22 @@ import {
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
* the light profile because it fronts a remote instance and installs nothing locally.
*/
export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
export const CORE_DOCK_ITEMS: CoreDockItem[] = [
{ labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' },
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because it is the door into another account's filesystem, and the permission that gates it is
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets.
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure
@@ -194,16 +196,16 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
{ label: 'Calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
{ label: 'Contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
{ labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
{ labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
{ labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' },
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
{ label: 'Marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
{ labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
];
/**
@@ -213,6 +215,30 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
*/
/**
* A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only
* dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest,
* which is the plugin's string and not ours to translate (docs/localization.md open question 3).
*/
type CoreDockItem = Omit<DockItem, 'label'> & { labelKey: AppTranslationKey };
/**
* The composed dock, with the shell's own labels in the active locale.
*
* One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by
* spreading the same two sources, and each would otherwise have to resolve labels itself.
*/
export function useDockItems(plugins: PluginManifest[]): DockItem[] {
const { t } = useTranslation();
return useMemo(
() => [
...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })),
...dockItemsFromPlugins(plugins),
],
[t, plugins],
);
}
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
return plugins.flatMap((plugin) => {
const tile = (t: {
@@ -13,6 +13,7 @@ import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle';
import { useAuth } from 'hooks/useAuth';
import { useThemeAsset } from '@/components/ui/ThemeProvider';
import { useTranslation } from '@/lib/i18n';
// import { BugReportButton } from '../BugReport/BugReportButton';
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
@@ -20,14 +21,15 @@ import { useThemeAsset } from '@/components/ui/ThemeProvider';
// the field hand the tab back to the route's own name.
function EditablePageTitle() {
const [title, rename] = usePageTitle();
const { t } = useTranslation();
return (
<EditableTitle
value={title}
onCommit={rename}
allowEmpty
ariaLabel="Tab name"
hint="Click to rename this tab — clear it to go back to the page name"
ariaLabel={t('header.tabName')}
hint={t('header.tabNameHint')}
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
/>
@@ -48,6 +50,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
const [open, setOpen] = useState(false);
const isTouch = useIsTouch();
const { user } = useAuth();
const { t } = useTranslation();
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
const logo = useThemeAsset('--logo-src');
@@ -81,7 +84,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
*/}
<div
role="img"
aria-label="Officer"
aria-label={t('header.logoAlt')}
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
@@ -119,7 +122,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetTitle>{t('header.navigation')}</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 mt-4 px-2">
{dockItems.map((item) => (
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
import { Loader2, ListOrdered } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions';
import { useTranslation } from '@/lib/i18n';
type Counts = { running: number; runningJobId: string | null; queued: number };
@@ -15,6 +16,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
export const JobsIndicator = () => {
const client = useClient();
const { can } = usePermissions();
const { t } = useTranslation();
const allowed = can('tasks');
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
@@ -44,7 +46,7 @@ export const JobsIndicator = () => {
<div className="flex items-center gap-1.5">
<Link
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
title={counts.running ? 'Running job' : 'Nothing running'}
title={counts.running ? t('header.jobsRunning') : t('header.jobsNothingRunning')}
className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`}
>
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
@@ -52,7 +54,7 @@ export const JobsIndicator = () => {
</Link>
<Link
to="/jobs"
title="Queued jobs"
title={t('header.jobsQueued')}
className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`}
>
<ListOrdered className="h-3.5 w-3.5" />
@@ -5,6 +5,7 @@ import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
import { useThemeFavorites } from '@/state/useThemeFavorites';
import { useTranslation } from '@/lib/i18n';
const {
DropdownMenu,
@@ -32,6 +33,7 @@ const {
*/
export const ThemeSwitcher = () => {
const { theme, themeId, setTheme, themes } = useTheme();
const { t } = useTranslation();
const { favorites, isFavorite, toggle } = useThemeFavorites();
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
@@ -50,7 +52,7 @@ export const ThemeSwitcher = () => {
*/
const ordered = useMemo(() => {
const rotated = rotateToSelected(themes, themeId);
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
return favouritesFirst(listed, favorites);
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
@@ -62,10 +64,11 @@ export const ThemeSwitcher = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
isFavorite(themeId) ? 'unstar' : 'star'
}`}
aria-label={`Theme: ${theme.name}`}
title={t('theme.pillTitle', {
name: theme.name,
starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
})}
aria-label={t('theme.pillLabel', { name: theme.name })}
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
// the cluster stays one shape.
@@ -89,8 +92,8 @@ export const ThemeSwitcher = () => {
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
<div className="shrink-0 border-b border-border px-2 py-1.5">
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
<span className="block">ctrl+? for every shortcut</span>
<span className="block">{t('theme.cycleHint')}</span>
<span className="block">{t('theme.everyShortcutHint')}</span>
</DropdownMenuLabel>
<button
type="button"
@@ -105,35 +108,41 @@ export const ThemeSwitcher = () => {
}`}
>
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
{onlyFavorites
? t('theme.favouritesOnly', { count: favorites.size })
: t('theme.favourites', { count: favorites.size })}
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{ordered.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
No favourites yet — star one with the button on its row.
</p>
<p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
) : (
families.map(([family, entries]) => (
<div key={family}>
{entries.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{t.name}</span>
{entries.map((entry) => (
<DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
<Check
className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
<button
type="button"
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
aria-label={
isFavorite(entry.id)
? t('theme.unstarNamed', { name: entry.name })
: t('theme.starNamed', { name: entry.name })
}
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
toggle(t.id);
toggle(entry.id);
}}
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
>
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
<Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
</button>
</DropdownMenuItem>
))}
@@ -165,22 +174,24 @@ export const ThemeSwitcher = () => {
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
same thing in both, or the browse teaches you an order the menu then contradicts. */}
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
{ordered.map((t) => {
const isFocused = t.id === focused;
{ordered.map((entry) => {
const isFocused = entry.id === focused;
return (
<li key={t.id}>
<li key={entry.id}>
<button
onMouseEnter={() => setFocused(t.id)}
onMouseEnter={() => setFocused(entry.id)}
onClick={() => {
setTheme(t.id);
setTheme(entry.id);
setOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
}`}
>
<span className="min-w-0 flex-1 truncate">{t.name}</span>
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
{entry.id === themeId && (
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
)}
{/*
A span, not a button: this sits inside the row's own <button>, and a button inside
a button is invalid HTML that browsers resolve by dropping one of them. The click
@@ -190,18 +201,24 @@ export const ThemeSwitcher = () => {
<span
role="button"
tabIndex={-1}
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
aria-label={
isFavorite(entry.id)
? t('theme.unstarNamed', { name: entry.name })
: t('theme.starNamed', { name: entry.name })
}
title={
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
}
onClick={(ev) => {
// Or the row's own onClick applies the theme and closes the overlay, which is
// the opposite of what starring is for.
ev.preventDefault();
ev.stopPropagation();
toggle(t.id);
toggle(entry.id);
}}
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
>
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
<Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
</span>
</button>
</li>
@@ -210,9 +227,7 @@ export const ThemeSwitcher = () => {
</ul>
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
header does. */}
<p className="mt-6 text-center text-xs opacity-60">
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
</p>
<p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
</div>
</div>,
document.body,
@@ -27,13 +27,13 @@ export function UserMenu() {
<DropdownMenuContent align="end" className="z-10 w-48">
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile">
<User className="mr-2 h-4 w-4" />
<User className="me-2 h-4 w-4" />
{t('header.userMenu.profile')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/system">
<Settings className="mr-2 h-4 w-4" />
<Settings className="me-2 h-4 w-4" />
{t('header.userMenu.systemSettings')}
</Link>
</DropdownMenuItem>
@@ -42,14 +42,14 @@ export function UserMenu() {
(navigation-audit M8.) */}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" />
AI
<Bot className="me-2 h-4 w-4" />
{t('header.userMenu.ai')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/integrations">
<Puzzle className="mr-2 h-4 w-4" />
Integrations
<Puzzle className="me-2 h-4 w-4" />
{t('header.userMenu.integrations')}
</Link>
</DropdownMenuItem>
{/* Owner only. This is tidiness, not access control — the route stays reachable and its
@@ -58,15 +58,15 @@ export function UserMenu() {
{user?.role === 'Super Admin' && (
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/user-management">
<UserCog className="mr-2 h-4 w-4" />
User Management
<UserCog className="me-2 h-4 w-4" />
{t('header.userMenu.userManagement')}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" />
<LogOut className="me-2 h-4 w-4" />
{t('header.userMenu.signOut')}
</Link>
</DropdownMenuItem>
@@ -4,9 +4,29 @@ import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
type RescanResponse = { ok: boolean; counts: Record<string, number> };
/**
* One key per counted noun rather than one sentence with six numbers in it.
*
* Each noun pluralizes on its own count — English needs it for `1 task` vs `2 tasks`, and a locale with
* more than two categories needs it for every value. Assembling the sentence in code and translating only
* the frame would have hardcoded English plural rules into the frame.
*
* Spelled out as literal pairs rather than built from the count name: a key assembled at runtime defeats
* the type checker and cannot be grepped (see the key naming convention in docs/localization.md).
*/
const RESCAN_COUNTS: readonly (readonly [string, AppTranslationKey])[] = [
['tasks', 'rescan.counts.tasks'],
['agents', 'rescan.counts.agents'],
['tools', 'rescan.counts.tools'],
['skills', 'rescan.counts.skills'],
['processes', 'rescan.counts.processes'],
['affordances', 'rescan.counts.affordances'],
];
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the
// Automation and Capability pages).
const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories'];
@@ -15,6 +35,7 @@ export function RescanButton() {
const client = useClient();
const qc = useQueryClient();
const { can } = usePermissions();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
@@ -32,32 +53,28 @@ export function RescanButton() {
// you next open that page. This refreshes every item cache immediately, wherever you press it.
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
const c = res.counts ?? {};
toast.success(
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`,
{
// Two things said here rather than left to be discovered.
//
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
// agent, because the loader skips it: a line that does not say when to reach for something
// costs context in every conversation and earns nothing. So a skill can be present, listed and
// editable while being invisible to every agent, and the only symptom is two numbers
// disagreeing where nobody is comparing them.
description:
[
c.affordances || c.skillsOffered
? 'Applies to new chats — an open one keeps what it started with.'
: null,
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
? `${c.skills - c.skillsOffered} skill(s) have no description, so no agent is offered them.`
: null,
]
.filter(Boolean)
.join(' ') || undefined,
},
);
const summary = RESCAN_COUNTS.map(([noun, key]) => t(key, { count: c[noun] ?? 0 })).join(', ');
toast.success(t('rescan.done', { summary }), {
// Two things said here rather than left to be discovered.
//
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
// agent, because the loader skips it: a line that does not say when to reach for something
// costs context in every conversation and earns nothing. So a skill can be present, listed and
// editable while being invisible to every agent, and the only symptom is two numbers
// disagreeing where nobody is comparing them.
description:
[
c.affordances || c.skillsOffered ? t('rescan.newChatsOnly') : null,
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
? t('rescan.noDescription', { count: c.skills - c.skillsOffered })
: null,
]
.filter(Boolean)
.join(' ') || undefined,
});
} catch {
toast.error('Failed to rescan items');
toast.error(t('rescan.failed'));
} finally {
setLoading(false);
}
@@ -68,8 +85,8 @@ export function RescanButton() {
onClick={rescan}
disabled={loading}
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
aria-label="Rescan items"
title="Rescan officer-items (apply changes without restarting)"
aria-label={t('rescan.label')}
title={t('rescan.title')}
>
<RotateCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/lib/i18n';
type ScreenErrorFallbackProps = { error: Error; reset: () => void };
@@ -18,6 +19,7 @@ type ScreenErrorFallbackProps = { error: Error; reset: () => void };
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
const client = useClient();
const queryClient = useQueryClient();
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
@@ -46,36 +48,34 @@ export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps)
<div className="flex items-start gap-3">
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
<div className="space-y-1">
<h1 className="text-xl font-semibold">This screen failed to render</h1>
<p className="text-muted-foreground text-sm">
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
untouched.
</p>
<h1 className="text-xl font-semibold">{t('screenError.title')}</h1>
<p className="text-muted-foreground text-sm">{t('screenError.description')}</p>
</div>
</div>
<details className="bg-muted/40 rounded-md border p-3 text-sm">
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary>
<summary className="cursor-pointer select-none font-medium">
{error.message || t('screenError.unknownError')}
</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
</details>
<div className="flex flex-wrap gap-2">
<Button onClick={reset}>Try again</Button>
<Button onClick={reset}>{t('screenError.tryAgain')}</Button>
<Button variant="secondary" onClick={() => window.location.reload()}>
Reload
{t('screenError.reload')}
</Button>
<Button variant="secondary" asChild>
<Link to="/">Go home</Link>
<Link to="/">{t('screenError.goHome')}</Link>
</Button>
</div>
<div className="space-y-2 border-t pt-4">
<p className="text-muted-foreground text-sm">
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
{t('screenError.resetHint')} <strong>{t('screenError.dashboardsSafe')}</strong>
</p>
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'}
{resetting ? t('screenError.resetting') : confirming ? t('screenError.resetConfirm') : t('screenError.reset')}
</Button>
</div>
</div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Keyboard } from 'lucide-react';
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
import { useTranslation } from '@/lib/i18n';
/*
`Ctrl+?` — every shortcut the shell owns, on one screen.
@@ -27,6 +28,7 @@ const Row = ({ keys, description }: { keys: string; description: string }) => (
export const ShortcutsModal = () => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
@@ -57,19 +59,23 @@ export const ShortcutsModal = () => {
>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
<Keyboard className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
<h2 className="text-sm font-semibold">{t('shortcutsModal.title')}</h2>
<span className="ms-auto text-xs text-muted-foreground">{t('shortcutsModal.hint')}</span>
</div>
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
{SHORTCUT_SECTIONS.map((section) => (
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
<section key={section.titleKey} className="rounded-xl border border-border bg-muted/20 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">
{t(section.titleKey)}
</h3>
{section.noteKey && (
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{t(section.noteKey)}</p>
)}
<div className="mt-2">
{section.shortcuts.map((shortcut) => (
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
<Row key={shortcut.keys} keys={shortcut.keys} description={t(shortcut.descriptionKey)} />
))}
</div>
</section>
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { Search } from 'lucide-react';
import { rank } from './match';
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
import { useTranslation } from '@/lib/i18n';
// Spotlight: type a few letters, go somewhere.
//
@@ -181,6 +182,7 @@ export const Spotlight = () => {
const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const items = useSpotlightItems();
const { t } = useTranslation();
const results = useMemo(() => rank(items, query), [items, query]);
@@ -287,14 +289,14 @@ export const Spotlight = () => {
ref={inputRef}
value={query}
onChange={(ev) => setQuery(ev.target.value)}
placeholder="Go to…"
placeholder={t('spotlight.placeholder')}
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2">
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
<p className="px-3 py-6 text-center text-sm text-muted-foreground">{t('spotlight.noResults', { query })}</p>
) : (
results.map((result, i) => (
<Row
@@ -19,7 +19,8 @@ import {
Wrench,
} from 'lucide-react';
import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
import { useDockItems } from '../Dock';
// What Spotlight can take you to.
//
@@ -40,6 +41,7 @@ import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
export type SpotlightItem = {
/** Stable across renders — used as the React key and for the recency store. */
id: string;
/** Resolved for the active locale, because `rank()` matches what the reader can actually see. */
label: string;
/** Where it goes. Matched against `canVisit` for anything not already filtered. */
to: string;
@@ -78,64 +80,137 @@ export type SpotlightItem = {
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
* does not say where it lives.
*/
const SETTINGS_ITEMS: SpotlightItem[] = [
type SettingsItem = Omit<SpotlightItem, 'label' | 'hint'> & {
labelKey: AppTranslationKey;
hintKey?: AppTranslationKey;
};
const SETTINGS_ITEMS: SettingsItem[] = [
// Profile — ProfileSettings/index.tsx
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock },
{
id: 'set:profile',
labelKey: 'settings.sections.profile.label',
to: '/settings/profile',
hintKey: 'settings.sections.profile.hint',
icon: User,
},
{
id: 'set:password',
labelKey: 'settings.sections.changePassword.label',
to: '/settings/profile/change-password',
icon: Lock,
},
{
id: 'set:api-keys',
label: 'API keys',
labelKey: 'settings.sections.apiKeys.label',
to: '/settings/profile/api-keys',
hint: 'keys that sign in as you',
hintKey: 'settings.sections.apiKeys.hint',
icon: KeyRound,
},
{
id: 'set:languages',
label: 'Languages',
labelKey: 'settings.sections.languages.label',
to: '/settings/profile/languages',
hint: 'spoken and translation',
hintKey: 'settings.sections.languages.hint',
icon: Globe,
},
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 },
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid },
{
id: 'set:voice',
labelKey: 'settings.sections.voice.label',
to: '/settings/profile/voice',
hintKey: 'settings.sections.voice.hint',
icon: Volume2,
},
{
id: 'set:dock',
labelKey: 'settings.sections.dock.label',
to: '/settings/profile/dock',
hintKey: 'settings.sections.dock.hint',
icon: LayoutGrid,
},
// AI — AISettings.tsx
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles },
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles },
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles },
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot },
{
id: 'set:ai-my-models',
labelKey: 'settings.sections.myModels.label',
to: '/settings/ai/my-models',
icon: Sparkles,
},
{
id: 'set:ai-channel-models',
labelKey: 'settings.sections.channelModels.label',
to: '/settings/ai/member-models',
icon: Sparkles,
},
{
id: 'set:ai-default-models',
labelKey: 'settings.sections.defaultModels.label',
to: '/settings/ai/default-models',
icon: Sparkles,
},
{
id: 'set:ai-harnesses',
labelKey: 'settings.sections.providers.label',
to: '/settings/ai/ai-harnesses',
hintKey: 'settings.sections.providers.hint',
icon: Bot,
},
// System — SystemSettings.tsx
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText },
{ id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', labelKey: 'settings.sections.stt.label', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
{
id: 'set:maintenance',
label: 'Rebuild & restart',
labelKey: 'settings.sections.maintenance.label',
to: '/settings/system/maintenance',
hint: 'maintenance',
hintKey: 'settings.sections.maintenance.hint',
icon: Cpu,
},
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail },
{
id: 'set:smtp',
labelKey: 'settings.sections.smtp.label',
to: '/settings/system/smtp',
hintKey: 'settings.sections.smtp.hint',
icon: Mail,
},
// Integrations — IntegrationsSettings
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound },
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench },
{
id: 'set:google-oauth',
labelKey: 'settings.sections.googleOauth.label',
to: '/settings/integrations/google-oauth',
icon: KeyRound,
},
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
{
id: 'set:dav',
label: 'Calendar & Contacts sync',
labelKey: 'settings.sections.davSync.label',
to: '/settings/integrations/dav-app-passwords',
hint: 'app passwords',
hintKey: 'settings.sections.davSync.hint',
icon: CalendarDays,
},
// Users — UserManagement
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users },
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck },
{
id: 'set:users',
labelKey: 'settings.sections.accounts.label',
to: '/settings/user-management/users',
hintKey: 'settings.sections.accounts.hint',
icon: Users,
},
{
id: 'set:permissions',
labelKey: 'settings.sections.permissions.label',
to: '/settings/user-management/permissions',
icon: ShieldCheck,
},
];
/** Everything Spotlight can offer this account, in a stable order. */
export function useSpotlightItems(): SpotlightItem[] {
const { canVisit, plugins } = usePermissions();
const { t } = useTranslation();
const dockItems = useDockItems(plugins);
return useMemo(() => {
const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)]
const fromDock: SpotlightItem[] = dockItems
.filter((item) => canVisit(item.to))
.map((item) => ({
id: `nav:${item.to}`,
@@ -149,8 +224,14 @@ export function useSpotlightItems(): SpotlightItem[] {
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to));
const settings: SpotlightItem[] = SETTINGS_ITEMS.filter((item) => canVisit(item.to)).map(
({ labelKey, hintKey, ...item }) => ({
...item,
label: t(labelKey),
...(hintKey ? { hint: t(hintKey) } : {}),
}),
);
return [...fromDock, ...settings];
}, [canVisit, plugins]);
}, [canVisit, dockItems, t]);
}
@@ -4,8 +4,10 @@ import { KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
export const PasskeyGate = () => {
const { t } = useTranslation();
const { user, createPasskeyCredentials } = useAuth();
const [isRegistering, setIsRegistering] = useState(false);
@@ -31,7 +33,7 @@ export const PasskeyGate = () => {
<KeyRound className="h-8 w-8 text-duck-teal" />
</div>
<h2 className="text-2xl font-bold text-duck-dark">Set up your passkey</h2>
<h2 className="text-2xl font-bold text-duck-dark">{t('passkeyGate.setUp')}</h2>
<p className="text-duck-dark/70">
Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own
@@ -11,6 +11,7 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings';
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
import { useAccessPolicy } from 'state/useAccessPolicy';
import { useUserState } from 'state/useUserState';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
// --- My Models Section (per-user hidden models) ---
function MyModelsSection() {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings();
const visibleModels = useModels();
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
@@ -174,11 +176,7 @@ function MyModelsSection() {
}, []);
if (providerGroups.length === 0) {
return (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
}
return (
@@ -199,9 +197,9 @@ function MyModelsSection() {
))}
</div>
<DropZone label="Visible" onDrop={showModel}>
<DropZone label={t('settings.ai.visible')} onDrop={showModel}>
{shown.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to show</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.dragToShow')}</span>
)}
{shown.map((m) => (
<ModelPill
@@ -215,9 +213,9 @@ function MyModelsSection() {
))}
</DropZone>
<DropZone label="Hidden" onDrop={hideModel}>
<DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
{hidden.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models visible</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allVisible')}</span>
)}
{hidden.map((m) => (
<ModelPill
@@ -237,6 +235,7 @@ function MyModelsSection() {
// --- Member Models Section (system-wide policy, admin only) ---
function MemberModelsSection() {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const { policy, savePolicy } = useAccessPolicy();
@@ -249,9 +248,9 @@ function MemberModelsSection() {
try {
await client.post('/server-settings/chat-providers/local-providers/refresh');
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success('Models refreshed');
toast.success(t('settings.ai.refreshed'));
} catch {
toast.error('Failed to refresh models');
toast.error(t('settings.ai.refreshFailed'));
} finally {
setRefreshing(false);
}
@@ -325,11 +324,7 @@ function MemberModelsSection() {
}, []);
if (providerGroups.length === 0) {
return (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
}
return (
@@ -353,15 +348,17 @@ function MemberModelsSection() {
onClick={refreshModels}
disabled={refreshing}
className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Refresh models from providers"
title={t('settings.ai.refresh')}
>
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
<DropZone label="Enabled" onDrop={enableModel}>
<DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
{enabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.ai.dragToEnable')}
</span>
)}
{enabled.map((m) => (
<ModelPill
@@ -375,9 +372,9 @@ function MemberModelsSection() {
))}
</DropZone>
<DropZone label="Disabled" onDrop={disableModel}>
<DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
{disabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allEnabled')}</span>
)}
{disabled.map((m) => (
<ModelPill
@@ -410,49 +407,49 @@ function MemberModelsSection() {
function useAISettingsGroups(): SettingsSectionGroup[] {
return useMemo(() => {
const modelsGroup: SettingsSectionGroup = {
label: 'Models',
labelKey: 'settings.groups.models',
icon: Eye,
sections: [
{
key: 'my-models',
icon: Eye,
title: 'My Models',
description: 'Show or hide models for yourself',
titleKey: 'settings.sections.myModels.label',
descriptionKey: 'settings.sections.myModels.description',
content: <MyModelsSection />,
},
{
key: 'member-models',
icon: Eye,
title: 'Channel Models',
description: 'Models reachable by non-owner accounts',
titleKey: 'settings.sections.channelModels.label',
descriptionKey: 'settings.sections.channelModels.description',
content: <MemberModelsSection />,
},
],
};
const defaultsGroup: SettingsSectionGroup = {
label: 'Defaults',
labelKey: 'settings.groups.defaults',
icon: Sparkles,
sections: [
{
key: 'default-models',
icon: Sparkles,
title: 'Default Models',
description: 'Default models for chat, projects, and tasks',
titleKey: 'settings.sections.defaultModels.label',
descriptionKey: 'settings.sections.defaultModels.description',
content: <AIModels />,
},
],
};
const providersGroup: SettingsSectionGroup = {
label: 'Providers',
labelKey: 'settings.groups.providers',
icon: Terminal,
sections: [
{
key: 'ai-harnesses',
icon: Terminal,
title: 'Providers',
description: 'Remote and local AI providers',
titleKey: 'settings.sections.providers.label',
descriptionKey: 'settings.sections.providers.description',
content: <AIHarnessesSection />,
},
],
@@ -482,7 +479,7 @@ export const AISettings = () => {
createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: Bot,
sidebarLabel: 'AI',
sidebarLabelKey: 'settings.nav.ai',
groups,
}),
[groups],
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type ApifyConfigData = {
apiToken: string;
@@ -15,6 +16,7 @@ type ApifyStatus = {
};
export const ApifyConfig = () => {
const { t } = useTranslation();
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@@ -38,10 +40,10 @@ export const ApifyConfig = () => {
setIsSaving(true);
try {
await client.put('/integrations/apify/config', { apiToken: apiToken.trim() });
toast.success('Apify API token saved');
toast.success(t('settings.apify.saved'));
setStatus({ configured: !!apiToken.trim() });
} catch {
toast.error('Failed to save Apify API token');
toast.error(t('settings.apify.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -64,7 +66,7 @@ export const ApifyConfig = () => {
<form onSubmit={handleSave} className="grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Token</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.apify.token')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
@@ -7,6 +7,8 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
//
@@ -26,19 +28,21 @@ type DavPassword = {
type Minted = { password: string; username: string; label: string };
const formatDate = (value: string | null) =>
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(`${what} copied`);
} catch {
toast.error('Could not copy — select and copy manually');
}
};
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
export const DavAppPasswords = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.dav.copied', { what }));
} catch {
toast.error(t('settings.dav.copyFailed'));
}
};
const client = useClient();
const [passwords, setPasswords] = useState<DavPassword[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -71,7 +75,7 @@ export const DavAppPasswords = () => {
setLabel('');
load();
} catch {
toast.error('Could not create the app password');
toast.error(t('settings.dav.createFailed'));
} finally {
setIsCreating(false);
}
@@ -80,10 +84,10 @@ export const DavAppPasswords = () => {
const revoke = async (id: number) => {
try {
await client.post(`/dav/passwords/${id}/revoke`, {});
toast.success('Revoked — that device will stop syncing');
toast.success(t('settings.dav.revoked'));
load();
} catch {
toast.error('Could not revoke');
toast.error(t('settings.dav.revokeFailed'));
}
};
@@ -92,7 +96,7 @@ export const DavAppPasswords = () => {
await client.delete(`/dav/passwords/${id}`);
load();
} catch {
toast.error('Could not delete');
toast.error(t('settings.dav.deleteFailed'));
}
};
@@ -126,7 +130,12 @@ export const DavAppPasswords = () => {
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{value}
</code>
<Button variant="ghost" size="sm" onClick={() => copy(value, field)} title={`Copy ${field}`}>
<Button
variant="ghost"
size="sm"
onClick={() => copy(value, field)}
title={t('settings.dav.copyField', { field })}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
@@ -146,29 +155,31 @@ export const DavAppPasswords = () => {
<form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New device</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={label}
onChange={(ev) => setLabel(ev.target.value)}
placeholder="Pixel 9, iPad, Thunderbird…"
placeholder={t('settings.dav.placeholder')}
/>
</Label>
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
Create
{t('settings.dav.create')}
</Button>
</form>
{passwords.length === 0 ? (
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
No devices yet.
{t('settings.dav.none')}
</div>
) : (
<div className="grid gap-2">
{passwords.map((entry) => {
const revoked = !!entry.revokedAt;
const lastUsed = formatDate(entry.lastUsedAt);
const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div
key={entry.id}
@@ -192,7 +203,7 @@ export const DavAppPasswords = () => {
</div>
</div>
{!revoked && (
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title="Revoke">
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
@@ -200,7 +211,7 @@ export const DavAppPasswords = () => {
variant="ghost"
size="sm"
onClick={() => remove(entry.id)}
title="Delete"
title={t('settings.dav.delete')}
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type GoogleStatus = {
connected: boolean;
@@ -11,6 +12,7 @@ type GoogleStatus = {
};
export const GoogleAccount = () => {
const { t } = useTranslation();
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<GoogleStatus>({
@@ -30,9 +32,9 @@ export const GoogleAccount = () => {
const params = new URLSearchParams(window.location.search);
const result = params.get('google');
if (result === 'success') {
toast.success('Google account connected');
toast.success(t('settings.googleAccount.connectedToast'));
} else if (result === 'error') {
toast.error('Failed to connect Google account');
toast.error(t('settings.googleAccount.connectFailed'));
}
if (result) {
window.history.replaceState({}, '', window.location.pathname);
@@ -51,9 +53,9 @@ export const GoogleAccount = () => {
try {
await client.delete('/integrations/google/connection');
setStatus({ ...status, connected: false, email: null });
toast.success('Google account disconnected');
toast.success(t('settings.googleAccount.disconnectedToast'));
} catch {
toast.error('Failed to disconnect Google account');
toast.error(t('settings.googleAccount.disconnectFailed'));
}
};
@@ -63,17 +65,17 @@ export const GoogleAccount = () => {
return (
<div className="grid gap-4">
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Google Account</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
Connect your Google account for Calendar, Contacts, and other Google services.
</p>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">{t('settings.googleAccount.title')}</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">{t('settings.googleAccount.hint')}</p>
</div>
{status.connected ? (
<>
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-success shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.googleAccount.connected')}
</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
</div>
{status.picture && (
@@ -81,7 +83,7 @@ export const GoogleAccount = () => {
)}
</div>
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
Disconnect
{t('settings.googleAccount.disconnect')}
</Button>
</>
) : (
@@ -90,7 +92,7 @@ export const GoogleAccount = () => {
onClick={handleConnect}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
>
Connect Google Account
{t('settings.googleAccount.connect')}
</Button>
)}
</div>
@@ -7,197 +7,175 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
import { Untranslated } from '@/lib/Untranslated';
type GoogleOAuthSettings = {
clientId: string;
clientSecret: string;
};
// The scope strings are Google's own identifiers; only the explanation beside them is ours.
const SCOPES = [
{ scope: 'gmail.readonly', description: 'Read emails' },
{ scope: 'calendar.readonly', description: 'Read calendar events' },
];
{ scope: 'gmail.readonly', descriptionKey: 'settings.google.scopeGmail' },
{ scope: 'calendar.readonly', descriptionKey: 'settings.google.scopeCalendar' },
] as const;
const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
/*
Every quoted Google-console label below is wrapped in <Untranslated>. Per docs/localization.md they
are identifiers, not prose: they exist to match what the reader sees on Google's screen, and Google
localises by the reader's Google account language rather than ours.
The sentences are keyed WHOLE, each ending at the label or link it introduces. The first attempt
split them around inline labels, which needs "Go to the" and "page." as separate keys — and the
Portuguese value of the second one is "." That is the fragmentation smell, so the English was
rephrased to put the label at a clause boundary, which reads naturally in both languages.
*/
const GoogleLink = ({ href, label }: { href: string; label: string }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<Untranslated reason="foreign-ui-label">{label}</Untranslated>
</a>
);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
Step-by-step setup guide
{t('settings.google.guideToggle')}
</CollapsibleTrigger>
<CollapsibleContent>
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
<li>
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step1.title')}</strong>
<p className="mt-1">
Go to the{' '}
<a
{t('settings.google.step1.body')}{' '}
<GoogleLink
href="https://console.cloud.google.com/projectcreate"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
label={t('settings.google.step1.link')}
/>{' '}
→ <Untranslated reason="foreign-ui-label">Create</Untranslated>
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step2.title')}</strong>
<p className="mt-1">
Go to{' '}
<a
{t('settings.google.step2.body')}{' '}
<GoogleLink
href="https://console.cloud.google.com/apis/library"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
API Library
</a>
. Search for and enable each of these:
label={t('settings.google.step2.link')}
/>{' '}
→ <Untranslated reason="foreign-ui-label">Enable</Untranslated>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
<strong>Gmail API</strong>
<Untranslated reason="foreign-ui-label">Gmail API</Untranslated>
</li>
<li>
<strong>Google Calendar API</strong>
<Untranslated reason="foreign-ui-label">Google Calendar API</Untranslated>
</li>
</ul>
<p className="mt-1">
Click each one, then click <strong>Enable</strong>.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step3.title')}</strong>
<p className="mt-1">
Go to{' '}
<a
<GoogleLink
href="https://console.cloud.google.com/auth/branding"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Branding
</a>
.
label={t('settings.google.step3.link')}
/>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
Set <strong>App name</strong> to your organization name or &quot;Officer&quot;
{t('settings.google.step3.appName')} <Untranslated reason="foreign-ui-label">App name</Untranslated>
</li>
<li>
Set <strong>User support email</strong> to your admin email
{t('settings.google.step3.supportEmail')}{' '}
<Untranslated reason="foreign-ui-label">User support email</Untranslated>
</li>
<li>
Add your admin email under <strong>Developer contact information</strong>
{t('settings.google.step3.devContact')}{' '}
<Untranslated reason="foreign-ui-label">Developer contact information</Untranslated>
</li>
<li>
Click <strong>Save</strong>
{t('settings.google.step3.save')} <Untranslated reason="foreign-ui-label">Save</Untranslated>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step4.title')}</strong>
<p className="mt-1">
Go to{' '}
<a
<GoogleLink
href="https://console.cloud.google.com/auth/audience"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Audience
</a>
.
label={t('settings.google.step4.link')}
/>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
{t('settings.google.step4.internal')} <Untranslated reason="foreign-ui-label">Internal</Untranslated>
</li>
<li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
(required while the app is unverified; limit of 100 test users)
{t('settings.google.step4.external')} <Untranslated reason="foreign-ui-label">External</Untranslated>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step5.title')}</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a
href="https://console.cloud.google.com/auth/scopes"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
{t('settings.google.step5.body')}{' '}
<GoogleLink href="https://console.cloud.google.com/auth/scopes" label={t('settings.google.step5.link')} />{' '}
→ <Untranslated reason="foreign-ui-label">Add or remove scopes</Untranslated>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => (
<li key={s.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
{s.description}
{SCOPES.map((entry) => (
<li key={entry.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{entry.scope}
</code>{' '}
— {t(entry.descriptionKey)}
</li>
))}
</ul>
<p className="mt-1">
Click <strong>Update</strong>, then <strong>Save</strong>.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">{t('settings.google.step5.note')}</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step6.title')}</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a
{t('settings.google.step6.body')}{' '}
<GoogleLink
href="https://console.cloud.google.com/auth/clients"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Clients
</a>
, then click <strong>Create OAuth client</strong>.
label={t('settings.google.step6.link')}
/>{' '}
→ <Untranslated reason="foreign-ui-label">Create OAuth client</Untranslated>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
Application type: <strong>Web application</strong>
{t('settings.google.step6.appType')}{' '}
<Untranslated reason="foreign-ui-label">Web application</Untranslated>
</li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li>
<li>{t('settings.google.step6.nameAnything')}</li>
<li>
Authorized redirect URIs: add{' '}
{t('settings.google.step6.redirectUris')}{' '}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all">
{redirectUri}
</code>
</li>
<li>
Click <strong>Create</strong>
{t('settings.google.step6.thenClick')} <Untranslated reason="foreign-ui-label">Create</Untranslated>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
<p className="mt-1">
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
them into the fields below.
</p>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step7.title')}</strong>
<p className="mt-1">{t('settings.google.step7.body')}</p>
</li>
</ol>
</CollapsibleContent>
@@ -208,11 +186,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
type VerifyStatus = { valid: boolean; error: string | null } | null;
const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
const { t } = useTranslation();
if (isVerifying) {
return (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="h-2.5 w-2.5 rounded-full bg-duck-dark/20 dark:bg-foreground/20 animate-pulse shrink-0" />
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">Verifying credentials...</span>
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">{t('settings.google.verifying')}</span>
</div>
);
}
@@ -223,13 +202,14 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-success' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
{status.valid ? t('settings.google.valid') : (status.error ?? t('settings.google.invalid'))}
</span>
</div>
);
};
export const GoogleOAuthConfig = () => {
const { t } = useTranslation();
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@@ -243,7 +223,7 @@ export const GoogleOAuthConfig = () => {
client
.get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
.then(setVerifyStatus)
.catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' }))
.catch(() => setVerifyStatus({ valid: false, error: t('settings.google.verifyFailed') }))
.finally(() => setIsVerifying(false));
};
@@ -273,10 +253,10 @@ export const GoogleOAuthConfig = () => {
setIsSaving(true);
try {
await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
toast.success('Google OAuth configuration saved');
toast.success(t('settings.google.saved'));
verify();
} catch {
toast.error('Failed to save Google OAuth configuration');
toast.error(t('settings.google.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -294,7 +274,7 @@ export const GoogleOAuthConfig = () => {
<form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientId')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
@@ -305,7 +285,7 @@ export const GoogleOAuthConfig = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client Secret</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientSecret')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
@@ -316,10 +296,8 @@ export const GoogleOAuthConfig = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Redirect URI</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Add this URI to your Google OAuth client&apos;s authorized redirect URIs
</p>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.redirectUri')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.google.redirectUriHint')}</p>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60"
type="text"
@@ -10,6 +10,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { ApifyConfig } from './ApifyConfig';
import { DavAppPasswords } from './DavAppPasswords';
import { useTranslation } from '@/lib/i18n';
const BASE_PATH = '/settings/integrations';
@@ -17,15 +18,15 @@ const enterpriseSections: SettingsSection[] = [
{
key: 'google-oauth',
icon: KeyRound,
title: 'Google OAuth',
description: 'Client ID and secret for Google APIs',
titleKey: 'settings.sections.googleOauth.label',
descriptionKey: 'settings.sections.googleOauth.description',
content: <GoogleOAuthConfig />,
},
{
key: 'apify',
icon: Wrench,
title: 'Apify',
description: 'API token for web scraping actors',
titleKey: 'settings.sections.apify.label',
descriptionKey: 'settings.sections.apify.description',
content: <ApifyConfig />,
},
];
@@ -39,14 +40,14 @@ const personalSections: SettingsSection[] = [
// key: 'google-account',
// icon: UserCircle,
// title: 'Google Account',
// description: 'Connect your Google account',
// descriptionKey: 'settings.sections.apify.description',
// content: <GoogleAccount />,
// },
{
key: 'dav-app-passwords',
icon: CalendarDays,
title: 'Calendar & Contacts sync',
description: 'Per-device passwords for CalDAV/CardDAV clients',
titleKey: 'settings.sections.davSync.label',
descriptionKey: 'settings.sections.davSync.description',
content: <DavAppPasswords />,
},
];
@@ -60,6 +61,7 @@ const tabForSection = (section: string | undefined) =>
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
const IntegrationsSidebar = () => {
const { t } = useTranslation();
const { section } = useParams<{ section?: string }>();
const navigate = useNavigate();
const tab = tabForSection(section);
@@ -77,22 +79,28 @@ const IntegrationsSidebar = () => {
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Puzzle className="h-4 w-4" />
Integrations
{t('settings.nav.integrations')}
</div>
</div>
<div className="px-3 pb-2">
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="w-full">
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
Enterprise
{t('settings.integrations.enterprise')}
</TabsTrigger>
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
Personal
{t('settings.integrations.personal')}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<SettingsSidebar basePath={BASE_PATH} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
<SettingsSidebar
basePath={BASE_PATH}
icon={Puzzle}
labelKey="settings.nav.integrations"
sections={sections}
hideHeader
/>
</div>
);
};
@@ -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<string, { id: string; name: string }[]> = {};
@@ -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 (
<div className="grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultChat')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat from the home screen
{t('settings.profile.models.defaultChatHint')}
</p>
{renderModelSelect(chatModel, setChatModel, 'System default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultProject')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat inside a project dashboard
{t('settings.profile.models.defaultProjectHint')}
</p>
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultTask')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when running tasks from the file browser
{t('settings.profile.models.defaultTaskHint')}
</p>
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label>
@@ -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 (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.profile.apiKeys.loading')}
</div>
);
}
@@ -124,15 +129,13 @@ export const ApiKeys = () => {
return (
<div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
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{' '}
<code className="font-mono text-xs">Authorization: Bearer …</code>.
{t('settings.profile.apiKeys.intro')} <code className="font-mono text-xs">Authorization: Bearer …</code>.
</p>
{minted && (
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
Key for “{minted.name}” — shown once
{t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
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 = () => {
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{minted.key}
</code>
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key">
<Button
variant="ghost"
size="sm"
onClick={() => copy(minted.key)}
title={t('settings.profile.apiKeys.copyKey')}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
I've saved it
{t('settings.profile.apiKeys.savedIt')}
</Button>
</div>
)}
<form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.apiKeys.newKey')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="iPhone, Music app, laptop CLI…"
placeholder={t('settings.profile.apiKeys.namePlaceholder')}
/>
</Label>
<Button type="submit" disabled={!name.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
Create
<Plus className="me-1.5 h-4 w-4" />
{t('settings.profile.apiKeys.create')}
</Button>
</form>
@@ -174,14 +182,16 @@ export const ApiKeys = () => {
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
{/* "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')}
</div>
) : (
<div className="grid gap-2">
{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 (
<div
key={entry.id}
@@ -195,14 +205,20 @@ export const ApiKeys = () => {
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-duck-dark dark:text-foreground">
{entry.name}
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>}
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>}
{entry.revokedAt && (
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.revoked')}</span>
)}
{!entry.revokedAt && expired && (
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.expired')}</span>
)}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
<code className="font-mono">{entry.prefix}…</code>
{' · '}
{/* "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')}
</div>
</div>
{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"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -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"
>
<Ban className="h-3.5 w-3.5" />
@@ -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 })}
</button>
)}
</div>
@@ -3,7 +3,8 @@ import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
import { useTranslation } from '@/lib/i18n';
type DockPillProps = {
label: string;
@@ -104,10 +105,11 @@ 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();
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
const allDockItems = useDockItems(plugins);
const { items, allItems, setItems, reset } = useDock(
allDockItems,
useMemo(() => defaultDockPaths(plugins), [plugins]),
@@ -193,10 +195,10 @@ export const DockSettings = () => {
return (
<div className="grid gap-4">
<DropZone label="Visible" onDrop={onDropVisible}>
<DropZone label={t('settings.profile.dock.visible')} onDrop={onDropVisible}>
{items.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
Drag items here to show in dock
{t('settings.profile.dock.dragToShow')}
</span>
)}
{items.map((item) => (
@@ -216,9 +218,11 @@ export const DockSettings = () => {
))}
</DropZone>
<DropZone label="Hidden" onDrop={onDropHidden}>
<DropZone label={t('settings.profile.dock.hidden')} onDrop={onDropHidden}>
{hiddenItems.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.profile.dock.allVisible')}
</span>
)}
{hiddenItems.map((item) => (
<DockPill
@@ -235,7 +239,7 @@ export const DockSettings = () => {
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Reset to defaults
{t('settings.profile.dock.reset')}
</Button>
</div>
);
@@ -1,45 +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('');
@@ -56,12 +78,13 @@ export const Languages = () => {
const removeSpoken = (code: string) => {
const next = spoken.filter((s) => s !== code);
const updates = { ...settings.languages, spoken: next };
if (defaultLang === code) updates.default = next[0] ?? 'en';
// `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
// language never leaves it without a valid value.
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
save(updates);
};
const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code));
const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code));
return (
<div className="grid gap-5">
@@ -74,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 && (
<button
onClick={() => removeSpoken(code)}
@@ -94,9 +117,9 @@ export const Languages = () => {
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
>
<option value="">{t('settings.profile.languages.addLanguage')}</option>
{availableToAdd.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
{availableToAdd.map((code) => (
<option key={code} value={code}>
{languageName(code)}
</option>
))}
</select>
@@ -104,7 +127,7 @@ export const Languages = () => {
)}
</div>
{/* Default language */}
{/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
<Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
<select
@@ -112,9 +135,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{spoken.map((code) => (
{SHIPPED_LOCALES.map(({ code, label }) => (
<option key={code} value={code}>
{getLabel(code)}
{label}
</option>
))}
</select>
@@ -129,9 +152,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
{SPOKEN_LANGUAGE_TAGS.map((code) => (
<option key={code} value={code}>
{languageName(code)}
</option>
))}
</select>
@@ -13,8 +13,10 @@ import {
} from '@/components/ui/select';
import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
export const TaskDefaults = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings();
const models = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false);
@@ -44,9 +46,9 @@ export const TaskDefaults = () => {
setIsSaving(true);
try {
await saveSettings({ ...settings, tasks: { defaultProvider: 'chat', defaultModel: model } });
toast.success('Task defaults saved');
toast.success(t('settings.profile.models.taskDefaultsSaved'));
} catch {
toast.error('Failed to save settings');
toast.error(t('settings.profile.models.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -55,10 +57,10 @@ export const TaskDefaults = () => {
return (
<div className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Default Model</span>
<span className="text-duck-dark/70">{t('settings.profile.models.defaultModel')}</span>
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Same as chat default" />
<SelectValue placeholder={t('settings.profile.models.sameAsChat')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{modelGroups.map(({ provider, models }) => (
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type ProfileFormState = {
name?: string;
@@ -24,6 +25,7 @@ const readFileAsBase64 = (file: File): Promise<string> => {
};
export const UserData = () => {
const { t } = useTranslation();
const { user, updateUser } = useAuth();
const [isUpdating, setIsUpdating] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
@@ -35,12 +37,12 @@ export const UserData = () => {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
toast.error(t('settings.profile.userData.selectImage'));
return;
}
if (file.size > MAX_AVATAR_SIZE) {
toast.error('Image must be smaller than 384KB');
toast.error(t('settings.profile.userData.imageTooLarge'));
return;
}
@@ -58,11 +60,11 @@ export const UserData = () => {
name: profileForm.state.name ?? '',
avatar: avatarPreview ?? user?.avatar ?? '',
});
toast.success('Profile updated');
toast.success(t('settings.profile.userData.updated'));
setAvatarPreview(null);
} catch (ex) {
const error = ex as { message?: string };
toast.error(error.message || 'Failed to update profile');
toast.error(error.message || t('settings.profile.userData.updateFailed'));
} finally {
setIsUpdating(false);
}
@@ -93,7 +95,7 @@ export const UserData = () => {
<form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.email')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
@@ -103,7 +105,7 @@ export const UserData = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.username')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
@@ -113,12 +115,12 @@ export const UserData = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.name')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="name"
placeholder="Your name"
placeholder={t('settings.profile.userData.namePlaceholder')}
autoComplete="name"
/>
</Label>
@@ -128,7 +130,7 @@ export const UserData = () => {
disabled={isUpdating}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isUpdating ? 'Saving...' : 'Save'}
{isUpdating ? t('common.saving') : t('common.save')}
</Button>
</form>
</div>
@@ -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<string[]>([]);
@@ -128,13 +130,13 @@ export const VoicePreference = () => {
<div className="grid gap-4">
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.voice.label')}</span>
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
title={t('settings.profile.voice.refresh')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
@@ -144,7 +146,7 @@ export const VoicePreference = () => {
<div className="flex items-center gap-2">
<Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Server default" />
<SelectValue placeholder={t('settings.profile.voice.serverDefault')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>
@@ -175,7 +177,7 @@ export const VoicePreference = () => {
onClick={handleListen}
disabled={listening === 'loading' || !serverConfig}
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
title={listening === 'playing' ? 'Stop' : 'Listen'}
title={listening === 'playing' ? t('settings.profile.voice.stop') : t('settings.profile.voice.listen')}
>
{listening === 'loading' ? (
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
@@ -186,9 +188,7 @@ export const VoicePreference = () => {
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.profile.voice.hint')}</span>
</Label>
</div>
);
@@ -14,12 +14,18 @@ import { VoicePreference } from './VoicePreference';
const BASE_PATH = '/settings/profile';
const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
{
key: 'profile',
icon: User,
titleKey: 'settings.sections.profile.label',
descriptionKey: 'settings.sections.profile.description',
content: <UserData />,
},
{
key: 'change-password',
icon: Lock,
title: 'Change Password',
description: 'Update your password',
titleKey: 'settings.sections.changePassword.label',
descriptionKey: 'settings.sections.changePassword.description',
content: <ChangePassword />,
},
// Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an
@@ -29,29 +35,29 @@ const sections: SettingsSection[] = [
{
key: 'api-keys',
icon: KeyRound,
title: 'API keys',
description: 'Keys that sign in as you, revocable one at a time',
titleKey: 'settings.sections.apiKeys.label',
descriptionKey: 'settings.sections.apiKeys.description',
content: <ApiKeys />,
},
{
key: 'languages',
icon: Globe,
title: 'Languages',
description: 'Spoken, default, and translation',
titleKey: 'settings.sections.languages.label',
descriptionKey: 'settings.sections.languages.description',
content: <Languages />,
},
{
key: 'voice',
icon: Volume2,
title: 'Voice',
description: 'Text-to-speech voice preference',
titleKey: 'settings.sections.voice.label',
descriptionKey: 'settings.sections.voice.description',
content: <VoicePreference />,
},
{
key: 'dock',
icon: LayoutGrid,
title: 'Dock',
description: 'Choose and reorder dock items',
titleKey: 'settings.sections.dock.label',
descriptionKey: 'settings.sections.dock.description',
content: <DockSettings />,
},
];
@@ -59,7 +65,7 @@ const sections: SettingsSection[] = [
const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: User,
sidebarLabel: 'Profile',
sidebarLabelKey: 'settings.nav.profile',
sections,
});
@@ -13,6 +13,7 @@ import {
CommandList,
} from '@/components/ui/command';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type StoredApiKeys = { keys: { provider: string; value: string }[] };
@@ -38,6 +39,7 @@ type ChatProvider = { key: string; providerId: string };
type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' };
export const AIHarnessesSection = () => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [installing, setInstalling] = useState(false);
@@ -168,7 +170,7 @@ export const AIHarnessesSection = () => {
// No auth needed — save directly
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
toast.error(t('settings.server.harnesses.connectFailed'));
setProbeState({ step: 'url' });
}
};
@@ -192,13 +194,13 @@ export const AIHarnessesSection = () => {
return;
}
if (result.needsAuth) {
toast.error('Authentication failed');
toast.error(t('settings.server.harnesses.authFailed'));
setProbeState({ step: 'auth', probe });
return;
}
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
toast.error(t('settings.server.harnesses.connectFailed'));
setProbeState({ step: 'auth', probe });
}
};
@@ -218,10 +220,10 @@ export const AIHarnessesSection = () => {
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_PROVIDERS'] });
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_HEALTH'] });
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success(`Connected to ${probe.name}`);
toast.success(t('settings.server.harnesses.connectedTo', { name: probe.name ?? '' }));
resetLocalForm();
} catch {
toast.error('Failed to save provider');
toast.error(t('settings.server.harnesses.saveFailed'));
setProbeState({ step: 'url' });
}
};
@@ -254,9 +256,9 @@ export const AIHarnessesSection = () => {
<div className="flex flex-col gap-4">
<div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-duck-dark">Pi Mono</span>
<span className="text-sm font-medium text-duck-dark">{t('settings.server.harnesses.piMono')}</span>
{piMonoLoading ? (
<span className="text-xs text-duck-dark/50">Checking...</span>
<span className="text-xs text-duck-dark/50">{t('settings.server.harnesses.checking')}</span>
) : piMonoVersion?.version ? (
<div className="flex items-center gap-2">
<span className="text-xs text-duck-dark/50">{piMonoVersion.version}</span>
@@ -265,7 +267,7 @@ export const AIHarnessesSection = () => {
onClick={installAgent}
disabled={installing}
className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Update Pi Mono"
title={t('settings.server.harnesses.updatePiMono')}
>
<RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} />
</button>
@@ -285,13 +287,21 @@ export const AIHarnessesSection = () => {
<>
{/* Local Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.harnesses.localProviders')}
</span>
<div className="mt-3 flex flex-col gap-2">
{localProviders.map((lp: LocalProviderEntry) => (
<div key={lp.id} className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-success' : 'bg-red-500') : 'bg-duck-dark/20'}`}
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
title={
lp.id in localHealth
? localHealth[lp.id]
? 'Online'
: 'Offline'
: t('settings.server.harnesses.checking')
}
/>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
@@ -299,7 +309,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => removeLocalProvider(lp.id)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Remove provider"
title={t('settings.server.harnesses.removeProvider')}
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
@@ -312,7 +322,7 @@ export const AIHarnessesSection = () => {
<Input
type="text"
className="h-8 text-xs"
placeholder="Name (e.g. My Ollama)"
placeholder={t('settings.server.harnesses.namePlaceholder')}
value={localName}
onChange={(ev) => setLocalName(ev.target.value)}
onKeyDown={(ev) => {
@@ -340,7 +350,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!localUrl.trim()}
>
Connect
{t('settings.server.harnesses.connect')}
</Button>
)}
{(probeState.step === 'probing' || probeState.step === 'saving') && (
@@ -351,7 +361,7 @@ export const AIHarnessesSection = () => {
onClick={resetLocalForm}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</div>
</form>
@@ -370,7 +380,7 @@ export const AIHarnessesSection = () => {
<Input
type="text"
className="h-8 text-xs"
placeholder="Username"
placeholder={t('settings.server.harnesses.username')}
value={authUsername}
onChange={(ev) => setAuthUsername(ev.target.value)}
autoFocus
@@ -378,7 +388,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-8 text-xs"
placeholder="Password"
placeholder={t('settings.server.harnesses.password')}
value={authPassword}
onChange={(ev) => setAuthPassword(ev.target.value)}
/>
@@ -388,7 +398,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authUsername || !authPassword}
>
Authenticate
{t('settings.server.harnesses.authenticate')}
</Button>
</>
) : (
@@ -396,7 +406,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-8 text-xs"
placeholder="API Key"
placeholder={t('settings.server.harnesses.apiKey')}
value={authApiKey}
onChange={(ev) => setAuthApiKey(ev.target.value)}
autoFocus
@@ -407,7 +417,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authApiKey}
>
Authenticate
{t('settings.server.harnesses.authenticate')}
</Button>
</>
)}
@@ -423,7 +433,7 @@ export const AIHarnessesSection = () => {
onClick={() => setAddingLocal(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Local Provider
{t('settings.server.harnesses.connectLocal')}
</Button>
)}
</div>
@@ -431,7 +441,9 @@ export const AIHarnessesSection = () => {
{/* Remote Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.harnesses.remoteProviders')}
</span>
<div className="mt-3 flex flex-col gap-2">
{connectedProviders.map((provider) => (
<div key={provider.providerId} className="flex flex-col gap-2">
@@ -457,7 +469,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Edit key"
title={t('settings.server.harnesses.editKey')}
>
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
</button>
@@ -465,7 +477,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => disconnectProvider(provider)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Disconnect provider"
title={t('settings.server.harnesses.disconnect')}
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
@@ -478,7 +490,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
placeholder={getStoredMasked(provider.providerId) || t('settings.server.harnesses.enterKey')}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
@@ -492,7 +504,7 @@ export const AIHarnessesSection = () => {
type="submit"
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
title={t('settings.server.harnesses.saveKey')}
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
@@ -501,7 +513,7 @@ export const AIHarnessesSection = () => {
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</form>
)}
@@ -524,7 +536,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder="Enter API key"
placeholder={t('settings.server.harnesses.enterKey')}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
@@ -538,7 +550,7 @@ export const AIHarnessesSection = () => {
type="submit"
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
title={t('settings.server.harnesses.saveKey')}
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
@@ -547,7 +559,7 @@ export const AIHarnessesSection = () => {
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</form>
);
@@ -561,14 +573,14 @@ export const AIHarnessesSection = () => {
onClick={() => setCommandOpen(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Provider
{t('settings.server.harnesses.connectProvider')}
</Button>
)}
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Search providers..." />
<CommandInput placeholder={t('settings.server.harnesses.search')} />
<CommandList>
<CommandEmpty>No providers found.</CommandEmpty>
<CommandEmpty>{t('settings.server.harnesses.none')}</CommandEmpty>
<CommandGroup heading="Available Providers">
{unconnectedProviders.map((provider) => (
<CommandItem
@@ -3,6 +3,7 @@ import { Download, Hammer, Loader2, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
/**
* The two ways to load a change without leaving the browser.
@@ -30,6 +31,7 @@ export function errorMessage(err: unknown, fallback: string): string {
}
export const MaintenanceSection = () => {
const { t } = useTranslation();
const client = useClient();
const [pulling, setPulling] = useState(false);
const [pulled, setPulled] = useState<{ from: string; to: string; commits: string[]; needs: string[] } | null>(null);
@@ -58,11 +60,11 @@ export const MaintenanceSection = () => {
}>('/server-settings/pull', {});
if (!res.updated) {
setPulled(null);
toast.success('Already up to date');
toast.success(t('settings.server.maintenance.upToDate'));
return;
}
setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs });
toast.success(`Pulled ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`);
toast.success(t('settings.server.maintenance.pulled', { count: res.commits.length }));
} catch (err) {
toast.error(errorMessage(err, 'Pull failed'));
} finally {
@@ -81,7 +83,7 @@ export const MaintenanceSection = () => {
setLastBuild(`${(res.ms / 1000).toFixed(1)}s · ${res.outputs} files`);
// Nothing reloads itself: the bundle on disk changed, this page is still the old one. Saying so is
// the difference between "it worked" and "why does my change not show".
toast.success('Frontend rebuilt — refresh this page to see it');
toast.success(t('settings.server.maintenance.rebuilt'));
} catch (err) {
// The client throws on any status >= 400 with the raw response body as its message, so a build
// failure arrives here as the JSON the route sent. Unwrapped, or the toast shows a reader
@@ -96,7 +98,7 @@ export const MaintenanceSection = () => {
setRestarting(true);
try {
await client.post('/server-settings/restart', {});
toast.success('Restarting — the page will reconnect on its own');
toast.success(t('settings.server.maintenance.restarting'));
} catch (err) {
/**
* Two failures arrive here and they are opposites.
@@ -116,7 +118,7 @@ export const MaintenanceSection = () => {
setRestarting(false);
return;
}
toast.success('Restarting — the page will reconnect on its own');
toast.success(t('settings.server.maintenance.restarting'));
}
// Left disabled on the success path, deliberately: the button must not become pressable again while
// the server is coming back.
@@ -126,14 +128,12 @@ export const MaintenanceSection = () => {
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Update the platform</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.updateTitle')}</div>
<p className="mt-1 text-xs text-muted-foreground">
Pulls this server&rsquo;s repository. Refuses if anything is uncommitted or the checkout has diverged — it
will never discard local work.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Changes nothing that is running. Whatever the update needs is listed here, and stays your call.
</p>
<p className="mt-1 text-xs text-muted-foreground">{t('settings.server.maintenance.updateHint')}</p>
{pulled && (
<div className="mt-2 rounded-md border border-border bg-muted/40 p-2">
<p className="font-mono text-[11px] text-muted-foreground">
@@ -163,7 +163,7 @@ export const MaintenanceSection = () => {
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.rebuild')}</div>
<p className="mt-1 text-xs text-muted-foreground">
Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session —
refresh the page when it finishes.
@@ -178,7 +178,7 @@ export const MaintenanceSection = () => {
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Restart the server</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.restart')}</div>
<p className="mt-1 text-xs text-muted-foreground">
The only way to load changed server code — Bun caches modules for the life of the process, so rebuilding
cannot see an edit to the backend.
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type OcrConfig = {
url: string;
@@ -15,6 +16,7 @@ type OcrConfig = {
};
export const OCRSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
@@ -59,9 +61,9 @@ export const OCRSection = () => {
try {
await client.put('/server-settings/ocr', { url, model });
queryClient.invalidateQueries({ queryKey: ['OCR_CONFIG'] });
toast.success('OCR settings saved');
toast.success(t('settings.server.ocr.saved'));
} catch {
toast.error('Failed to save OCR settings');
toast.error(t('settings.server.ocr.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -75,7 +77,7 @@ export const OCRSection = () => {
if (res.error) {
toast.error(res.error);
} else {
toast.success('Vision model server is reachable');
toast.success(t('settings.server.ocr.reachable'));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -91,12 +93,12 @@ export const OCRSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return (
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Vision Model Server URL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.ocr.serverUrl')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="http://localhost:64203"
@@ -107,13 +109,13 @@ export const OCRSection = () => {
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
<button
type="button"
onClick={() => fetchModels(url)}
disabled={modelsLoading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh models"
title={t('settings.server.ocr.refreshModels')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${modelsLoading ? 'animate-spin' : ''}`}
@@ -123,7 +125,7 @@ export const OCRSection = () => {
{models.length > 0 ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Select a model" />
<SelectValue placeholder={t('settings.server.ocr.selectModel')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{models.map((m) => (
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type Provider = 'resend' | 'smtp' | 'mailhog';
@@ -31,6 +32,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<SmtpConfig>> = {
};
export const SMTPSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const { user } = useAuth();
@@ -92,9 +94,9 @@ export const SMTPSection = () => {
try {
await client.put('/server-settings/smtp', buildConfig());
queryClient.invalidateQueries({ queryKey: ['SMTP_CONFIG'] });
toast.success('SMTP settings saved');
toast.success(t('settings.server.smtp.saved'));
} catch {
toast.error('Failed to save SMTP settings');
toast.error(t('settings.server.smtp.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -105,7 +107,7 @@ export const SMTPSection = () => {
setIsTestingConnection(true);
try {
await client.post('/server-settings/smtp/test-connection');
toast.success('Connection successful');
toast.success(t('settings.server.smtp.connected'));
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Connection failed';
@@ -132,7 +134,7 @@ export const SMTPSection = () => {
if (result.error) {
toast.error(result.error);
} else {
toast.success(`Test email sent to ${testEmail}`);
toast.success(t('settings.server.smtp.testSent', { email: testEmail }));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -148,7 +150,7 @@ export const SMTPSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
const showSmtpFields = provider === 'smtp' || provider === 'mailhog';
@@ -156,22 +158,22 @@ export const SMTPSection = () => {
<div className="grid gap-4">
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[600]">
<SelectItem value="mailhog">MailHog (local testing)</SelectItem>
<SelectItem value="resend">Resend</SelectItem>
<SelectItem value="smtp">Custom SMTP</SelectItem>
<SelectItem value="mailhog">{t('settings.server.smtp.mailhog')}</SelectItem>
<SelectItem value="resend">{t('settings.server.smtp.resend')}</SelectItem>
<SelectItem value="smtp">{t('settings.server.smtp.custom')}</SelectItem>
</SelectContent>
</Select>
</Label>
{provider === 'resend' && (
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Key</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.apiKey')}</span>
<Input
type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -186,7 +188,7 @@ export const SMTPSection = () => {
<>
<div className="grid grid-cols-[1fr_auto] gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Host</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.host')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="smtp.example.com"
@@ -195,7 +197,7 @@ export const SMTPSection = () => {
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Port</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.port')}</span>
<Input
type="number"
className="h-11 w-24 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
@@ -208,16 +210,20 @@ export const SMTPSection = () => {
<>
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Username</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
{t('settings.server.smtp.username')}
</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="user@example.com"
placeholder={t('settings.server.smtp.userExample')}
value={username}
onChange={(ev) => setUsername(ev.target.value)}
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Password</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
{t('settings.server.smtp.password')}
</span>
<Input
type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
@@ -228,7 +234,7 @@ export const SMTPSection = () => {
</div>
<Label className="flex items-center gap-3">
<Switch checked={secure} onCheckedChange={setSecure} />
<span className="text-duck-dark/70 dark:text-foreground/70">TLS / SSL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.tls')}</span>
</Label>
</>
)}
@@ -237,7 +243,7 @@ export const SMTPSection = () => {
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">From Name</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromName')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
value={fromName}
@@ -245,7 +251,7 @@ export const SMTPSection = () => {
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">From Email</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromEmail')}</span>
<Input
type="email"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -276,12 +282,14 @@ export const SMTPSection = () => {
</form>
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-4 mt-1">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Send Test Email</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.smtp.sendTest')}
</span>
<form onSubmit={handleTest} className="flex items-center gap-2 mt-2">
<Input
type="email"
className="h-9 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 flex-1"
placeholder="recipient@example.com"
placeholder={t('settings.server.smtp.recipientExample')}
value={testEmail}
onChange={(ev) => setTestEmail(ev.target.value)}
/>
@@ -6,12 +6,14 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type SttConfig = {
url: string;
};
export const STTSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
@@ -36,9 +38,9 @@ export const STTSection = () => {
try {
await client.put('/server-settings/stt', { url });
queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] });
toast.success('STT settings saved');
toast.success(t('settings.server.stt.saved'));
} catch {
toast.error('Failed to save STT settings');
toast.error(t('settings.server.stt.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -52,7 +54,7 @@ export const STTSection = () => {
if (res.error) {
toast.error(res.error);
} else {
toast.success('Whisper server is reachable');
toast.success(t('settings.server.stt.reachable'));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -68,12 +70,12 @@ export const STTSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return (
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Whisper Server URL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.stt.serverUrl')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="http://localhost:64201"
@@ -16,6 +16,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type Provider = 'openai' | 'elevenlabs';
@@ -33,6 +34,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<TtsConfig>> = {
};
export const TTSSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -110,9 +112,9 @@ export const TTSSection = () => {
try {
await client.put('/server-settings/tts', buildConfig());
queryClient.invalidateQueries({ queryKey: ['TTS_CONFIG'] });
toast.success('TTS settings saved');
toast.success(t('settings.server.tts.saved'));
} catch {
toast.error('Failed to save TTS settings');
toast.error(t('settings.server.tts.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -143,27 +145,27 @@ export const TTSSection = () => {
const audio = new Audio(audioUrl);
audioRef.current = audio;
audio.play();
toast.success('Playing test audio');
toast.success(t('settings.server.tts.playing'));
} catch {
toast.error('Test failed — could not reach TTS server');
toast.error(t('settings.server.tts.testFailed'));
} finally {
setIsTesting(false);
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return (
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[600]">
<SelectItem value="openai">OpenAI-compatible (Kokoro, OpenAI, etc.)</SelectItem>
<SelectItem value="elevenlabs">ElevenLabs</SelectItem>
<SelectItem value="openai">{t('settings.server.tts.openaiCompatible')}</SelectItem>
<SelectItem value="elevenlabs">{t('settings.server.tts.elevenLabs')}</SelectItem>
</SelectContent>
</Select>
</Label>
@@ -195,7 +197,7 @@ export const TTSSection = () => {
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder={provider === 'openai' ? 'kokoro' : 'eleven_multilingual_v2'}
@@ -205,13 +207,13 @@ export const TTSSection = () => {
</Label>
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.voice')}</span>
<button
type="button"
onClick={() => fetchVoices(provider, url, apiKey, model)}
disabled={voicesLoading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
title={t('settings.server.tts.refreshVoices')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
@@ -221,7 +223,7 @@ export const TTSSection = () => {
{voices.length > 0 ? (
<Select value={voice} onValueChange={setVoice}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Select a voice" />
<SelectValue placeholder={t('settings.server.tts.selectVoice')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{voiceGroups.length > 0
@@ -3,17 +3,24 @@ import { useState } from 'react';
import { NavLink, Navigate, useParams } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
/**
* Sections are declared as module-level consts holding JSX `content`, so they cannot call a hook —
* they carry catalog KEYS and the renderers below resolve them. `titleKey` rather than `titleKeyName`
* and deliberately not `title_key`: docs-check-settings.ts greps `key:\s*'…'` for route segments, and
* a capital K keeps `titleKey` out of that match.
*/
export type SettingsSection = {
key: string;
icon: LucideIcon;
title: string;
description: string;
titleKey: AppTranslationKey;
descriptionKey: AppTranslationKey;
content: ReactNode;
};
export type SettingsSectionGroup = {
label: string;
labelKey: AppTranslationKey;
icon: LucideIcon;
sections: SettingsSection[];
};
@@ -21,7 +28,7 @@ export type SettingsSectionGroup = {
type SettingsSidebarProps = {
basePath: string;
icon: LucideIcon;
label: string;
labelKey: AppTranslationKey;
sections: SettingsSection[];
groups?: SettingsSectionGroup[];
hideHeader?: boolean;
@@ -32,44 +39,51 @@ type SettingsSidebarProps = {
* the browser knows it has been visited, and `isActive` comes from the router instead of from a global
* that the content pane had to be told about separately.
*/
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => (
<NavLink
to={to}
className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
<section.icon
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
</div>
</>
)}
</NavLink>
);
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => {
const { t } = useTranslation();
return (
<NavLink
to={to}
className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
<section.icon
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{t(section.titleKey)}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">
{t(section.descriptionKey)}
</div>
</div>
</>
)}
</NavLink>
);
};
export const SettingsSidebar = ({
basePath,
icon: Icon,
label,
labelKey,
sections,
groups,
hideHeader,
}: SettingsSidebarProps) => {
const [search, setSearch] = useState('');
const { t } = useTranslation();
const query = search.toLowerCase();
// Matched against the RESOLVED words, so searching works in the language on screen.
const matchesSearch = (s: SettingsSection) =>
s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query);
t(s.titleKey).toLowerCase().includes(query) || t(s.descriptionKey).toLowerCase().includes(query);
return (
<div className="flex flex-col h-full overflow-y-auto">
@@ -77,13 +91,13 @@ export const SettingsSidebar = ({
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Icon className="h-4 w-4" />
{label}
{t(labelKey)}
</div>
</div>
)}
<div className="px-3 pb-2">
<Input
placeholder="Search..."
placeholder={t('settings.searchPlaceholder')}
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs"
@@ -95,11 +109,11 @@ export const SettingsSidebar = ({
const filtered = group.sections.filter(matchesSearch);
if (filtered.length === 0) return null;
return (
<div key={group.label}>
<div key={group.labelKey}>
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
<group.icon className="h-3 w-3 text-duck-dark/30 dark:text-foreground/30" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30">
{group.label}
{t(group.labelKey)}
</span>
</div>
{filtered.map((s) => (
@@ -121,6 +135,7 @@ type SettingsContentProps = {
};
export const SettingsContent = ({ sections }: SettingsContentProps) => {
const { t } = useTranslation();
// The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key.
// They agree via the URL now, so neither has to be told about the other — and neither can be the one
// that seeds the slot, which is what made the old defaulting order matter (see §5.6).
@@ -130,7 +145,7 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
if (!section) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p>
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">{t('settings.selectSection')}</p>
</div>
);
}
@@ -139,9 +154,9 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2.5 mb-1">
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2>
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{t(section.titleKey)}</h2>
</div>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{t(section.descriptionKey)}</p>
<div>{section.content}</div>
</div>
);
@@ -166,7 +181,7 @@ export const SettingsRoute = ({ basePath, sections, children }: SettingsRoutePro
type CreateSettingsPanelParams = {
basePath: string;
sidebarIcon: LucideIcon;
sidebarLabel: string;
sidebarLabelKey: AppTranslationKey;
sections?: SettingsSection[];
groups?: SettingsSectionGroup[];
};
@@ -174,7 +189,7 @@ type CreateSettingsPanelParams = {
export const createSettingsPanelComponents = ({
basePath,
sidebarIcon,
sidebarLabel,
sidebarLabelKey,
sections = [],
groups,
}: CreateSettingsPanelParams) => {
@@ -183,7 +198,7 @@ export const createSettingsPanelComponents = ({
<SettingsSidebar
basePath={basePath}
icon={sidebarIcon}
label={sidebarLabel}
labelKey={sidebarLabelKey}
sections={allSections}
groups={groups}
/>
@@ -12,44 +12,56 @@ import { OCRSection } from './ServerSettings/OCRSection';
const groups: SettingsSectionGroup[] = [
{
label: 'AI',
labelKey: 'settings.groups.ai',
icon: Bot,
sections: [
{
key: 'tts',
icon: Volume2,
title: 'Text to Speech',
description: 'TTS provider and voice',
titleKey: 'settings.sections.tts.label',
descriptionKey: 'settings.sections.tts.description',
content: <TTSSection />,
},
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> },
{
key: 'stt',
icon: Mic,
titleKey: 'settings.sections.stt.label',
descriptionKey: 'settings.sections.stt.description',
content: <STTSection />,
},
{
key: 'ocr',
icon: ScanText,
title: 'OCR',
description: 'Vision model for text extraction',
titleKey: 'settings.sections.ocr.label',
descriptionKey: 'settings.sections.ocr.description',
content: <OCRSection />,
},
],
},
{
label: 'Maintenance',
labelKey: 'settings.groups.maintenance',
icon: Hammer,
sections: [
{
key: 'maintenance',
icon: Hammer,
title: 'Rebuild & restart',
description: 'Load changes without a terminal',
titleKey: 'settings.sections.maintenance.label',
descriptionKey: 'settings.sections.maintenance.description',
content: <MaintenanceSection />,
},
],
},
{
label: 'Email',
labelKey: 'settings.groups.email',
icon: Mail,
sections: [
{ key: 'smtp', icon: Mail, title: 'SMTP', description: 'Email delivery provider', content: <SMTPSection /> },
{
key: 'smtp',
icon: Mail,
titleKey: 'settings.sections.smtp.label',
descriptionKey: 'settings.sections.smtp.description',
content: <SMTPSection />,
},
],
},
];
@@ -59,7 +71,7 @@ const BASE_PATH = '/settings/system';
const { Sidebar, Content, allSections } = createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: Settings,
sidebarLabel: 'System',
sidebarLabelKey: 'settings.nav.system',
groups,
});
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/lib/i18n';
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
//
@@ -65,6 +66,7 @@ type CreatedAccount = {
};
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
@@ -118,16 +120,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
<div>
<h3 className="text-sm font-medium">{created.email} created</h3>
<p className="text-xs text-muted-foreground">
Copy what you need before closing — none of it can be shown again.
</p>
<p className="text-xs text-muted-foreground">{t('settings.users.copyBeforeClosing')}</p>
</div>
<div className="space-y-1.5">
<Label>Password</Label>
<Label>{t('settings.users.password')}</Label>
<div className="flex gap-2">
<Input readOnly value={created.password} className="font-mono" />
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copy(created.password, t('settings.users.password'))}
>
<Copy className="h-4 w-4" />
</Button>
</div>
@@ -139,14 +144,14 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
{created.osUser && (
<div className="space-y-1.5">
<Label>Linux account</Label>
<Label>{t('settings.users.linuxAccount')}</Label>
<Input readOnly value={created.osUser} className="font-mono" />
</div>
)}
{created.osSshPublicKey && (
<div className="space-y-1.5">
<Label>Their SSH public key</Label>
<Label>{t('settings.users.sshKey')}</Label>
<div className="flex gap-2">
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
<Button
@@ -162,20 +167,20 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
permission error that says nothing about a missing key. */}
<p className="text-xs text-muted-foreground">
Generated on the machine; the private half never leaves it.{' '}
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row.
<strong>{t('settings.users.addToGitea')}</strong> so they can push. Retrievable later from their row.
</p>
</div>
)}
{created.osUserError && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<div className="font-medium">The account works, but its Linux side did not finish</div>
<div className="font-medium">{t('settings.users.linuxIncomplete')}</div>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
</div>
)}
<Button type="button" onClick={close}>
Done
{t('settings.users.done')}
</Button>
</div>
);
@@ -185,7 +190,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Add account
{t('settings.users.addAccount')}
</Button>
);
}
@@ -194,19 +199,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
<form onSubmit={submit} className="space-y-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-sm font-medium">New account</h3>
<h3 className="text-sm font-medium">{t('settings.users.newAccount')}</h3>
<p className="text-xs text-muted-foreground">
Created active — they can sign in straight away. Tell them the password; it is not recoverable afterwards.
</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label="Cancel">
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label={tUi('button.cancel')}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-user-email">Email</Label>
<Label htmlFor="new-user-email">{t('settings.users.email')}</Label>
<Input
id="new-user-email"
type="email"
@@ -218,7 +223,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-name">Name</Label>
<Label htmlFor="new-user-name">{t('settings.users.name')}</Label>
<Input
id="new-user-name"
autoComplete="off"
@@ -229,7 +234,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-username">Username</Label>
<Label htmlFor="new-user-username">{t('settings.users.username')}</Label>
<Input
id="new-user-username"
autoComplete="off"
@@ -237,11 +242,11 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
onChange={(ev) => set('username')(ev.target.value)}
required
/>
<p className="text-xs text-muted-foreground">Letters, numbers, dots, hyphens and underscores.</p>
<p className="text-xs text-muted-foreground">{t('settings.users.usernameHint')}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-role">Role</Label>
<Label htmlFor="new-user-role">{t('settings.users.role')}</Label>
<Select value={form.role} onValueChange={set('role')}>
<SelectTrigger id="new-user-role">
<SelectValue />
@@ -261,7 +266,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-password">Password</Label>
<Label htmlFor="new-user-password">{t('settings.users.password')}</Label>
<div className="flex gap-2">
<Input
id="new-user-password"
@@ -276,7 +281,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
/>
<Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}>
<Dices className="h-4 w-4" />
<span className="sr-only">Generate a password</span>
<span className="sr-only">{t('settings.users.generatePassword')}</span>
</Button>
<Button
type="button"
@@ -285,29 +290,27 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
disabled={!form.password}
onClick={() => {
void copyToClipboard(form.password);
toast.success('Password copied');
toast.success(t('settings.users.passwordCopied'));
}}
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy the password</span>
<span className="sr-only">{t('settings.users.copyPassword')}</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
At least 12 characters, with upper and lower case, a number and a symbol.
</p>
<p className="text-xs text-muted-foreground">{t('settings.users.passwordHint')}</p>
</div>
{/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does
not replace it, because a key on a laptop is no use to an agent running on the server. */}
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-ssh">
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span>
{t('settings.users.sshKey')} <span className="ml-1 text-xs opacity-60">(optional)</span>
</Label>
<Textarea
id="new-user-ssh"
rows={3}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop"
placeholder={t('settings.users.sshPlaceholder')}
className="font-mono text-xs"
value={form.sshPublicKey}
onChange={(ev) => set('sshPublicKey')(ev.target.value)}
@@ -325,7 +328,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
Create account
</Button>
<Button type="button" variant="ghost" onClick={close} disabled={saving}>
Cancel
{tUi('button.cancel')}
</Button>
</div>
</form>
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
import { PERMISSIONS_QUERY_KEY } from 'hooks/usePermissions';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/lib/i18n';
// What each ROLE may reach. Not each user — see the table comment in schema/permissions.ts for why.
//
@@ -37,6 +38,7 @@ type Level = 'none' | 'read' | 'write';
const PERMISSIONS_KEY = ['ROLE_PERMISSIONS'];
export const PermissionsSection = () => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [role, setRole] = useState<string | null>(null);
@@ -78,7 +80,7 @@ export const PermissionsSection = () => {
// The owner may be editing their own view's inputs — and anyone already signed in needs the dock to
// catch up without a reload.
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_QUERY_KEY });
toast.success(`Saved what ${activeRole}s can reach`);
toast.success(t('settings.users.permissionsSaved', { role: activeRole }));
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not save');
} finally {
@@ -89,12 +91,12 @@ export const PermissionsSection = () => {
if (isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading permissions…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingPermissions')}
</div>
);
}
if (isError || !data) {
return <div className="p-6 text-sm text-destructive">Could not load permissions.</div>;
return <div className="p-6 text-sm text-destructive">{t('settings.users.permissionsFailed')}</div>;
}
return (
@@ -103,7 +105,11 @@ export const PermissionsSection = () => {
hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real
buttons, because switching role mutates a draft rather than navigating. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role">
<div
className="flex items-center gap-1 rounded-lg border p-1"
role="tablist"
aria-label={t('settings.users.role')}
>
{data.roles.map((r) => (
<button
key={r}
@@ -128,9 +134,9 @@ export const PermissionsSection = () => {
</div>
<p className="text-xs text-muted-foreground">
Everything is denied unless granted here. <strong>Read</strong> allows viewing, plus changes to things that are
only ever the person&rsquo;s own — their favourites, their playlists, their devices.
<strong> Full</strong> allows everything within the app.
{t('settings.users.deniedUnlessGranted')} <strong>{t('settings.users.read')}</strong> allows viewing, plus
changes to things that are only ever the person&rsquo;s own — their favourites, their playlists, their devices.
<strong> {t('settings.users.full')}</strong> allows everything within the app.
</p>
<div className="divide-y rounded-lg border">
@@ -151,9 +157,9 @@ export const PermissionsSection = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No access</SelectItem>
<SelectItem value="read">Read</SelectItem>
<SelectItem value="write">Full</SelectItem>
<SelectItem value="none">{t('settings.users.noAccess')}</SelectItem>
<SelectItem value="read">{t('settings.users.read')}</SelectItem>
<SelectItem value="write">{t('settings.users.full')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -17,6 +17,7 @@ import {
} from '@/components/ui/alert-dialog';
import { CreateUserForm } from './CreateUserForm';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/lib/i18n';
type ManagedUser = {
id: number;
@@ -43,6 +44,7 @@ type UsersResponse = {
const USERS_KEY = ['MANAGED_USERS'];
export const UsersSection = () => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [pendingId, setPendingId] = useState<number | null>(null);
@@ -77,11 +79,7 @@ export const UsersSection = () => {
* reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it".
*/
const provisionLinux = async (user: ManagedUser) => {
const key = window.prompt(
`Linux account for ${user.email}.\n\n` +
`Paste an SSH public key to allow them to SSH in, or leave empty to keep the current one.`,
'',
);
const key = window.prompt(t('settings.users.provisionPrompt', { email: user.email }), '');
// Cancel is null; empty string is a deliberate "no change".
if (key === null) return;
@@ -147,13 +145,13 @@ export const UsersSection = () => {
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading accounts…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingAccounts')}
</div>
);
}
if (isError || !data) {
return <div className="text-sm text-destructive">Could not load the accounts.</div>;
return <div className="text-sm text-destructive">{t('settings.users.accountsFailed')}</div>;
}
return (
@@ -173,7 +171,12 @@ export const UsersSection = () => {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{user.name || user.username || user.email}</span>
{user.isOwner && <Crown className="h-3.5 w-3.5 shrink-0 text-duck-teal" aria-label="Server owner" />}
{user.isOwner && (
<Crown
className="h-3.5 w-3.5 shrink-0 text-duck-teal"
aria-label={t('settings.users.serverOwner')}
/>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{user.email}
@@ -212,11 +215,13 @@ export const UsersSection = () => {
size="icon"
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
disabled={busy}
aria-label={user.osUser ? `Repair ${user.email}'s Linux account` : `Create a Linux account`}
aria-label={
user.osUser ? t('settings.users.repairFor', { email: user.email }) : t('settings.users.createLinux')
}
title={
user.osUser
? `Linux account: ${user.osUser} — click to repair or replace their SSH key`
: 'No Linux account — click to create one'
? t('settings.users.linuxAccountFor', { osUser: user.osUser })
: t('settings.users.noLinux')
}
onClick={() => void provisionLinux(user)}
>
@@ -231,11 +236,11 @@ export const UsersSection = () => {
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
aria-label={`Copy ${user.email}'s SSH public key`}
title="Copy their SSH public key (add it to their Gitea account)"
aria-label={t('settings.users.copySshFor', { email: user.email })}
title={t('settings.users.copySshTitle')}
onClick={() => {
void copyToClipboard(user.osSshPublicKey!);
toast.success('Public key copied');
toast.success(t('settings.users.publicKeyCopied'));
}}
>
<KeyRound className="h-4 w-4" />
@@ -250,8 +255,8 @@ export const UsersSection = () => {
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Reset ${user.email}'s password`}
title="Generate a new password — shown once, and signs them out everywhere"
aria-label={t('settings.users.resetPasswordFor', { email: user.email })}
title={t('settings.users.resetPasswordTitle')}
onClick={() => setConfirmReset(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
@@ -264,7 +269,7 @@ export const UsersSection = () => {
className="shrink-0 text-muted-foreground hover:text-destructive"
disabled={user.isOwner || busy}
onClick={() => setConfirmDelete(user)}
aria-label={`Remove ${user.email}`}
aria-label={t('settings.users.removeFor', { email: user.email })}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
@@ -282,19 +287,18 @@ export const UsersSection = () => {
<AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove {confirmDelete?.email}?</AlertDialogTitle>
<AlertDialogDescription>
This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, and
anything an installed plugin keeps for them. It cannot be undone.
</AlertDialogDescription>
<AlertDialogTitle>
{t('settings.users.removeTitle', { email: confirmDelete?.email ?? '' })}
</AlertDialogTitle>
<AlertDialogDescription>{t('settings.users.removeBody')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => confirmDelete && void remove(confirmDelete)}
>
Remove account
{t('settings.users.removeAccount')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -305,23 +309,16 @@ export const UsersSection = () => {
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the password for {confirmReset?.email}?</AlertDialogTitle>
<AlertDialogTitle>{t('settings.users.resetTitle', { email: confirmReset?.email ?? '' })}</AlertDialogTitle>
<AlertDialogDescription>
A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up
afterwards. Their existing password stops working immediately, and they are signed out everywhere.
{confirmReset?.osUser ? (
<>
{' '}
Their Linux account ({confirmReset.osUser}) is not affected: it has no password, and SSH keys are
unchanged.
</>
) : null}
{t('settings.users.resetBody')}
{confirmReset?.osUser ? ` ${t('settings.users.resetLinuxNote', { osUser: confirmReset.osUser })}` : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
Generate a new password
{t('settings.users.generateNewPassword')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -345,11 +342,11 @@ export const UsersSection = () => {
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Copy the new password"
aria-label={t('settings.users.copyNewPassword')}
onClick={() => {
if (!newPassword) return;
void copyToClipboard(newPassword.password).then((ok) =>
ok ? toast.success('Password copied') : toast.error('Could not copy — select it and copy by hand'),
ok ? toast.success(t('settings.users.passwordCopied')) : toast.error(t('settings.users.copyFailed')),
);
}}
>
@@ -358,7 +355,7 @@ export const UsersSection = () => {
</div>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setNewPassword(null)}>Done</AlertDialogAction>
<AlertDialogAction onClick={() => setNewPassword(null)}>{t('settings.users.done')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -15,15 +15,15 @@ const sections: SettingsSection[] = [
{
key: 'users',
icon: Users,
title: 'Accounts',
description: 'Who has access, and as what',
titleKey: 'settings.sections.accounts.label',
descriptionKey: 'settings.sections.accounts.description',
content: <UsersSection />,
},
{
key: 'permissions',
icon: ShieldCheck,
title: 'Permissions',
description: 'What each role can reach',
titleKey: 'settings.sections.permissions.label',
descriptionKey: 'settings.sections.permissions.description',
content: <PermissionsSection />,
},
];
@@ -33,7 +33,7 @@ const BASE_PATH = '/settings/user-management';
const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: UserCog,
sidebarLabel: 'Users',
sidebarLabelKey: 'settings.nav.users',
sections,
});
+30 -5
View File
@@ -1,11 +1,30 @@
import { useEffect } from 'react';
import type { ReactNode } from 'react';
import type { TranslationMap } from 'i18n';
import { I18nProvider } from 'i18n';
import { I18nProvider, localeDirection } from 'i18n';
import { panelCatalogs } from 'officerdev';
import { uiCatalogs } from '@/components/locales';
import { useSettings } from 'state/useSettings';
import en from '../locales/en.json';
import pt from '../locales/pt.json';
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
import appEnGB from '../locales/en-GB.json';
import appPtPT from '../locales/pt-PT.json';
const translations: TranslationMap = { en, pt };
const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
/**
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
*/
const translations: TranslationMap = Object.fromEntries(
SHIPPED_LOCALES.map(({ code }) => [
code,
{
app: appCatalogs[code] ?? {},
panel: panelCatalogs[code] ?? {},
ui: uiCatalogs[code] ?? {},
},
]),
);
type I18nBridgeProps = {
children: ReactNode;
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
const { settings } = useSettings();
const locale = settings.languages.default;
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
// RTL catalog needs no second pass over the tree.
useEffect(() => {
document.documentElement.dir = localeDirection(locale);
}, [locale]);
return (
<I18nProvider translations={translations} locale={locale}>
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
{children}
</I18nProvider>
);
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
type UntranslatedProps = {
children: ReactNode;
/** Why this text is not localized. Required so the decision is legible at the call site. */
reason: 'foreign-ui-label';
className?: string;
};
/**
* Text that is deliberately NOT localized, marked as such where it is written.
*
* The only reason so far is `foreign-ui-label`: a label quoted from another product's interface, which
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
* what the reader sees on Google's screen, and Google localises by their Google account language rather
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
*
* It exists as a component rather than a comment for two reasons. It states the decision at every call
* site, where the next person editing the sentence will see it. And it gives the scanner something exact
* to skip: without that, ~40 labels that MUST stay English count as debt forever, and the area can never
* be held at zero — the same trap as counting `new Error()` messages.
*
* `reason` is a closed union on purpose. A second legitimate reason should be argued for and added here,
* not smuggled in as free text.
*/
export const Untranslated = ({ children, className }: UntranslatedProps) => (
<strong className={className}>{children}</strong>
);
+23 -3
View File
@@ -1,12 +1,32 @@
import type { TranslationKeys } from 'i18n';
import type { InterpolationParams } from 'i18n';
import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n';
import type en from '../locales/en.json';
import type en from '../locales/en-GB.json';
import type uiEn from '@/components/locales/en-GB.json';
type AppTranslationKey = TranslationKeys<typeof en>;
type UiTranslationKey = TranslationKeys<typeof uiEn>;
/**
* `t` for this workspace's own catalog, `tUi` for the shared-component one.
*
* Both are here rather than in two imports because "Cancel", "Close" and "Search" recur across the
* shell and genuinely belong to `ui.*` — phase 1 put them there so phases 2 and 3 could reach for them
* instead of inventing a second key for the same word. Anything feature-specific stays in `app.*`:
* per D6 a `ui.*` key may not name a feature.
*
* Both are memoized. An unstable `t` is unusable in a dependency array, and this repo has already lost
* days to exactly that shape — see platform/CLAUDE.md on `useClient()` and the Jellyfin player.
*/
export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase();
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
return { t, locale };
const t = useCallback((key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params), [baseT]);
const tUi = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
return { t, tUi, locale };
};
export type { AppTranslationKey };
+27
View File
@@ -0,0 +1,27 @@
/**
* The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the
* one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is
* deliberately separate from the broader "languages you speak" / "translate to" lists, which are a
* different feature (see the doc's open question 4) and are not limited to locales with a catalog.
*/
export type ShippedLocale = { code: string; label: string };
/**
* The label is an AUTONYM — each language named in its own language, so "Português" reads as Portuguese
* whichever locale the UI is currently in. That is the convention for a language picker and it is the
* useful behaviour: somebody who cannot read the current language still has to find theirs.
*
* Computed rather than written down, for the same reason the spoken-language list is (see Languages.tsx):
* `Intl` already knows every language's name in every locale, including its own.
*/
const autonym = (code: string): string => {
const name = new Intl.DisplayNames([code], { type: 'language', fallback: 'code' }).of(code) ?? code;
return name.charAt(0).toUpperCase() + name.slice(1);
};
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT'].map((code) => ({
code,
label: autonym(code),
}));
export const FALLBACK_LOCALE = 'en-GB';
+704
View File
@@ -0,0 +1,704 @@
{
"common": {
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel"
},
"settings": {
"profile": {
"userData": {
"email": "Email",
"username": "Username",
"name": "Name",
"namePlaceholder": "Your name",
"updated": "Profile updated",
"updateFailed": "Failed to update profile",
"selectImage": "Please select an image file",
"imageTooLarge": "Image must be smaller than 384KB"
},
"apiKeys": {
"intro": "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",
"loading": "Loading keys…",
"mintedTitle": "Key for “{{name}}” — shown once",
"savedIt": "I’ve saved it",
"copyKey": "Copy key",
"copied": "Key copied",
"copyFailed": "Could not copy — select and copy manually",
"newKey": "New key",
"namePlaceholder": "iPhone, Music app, laptop CLI…",
"create": "Create",
"createFailed": "Could not create the key",
"noneYet": "No keys yet.",
"noneInUse": "No keys in use.",
"revoked": "revoked",
"expired": "expired",
"lastUsed": "last used {{date}}",
"neverUsed": "never used",
"forget": "Remove this record",
"forgetFailed": "Could not remove it",
"revoke": "Revoke",
"revokeDone": "Revoked — anything using that key is signed out",
"revokeFailed": "Could not revoke",
"hideDead": "Hide revoked and expired",
"showDead": {
"one": "Show {{count}} revoked or expired key",
"other": "Show {{count}} revoked or expired keys"
}
},
"voice": {
"label": "Voice",
"refresh": "Refresh voices",
"serverDefault": "Server default",
"hint": "Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.",
"stop": "Stop",
"listen": "Listen"
},
"dock": {
"visible": "Visible",
"hidden": "Hidden",
"dragToShow": "Drag items here to show in dock",
"allVisible": "All items visible",
"reset": "Reset to defaults"
},
"languages": {
"spokenLabel": "Languages you speak",
"addLanguage": "Add a language...",
"defaultLabel": "Default language",
"defaultHint": "Used for UI localization.",
"translateToLabel": "Translate to",
"translateToHint": "Target language when translating content you don't speak."
},
"changePassword": {
"currentPasswordLabel": "Current Password",
"currentPasswordPlaceholder": "Current password",
"newPasswordLabel": "New Password",
"newPasswordPlaceholder": "New password",
"confirmPasswordLabel": "Confirm New Password",
"confirmPasswordPlaceholder": "Confirm new password",
"changeButton": "Change Password",
"changingButton": "Changing...",
"passwordChanged": "Password changed",
"passwordChangeFailed": "Failed to change password"
},
"models": {
"defaultModel": "Default Model",
"sameAsChat": "Same as chat default",
"taskDefaultsSaved": "Task defaults saved",
"saveFailed": "Failed to save settings",
"defaultChat": "Default Chat Model",
"defaultChatHint": "Used when starting a new chat from the home screen",
"defaultProject": "Default Project Model",
"defaultProjectHint": "Used when starting a new chat inside a project dashboard",
"defaultTask": "Default Task Model",
"defaultTaskHint": "Used when running tasks from the file browser",
"aiDefaultsSaved": "AI model defaults saved"
}
},
"sections": {
"profile": {
"label": "Profile",
"hint": "avatar, name, email",
"description": "Avatar, name, and email"
},
"changePassword": {
"label": "Change password",
"description": "Update your password"
},
"apiKeys": {
"label": "API keys",
"hint": "keys that sign in as you",
"description": "Keys that sign in as you, revocable one at a time"
},
"languages": {
"label": "Languages",
"hint": "spoken and translation",
"description": "Spoken, default, and translation"
},
"voice": {
"label": "Voice",
"hint": "text-to-speech",
"description": "Text-to-speech voice preference"
},
"dock": {
"label": "Dock",
"hint": "choose and reorder",
"description": "Choose and reorder dock items"
},
"myModels": {
"label": "My Models",
"description": "Show or hide models for yourself"
},
"channelModels": {
"label": "Channel Models",
"description": "Models reachable by non-owner accounts"
},
"defaultModels": {
"label": "Default Models",
"description": "Default models for chat, projects, and tasks"
},
"providers": {
"label": "Providers",
"hint": "harnesses",
"description": "Remote and local AI providers"
},
"tts": {
"label": "Text to Speech",
"description": "TTS provider and voice"
},
"stt": {
"label": "Speech to Text",
"description": "Whisper server URL"
},
"ocr": {
"label": "OCR",
"description": "Vision model for text extraction"
},
"maintenance": {
"label": "Rebuild & restart",
"hint": "maintenance",
"description": "Load changes without a terminal"
},
"smtp": {
"label": "SMTP",
"hint": "outgoing mail",
"description": "Email delivery provider"
},
"googleOauth": {
"label": "Google OAuth",
"description": "Client ID and secret for Google APIs"
},
"apify": {
"label": "Apify",
"description": "API token for web scraping actors"
},
"davSync": {
"label": "Calendar & Contacts sync",
"hint": "app passwords",
"description": "Per-device passwords for CalDAV/CardDAV clients"
},
"accounts": {
"label": "Accounts",
"hint": "users",
"description": "Who has access, and as what"
},
"permissions": {
"label": "Permissions",
"description": "What each role can reach"
}
},
"searchPlaceholder": "Search settings…",
"selectSection": "Select a section",
"groups": {
"models": "Models",
"defaults": "Defaults",
"providers": "Providers",
"ai": "AI",
"maintenance": "Maintenance",
"email": "Email"
},
"nav": {
"profile": "Profile",
"ai": "AI",
"system": "System",
"integrations": "Integrations",
"users": "Users"
},
"ai": {
"noModels": "No models available. Configure API keys in Providers.",
"dragToShow": "Drag models here to show",
"allVisible": "All models visible",
"dragToEnable": "Drag models here to enable",
"allEnabled": "All models enabled",
"visible": "Visible",
"hidden": "Hidden",
"enabled": "Enabled",
"disabled": "Disabled",
"refresh": "Refresh models from providers",
"refreshed": "Models refreshed",
"refreshFailed": "Failed to refresh models"
},
"integrations": {
"enterprise": "Enterprise",
"personal": "Personal"
},
"server": {
"loading": "Loading…",
"provider": "Provider",
"model": "Model",
"voice": "Voice",
"ocr": {
"serverUrl": "Vision Model Server URL",
"refreshModels": "Refresh models",
"selectModel": "Select a model",
"saved": "OCR settings saved",
"saveFailed": "Failed to save OCR settings",
"reachable": "Vision model server is reachable"
},
"stt": {
"serverUrl": "Whisper Server URL",
"saved": "STT settings saved",
"saveFailed": "Failed to save STT settings",
"reachable": "Whisper server is reachable"
},
"tts": {
"openaiCompatible": "OpenAI-compatible (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
"refreshVoices": "Refresh voices",
"selectVoice": "Select a voice",
"saved": "TTS settings saved",
"saveFailed": "Failed to save TTS settings",
"playing": "Playing test audio",
"testFailed": "Test failed — could not reach TTS server"
},
"smtp": {
"mailhog": "MailHog (local testing)",
"resend": "Resend",
"custom": "Custom SMTP",
"apiKey": "API Key",
"host": "Host",
"port": "Port",
"username": "Username",
"password": "Password",
"tls": "TLS / SSL",
"fromName": "From Name",
"fromEmail": "From Email",
"sendTest": "Send Test Email",
"userExample": "user@example.com",
"recipientExample": "recipient@example.com",
"saved": "SMTP settings saved",
"saveFailed": "Failed to save SMTP settings",
"connected": "Connection successful",
"testSent": "Test email sent to {{email}}"
},
"maintenance": {
"updateTitle": "Update the platform",
"updateHint": "Changes nothing that is running. Whatever the update needs is listed here, and stays your call.",
"rebuild": "Rebuild the frontend",
"restart": "Restart the server",
"upToDate": "Already up to date",
"rebuilt": "Frontend rebuilt — refresh this page to see it",
"restarting": "Restarting — the page will reconnect on its own",
"pulled": {
"one": "Pulled {{count}} commit",
"other": "Pulled {{count}} commits"
}
},
"harnesses": {
"piMono": "Pi Mono",
"checking": "Checking…",
"localProviders": "Local Providers",
"remoteProviders": "Remote Providers",
"connect": "Connect",
"authenticate": "Authenticate",
"connectLocal": "Connect Local Provider",
"connectProvider": "Connect Provider",
"none": "No providers found.",
"updatePiMono": "Update Pi Mono",
"removeProvider": "Remove provider",
"namePlaceholder": "Name (e.g. My Ollama)",
"username": "Username",
"password": "Password",
"apiKey": "API Key",
"editKey": "Edit key",
"disconnect": "Disconnect provider",
"saveKey": "Save key",
"enterKey": "Enter API key",
"search": "Search providers…",
"connectFailed": "Failed to connect",
"authFailed": "Authentication failed",
"saveFailed": "Failed to save provider",
"connectedTo": "Connected to {{name}}"
}
},
"users": {
"loadingPermissions": "Loading permissions…",
"permissionsFailed": "Could not load permissions.",
"deniedUnlessGranted": "Everything is denied unless granted here.",
"read": "Read",
"full": "Full",
"noAccess": "No access",
"role": "Role",
"permissionsSaved": "Saved what {{role}}s can reach",
"copyBeforeClosing": "Copy what you need before closing — none of it can be shown again.",
"password": "Password",
"linuxAccount": "Linux account",
"sshKey": "Their SSH public key",
"addToGitea": "Add this to their Gitea account",
"linuxIncomplete": "The account works, but its Linux side did not finish",
"done": "Done",
"addAccount": "Add account",
"newAccount": "New account",
"email": "Email",
"name": "Name",
"username": "Username",
"usernameHint": "Letters, numbers, dots, hyphens and underscores.",
"generatePassword": "Generate a password",
"copyPassword": "Copy the password",
"passwordHint": "At least 12 characters, with upper and lower case, a number and a symbol.",
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@laptop",
"passwordCopied": "Password copied",
"loadingAccounts": "Loading accounts…",
"accountsFailed": "Could not load the accounts.",
"removeAccount": "Remove account",
"generateNewPassword": "Generate a new password",
"serverOwner": "Server owner",
"copySshTitle": "Copy their SSH public key (add it to their Gitea account)",
"resetPasswordTitle": "Generate a new password — shown once, and signs them out everywhere",
"copyNewPassword": "Copy the new password",
"publicKeyCopied": "Public key copied",
"copyFailed": "Could not copy — select it and copy by hand",
"repairFor": "Repair {{email}}’s Linux account",
"linuxAccountFor": "Linux account: {{osUser}} — click to repair or replace their SSH key",
"copySshFor": "Copy {{email}}’s SSH public key",
"resetPasswordFor": "Reset {{email}}’s password",
"removeFor": "Remove {{email}}",
"provisionPrompt": "Linux account for {{email}}.\n\nPaste an SSH public key to allow them to SSH in, or leave empty to keep the current one.",
"createLinux": "Create a Linux account",
"noLinux": "No Linux account — click to create one",
"removeTitle": "Remove {{email}}?",
"removeBody": "This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, and anything an installed plugin keeps for them. It cannot be undone.",
"resetTitle": "Reset the password for {{email}}?",
"resetBody": "A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up afterwards. Their existing password stops working immediately, and they are signed out everywhere.",
"resetLinuxNote": "Their Linux account ({{osUser}}) is not affected: it has no password, and SSH keys are unchanged."
},
"dav": {
"newDevice": "New device",
"create": "Create",
"none": "No devices yet.",
"placeholder": "Pixel 9, iPad, Thunderbird…",
"revoke": "Revoke",
"delete": "Delete",
"copyFailed": "Could not copy — select and copy manually",
"createFailed": "Could not create the app password",
"revoked": "Revoked — that device will stop syncing",
"revokeFailed": "Could not revoke",
"deleteFailed": "Could not delete",
"copyField": "Copy {{field}}",
"copied": "{{what}} copied"
},
"google": {
"guideToggle": "Step-by-step setup guide",
"step1": {
"title": "Create a Google Cloud project",
"body": "Open the project creation page, give it a name (for example “Officer”), and click:",
"link": "New Project"
},
"step2": {
"title": "Enable the APIs",
"body": "Open the API library and search for each of these, then click:",
"link": "API Library"
},
"step3": {
"title": "Configure the OAuth consent screen",
"link": "OAuth Branding",
"appName": "Set your organization name, or “Officer”, as:",
"supportEmail": "Set your admin email as:",
"devContact": "Add your admin email under:",
"save": "Then click:"
},
"step4": {
"title": "Set the audience",
"link": "OAuth Audience",
"internal": "If your team uses Google Workspace, choose — no verification needed:",
"external": "Otherwise choose, and add your team’s emails under Test users (required while the app is unverified; limit of 100 test users):"
},
"step5": {
"title": "Add scopes",
"link": "Data Access",
"body": "Open data access and search for and add the scopes below, then click:",
"note": "Google classifies the calendar scope as sensitive and the Gmail scope as restricted. That is fine for Internal apps (Google Workspace) and for External apps in testing mode; publishing to production with restricted scopes requires Google verification."
},
"step6": {
"title": "Create OAuth credentials",
"link": "Clients",
"body": "Open the clients page and click:",
"appType": "Application type:",
"nameAnything": "Name: anything (for example “Officer”)",
"redirectUris": "Authorized redirect URIs — add:",
"thenClick": "Then click:"
},
"step7": {
"title": "Copy the credentials",
"body": "A dialog shows your client ID and client secret. Copy both and paste them into the fields below."
},
"scopeGmail": "Read emails",
"scopeCalendar": "Read calendar events",
"verifying": "Verifying credentials…",
"valid": "Credentials valid",
"invalid": "Invalid credentials",
"verifyFailed": "Verification request failed",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"redirectUri": "Redirect URI",
"redirectUriHint": "Add this URI to your Google OAuth client’s authorized redirect URIs",
"saved": "Google OAuth configuration saved",
"saveFailed": "Failed to save Google OAuth configuration"
},
"googleAccount": {
"title": "Google Account",
"hint": "Connect your Google account for Calendar, Contacts, and other Google services.",
"connected": "Connected",
"disconnect": "Disconnect",
"connect": "Connect Google Account",
"connectedToast": "Google account connected",
"connectFailed": "Failed to connect Google account",
"disconnectedToast": "Google account disconnected",
"disconnectFailed": "Failed to disconnect Google account"
},
"apify": {
"token": "API Token",
"saved": "Apify API token saved",
"saveFailed": "Failed to save Apify API token"
}
},
"header": {
"userMenu": {
"profile": "Profile",
"systemSettings": "System Settings",
"signOut": "Sign Out",
"ai": "AI",
"integrations": "Integrations",
"userManagement": "User Management"
},
"tabName": "Tab name",
"tabNameHint": "Click to rename this tab — clear it to go back to the page name",
"logoAlt": "Officer",
"navigation": "Navigation",
"jobsRunning": "Running job",
"jobsNothingRunning": "Nothing running",
"jobsQueued": "Queued jobs"
},
"nav": {
"home": "Home",
"files": "Files",
"chat": "Chat",
"commandHq": "Command HQ",
"editor": "Editor",
"jobs": "Jobs",
"terminal": "Terminal",
"browser": "Browser",
"monitor": "Monitor",
"activity": "Activity",
"dashboards": "Dashboards",
"sendOff": "SendOff",
"calendar": "Calendar",
"contacts": "Contacts",
"appStore": "App store",
"marketplace": "Marketplace",
"codeEditor": "Code Editor"
},
"theme": {
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}",
"pillLabel": "Theme: {{name}}",
"star": "star",
"unstar": "unstar",
"cycleHint": "ctrl+shift+←/→ cycle · ctrl+shift+space browse",
"everyShortcutHint": "ctrl+? for every shortcut",
"favouritesOnly": "Favourites only ({{count}})",
"favourites": "Favourites ({{count}})",
"noFavourites": "No favourites yet — star one with the button on its row.",
"starNamed": "Star {{name}}",
"unstarNamed": "Unstar {{name}}",
"addToFavourites": "Add to favourites",
"removeFromFavourites": "Remove from favourites",
"addToFavouritesKey": "Add to favourites (f)",
"removeFromFavouritesKey": "Remove from favourites (f)",
"active": "active",
"browseHint": "j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel"
},
"spotlight": {
"placeholder": "Go to…",
"noResults": "Nothing matches “{{query}}”."
},
"shortcutsModal": {
"title": "Keyboard shortcuts",
"hint": "Ctrl+? · Esc to close"
},
"screenError": {
"title": "This screen failed to render",
"description": "Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are untouched.",
"unknownError": "Unknown error",
"tryAgain": "Try again",
"reload": "Reload",
"goHome": "Go home",
"resetHint": "If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen’s panes back to their defaults.",
"dashboardsSafe": "Your dashboards are not touched.",
"resetting": "Resetting…",
"resetConfirm": "Yes — reset every screen layout",
"reset": "Reset screen layouts"
},
"rescan": {
"label": "Rescan items",
"title": "Rescan officer-items (apply changes without restarting)",
"failed": "Failed to rescan items",
"done": "Rescanned items — {{summary}}",
"counts": {
"tasks": {
"one": "{{count}} task",
"other": "{{count}} tasks"
},
"agents": {
"one": "{{count}} agent",
"other": "{{count}} agents"
},
"tools": {
"one": "{{count}} tool",
"other": "{{count}} tools"
},
"skills": {
"one": "{{count}} skill",
"other": "{{count}} skills"
},
"processes": {
"one": "{{count}} process",
"other": "{{count}} processes"
},
"affordances": {
"one": "{{count}} affordance",
"other": "{{count}} affordances"
}
},
"newChatsOnly": "Applies to new chats — an open one keeps what it started with.",
"noDescription": {
"one": "{{count}} skill has no description, so no agent is offered it.",
"other": "{{count}} skills have no description, so no agent is offered them."
}
},
"bugReport": {
"label": "Report a bug",
"title": "Report a Bug",
"describePlaceholder": "Describe what went wrong...",
"screenshot": "Screenshot",
"noScreenshot": "No screenshot captured",
"collectedNote": "Console logs, browser info, and recent API errors are collected automatically.",
"submitted": "Bug report submitted",
"failed": "Failed to submit bug report",
"submit": "Submit"
},
"auth": {
"signInTitle": "Welcome Back",
"signInSubtitle": "Sign in to your account",
"emailOrUsername": "Email or username",
"password": "Password",
"forgotPassword": "Forgot password?",
"emailPlaceholder": "you@example.com",
"passwordPlaceholder": "Your password",
"forgotTitle": "Forgot Password",
"forgotSubtitle": "Enter your email to receive a reset link",
"email": "Email",
"backToLogin": "Back to login",
"checkEmail": "Check your email",
"checkEmailHint": "Click it to reset your password.",
"resetOk": "Password reset successfully. Please sign in.",
"verifying": "Verifying…",
"invalidLink": "Invalid Link",
"invalidLinkHint": "This reset link is invalid or has expired.",
"resetTitle": "Reset Password",
"resetSubtitle": "Enter your new password",
"confirmPassword": "Confirm Password",
"minChars": "Min 12 characters",
"confirmPlaceholder": "Confirm your password",
"bootstrapTitle": "Welcome, Admin",
"bootstrapSubtitle": "Create your administrator account",
"name": "Name",
"username": "Username",
"namePlaceholder": "Your name",
"adminEmailPlaceholder": "admin@example.com",
"bootstrapOk": "Administrator account created. Please sign in.",
"debug": {
"controls": "Controls:",
"camera": "Camera:",
"model": "Model:"
}
},
"shortcuts": {
"navigation": "Navigation",
"spotlight": "Spotlight — go to any screen, plugin or setting by name",
"thisList": "This list",
"themes": "Themes",
"themesNote": "The browser applies as you move, so you watch the app change rather than reading names.",
"openBrowser": "Open the theme browser",
"prevNextTheme": "Previous / next theme, without opening anything",
"starCurrent": "Star or unstar the current theme, from anywhere",
"inBrowser": "In the theme browser",
"moveList": "Move through the list",
"applyKeepBrowsing": "Apply the focused theme and keep browsing",
"restoreStart": "Put back the theme you started on",
"starFocused": "Star or unstar the focused theme",
"keepLooking": "Keep what you are looking at",
"cancelRestore": "Cancel and restore",
"inSpotlight": "In Spotlight",
"moveResults": "Move through the results",
"goThere": "Go there",
"close": "Close",
"chat": "Chat",
"chatNote": "Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.",
"prevNextPrompt": "Previous / next prompt you sent, shell-style",
"send": "Send · on an empty composer with something queued, send that now",
"newline": "Newline",
"stopTurn": "Stop the turn that is generating"
},
"jobs": {
"runningQueued": "Running & Queued",
"loading": "Loading…",
"nothingRunning": "Nothing running",
"history": "History",
"clearAll": "Clear all",
"selectJob": "Select a job",
"notFound": "Job not found.",
"backToJobs": "Back to jobs",
"stop": "Stop",
"testNotification": "Send a test notification to every configured channel",
"search": "Search…",
"deleteFinished": "Delete all finished jobs",
"noChannels": "No channels configured",
"notifyFailed": "Failed to send notification",
"stopRequested": "Stop requested",
"stopFailed": "Failed to stop",
"titles": "Titles",
"download": "Download",
"input": "Input",
"output": "Output",
"steps": "Steps",
"selectStep": "Select a step or sub-task to view output",
"noOutput": "No output yet",
"tokens": "Tokens:",
"cost": "Cost:",
"status": {
"running": "Running",
"queued": "Queued",
"completed": "Completed",
"failed": "Failed",
"stopped": "Stopped",
"interrupted": "Interrupted"
},
"failedWithCode": "Failed (exit {{code}})"
},
"activity": {
"title": "Activity",
"backgroundTasks": "Background tasks",
"detached": "Detached",
"backToList": "Back to the list",
"selectTask": "Select a task to follow its live output",
"noneRunning": "none running"
},
"passkeyGate": {
"setUp": "Set up your passkey"
},
"chatHistory": {
"loadFailed": "Could not load this conversation: {{reason}}"
},
"capability": {
"frontmatter": "Frontmatter",
"noMatches": "No matches",
"emptyFile": "Empty file",
"createFailed": "Could not create the {{kind}}",
"deleteFailed": "Could not delete the {{kind}}",
"searchPlaceholder": "Search {{kind}}…",
"selectOne": "Select a {{kind}}",
"selectToView": "Select a {{kind}} to view its contents",
"delete": "Delete",
"createPrompt": "Help me create the content for this new {{kind}} file",
"improvePrompt": "Help me understand and improve this {{kind}} file"
}
}
-68
View File
@@ -1,68 +0,0 @@
{
"common": {
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel"
},
"settings": {
"profile": {
"searchPlaceholder": "Search settings...",
"sections": {
"profileTitle": "Profile",
"profileDescription": "Update your name and avatar.",
"changePasswordTitle": "Change Password",
"changePasswordDescription": "Update your account password.",
"tasksTitle": "Tasks",
"tasksDescription": "Default model for file browser tasks.",
"languagesTitle": "Languages",
"languagesDescription": "Set your spoken languages and translation preferences."
},
"userData": {
"emailLabel": "Email",
"nameLabel": "Name",
"namePlaceholder": "Your name",
"profileUpdated": "Profile updated",
"profileUpdateFailed": "Failed to update profile",
"selectImageFile": "Please select an image file",
"imageTooLarge": "Image must be smaller than 384KB"
},
"changePassword": {
"currentPasswordLabel": "Current Password",
"currentPasswordPlaceholder": "Current password",
"newPasswordLabel": "New Password",
"newPasswordPlaceholder": "New password",
"confirmPasswordLabel": "Confirm New Password",
"confirmPasswordPlaceholder": "Confirm new password",
"changeButton": "Change Password",
"changingButton": "Changing...",
"passwordChanged": "Password changed",
"passwordChangeFailed": "Failed to change password"
},
"languages": {
"spokenLabel": "Languages you speak",
"addLanguage": "Add a language...",
"defaultLabel": "Default language",
"defaultHint": "Used for UI localization.",
"translateToLabel": "Translate to",
"translateToHint": "Target language when translating content you don't speak."
}
}
},
"header": {
"userMenu": {
"profile": "Profile",
"systemSettings": "System Settings",
"signOut": "Sign Out"
}
},
"dock": {
"chat": "Chat",
"files": "Files",
"terminal": "Terminal",
"plans": "Plans",
"skills": "Skills",
"tasks": "Tasks",
"processes": "Processes",
"logs": "Logs"
}
}
@@ -0,0 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { findMissingKeys } from 'i18n';
import en from './en-GB.json';
import ptPT from './pt-PT.json';
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
// not a warning — there is no regional fallback to paper over the gap.
describe('officer-web (app.*) locale catalogs', () => {
test('pt-PT has every key en-GB has', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
+704
View File
@@ -0,0 +1,704 @@
{
"common": {
"save": "Guardar",
"saving": "A guardar...",
"cancel": "Cancelar"
},
"settings": {
"profile": {
"userData": {
"email": "Email",
"username": "Nome de utilizador",
"name": "Nome",
"namePlaceholder": "O seu nome",
"updated": "Perfil atualizado",
"updateFailed": "Falha ao atualizar o perfil",
"selectImage": "Selecione um ficheiro de imagem",
"imageTooLarge": "A imagem tem de ser menor do que 384KB"
},
"apiKeys": {
"intro": "Uma chave de API inicia sessão como você, sem palavra-passe e sem expirar. Uma chave funciona em todo o lado — uma chave não está associada a uma aplicação. Dar a cada dispositivo a sua própria chave serve apenas para poder cortar uma sem mexer nas restantes. Envie-a como",
"loading": "A carregar chaves…",
"mintedTitle": "Chave para “{{name}}” — mostrada uma única vez",
"savedIt": "Já a guardei",
"copyKey": "Copiar chave",
"copied": "Chave copiada",
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
"newKey": "Nova chave",
"namePlaceholder": "iPhone, aplicação Música, CLI do portátil…",
"create": "Criar",
"createFailed": "Não foi possível criar a chave",
"noneYet": "Ainda não há chaves.",
"noneInUse": "Nenhuma chave em uso.",
"revoked": "revogada",
"expired": "expirada",
"lastUsed": "usada pela última vez em {{date}}",
"neverUsed": "nunca usada",
"forget": "Remover este registo",
"forgetFailed": "Não foi possível remover",
"revoke": "Revogar",
"revokeDone": "Revogada — tudo o que usava essa chave foi desligado",
"revokeFailed": "Não foi possível revogar",
"hideDead": "Ocultar revogadas e expiradas",
"showDead": {
"one": "Mostrar {{count}} chave revogada ou expirada",
"other": "Mostrar {{count}} chaves revogadas ou expiradas"
}
},
"voice": {
"label": "Voz",
"refresh": "Atualizar vozes",
"serverDefault": "Predefinição do servidor",
"hint": "Escolha uma voz para a conversão de texto em fala. Deixe na predefinição do servidor para usar a voz configurada pelo administrador.",
"stop": "Parar",
"listen": "Ouvir"
},
"dock": {
"visible": "Visíveis",
"hidden": "Ocultos",
"dragToShow": "Arraste itens para aqui para os mostrar no dock",
"allVisible": "Todos os itens visíveis",
"reset": "Restaurar predefinições"
},
"languages": {
"spokenLabel": "Idiomas que falas",
"addLanguage": "Adicionar idioma...",
"defaultLabel": "Idioma predefinido",
"defaultHint": "Usado para localização da interface.",
"translateToLabel": "Traduzir para",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
},
"changePassword": {
"currentPasswordLabel": "Palavra-passe Atual",
"currentPasswordPlaceholder": "Palavra-passe atual",
"newPasswordLabel": "Nova Palavra-passe",
"newPasswordPlaceholder": "Nova palavra-passe",
"confirmPasswordLabel": "Confirmar Nova Palavra-passe",
"confirmPasswordPlaceholder": "Confirmar nova palavra-passe",
"changeButton": "Alterar Palavra-passe",
"changingButton": "A alterar...",
"passwordChanged": "Palavra-passe alterada",
"passwordChangeFailed": "Falha ao alterar palavra-passe"
},
"models": {
"defaultModel": "Modelo predefinido",
"sameAsChat": "Igual à predefinição do chat",
"taskDefaultsSaved": "Predefinições de tarefas guardadas",
"saveFailed": "Falha ao guardar as definições",
"defaultChat": "Modelo predefinido do chat",
"defaultChatHint": "Usado ao iniciar um novo chat a partir do ecrã inicial",
"defaultProject": "Modelo predefinido do projeto",
"defaultProjectHint": "Usado ao iniciar um novo chat dentro de um painel de projeto",
"defaultTask": "Modelo predefinido das tarefas",
"defaultTaskHint": "Usado ao executar tarefas a partir do explorador de ficheiros",
"aiDefaultsSaved": "Predefinições de modelos de IA guardadas"
}
},
"sections": {
"profile": {
"label": "Perfil",
"hint": "avatar, nome, email",
"description": "Avatar, nome e email"
},
"changePassword": {
"label": "Alterar palavra-passe",
"description": "Atualize a sua palavra-passe"
},
"apiKeys": {
"label": "Chaves de API",
"hint": "chaves que iniciam sessão como você",
"description": "Chaves que iniciam sessão como você, revogáveis uma a uma"
},
"languages": {
"label": "Idiomas",
"hint": "falados e tradução",
"description": "Falados, predefinido e tradução"
},
"voice": {
"label": "Voz",
"hint": "conversão de texto em fala",
"description": "Preferência de voz para conversão de texto em fala"
},
"dock": {
"label": "Dock",
"hint": "escolher e reordenar",
"description": "Escolher e reordenar os itens do dock"
},
"myModels": {
"label": "Os meus modelos",
"description": "Mostrar ou ocultar modelos para si"
},
"channelModels": {
"label": "Modelos do canal",
"description": "Modelos acessíveis a contas que não são do proprietário"
},
"defaultModels": {
"label": "Modelos predefinidos",
"description": "Modelos predefinidos para chat, projetos e tarefas"
},
"providers": {
"label": "Fornecedores",
"hint": "harnesses",
"description": "Fornecedores de IA remotos e locais"
},
"tts": {
"label": "Texto para fala",
"description": "Fornecedor de TTS e voz"
},
"stt": {
"label": "Fala para texto",
"description": "URL do servidor Whisper"
},
"ocr": {
"label": "OCR",
"description": "Modelo de visão para extração de texto"
},
"maintenance": {
"label": "Reconstruir e reiniciar",
"hint": "manutenção",
"description": "Carregar alterações sem um terminal"
},
"smtp": {
"label": "SMTP",
"hint": "correio a enviar",
"description": "Fornecedor de entrega de email"
},
"googleOauth": {
"label": "Google OAuth",
"description": "Client ID e secret para as APIs Google"
},
"apify": {
"label": "Apify",
"description": "Token de API para atores de web scraping"
},
"davSync": {
"label": "Sincronização de Calendário e Contactos",
"hint": "palavras-passe de aplicação",
"description": "Palavras-passe por dispositivo para clientes CalDAV/CardDAV"
},
"accounts": {
"label": "Contas",
"hint": "utilizadores",
"description": "Quem tem acesso, e como"
},
"permissions": {
"label": "Permissões",
"description": "O que cada função pode alcançar"
}
},
"searchPlaceholder": "Pesquisar definições…",
"selectSection": "Selecione uma secção",
"groups": {
"models": "Modelos",
"defaults": "Predefinições",
"providers": "Fornecedores",
"ai": "IA",
"maintenance": "Manutenção",
"email": "Email"
},
"nav": {
"profile": "Perfil",
"ai": "IA",
"system": "Sistema",
"integrations": "Integrações",
"users": "Utilizadores"
},
"ai": {
"noModels": "Nenhum modelo disponível. Configure as chaves de API em Fornecedores.",
"dragToShow": "Arraste modelos para aqui para os mostrar",
"allVisible": "Todos os modelos visíveis",
"dragToEnable": "Arraste modelos para aqui para os ativar",
"allEnabled": "Todos os modelos ativados",
"visible": "Visíveis",
"hidden": "Ocultos",
"enabled": "Ativados",
"disabled": "Desativados",
"refresh": "Atualizar modelos a partir dos fornecedores",
"refreshed": "Modelos atualizados",
"refreshFailed": "Falha ao atualizar modelos"
},
"integrations": {
"enterprise": "Empresa",
"personal": "Pessoal"
},
"server": {
"loading": "A carregar…",
"provider": "Fornecedor",
"model": "Modelo",
"voice": "Voz",
"ocr": {
"serverUrl": "URL do servidor do modelo de visão",
"refreshModels": "Atualizar modelos",
"selectModel": "Selecione um modelo",
"saved": "Definições de OCR guardadas",
"saveFailed": "Falha ao guardar as definições de OCR",
"reachable": "O servidor do modelo de visão está acessível"
},
"stt": {
"serverUrl": "URL do servidor Whisper",
"saved": "Definições de STT guardadas",
"saveFailed": "Falha ao guardar as definições de STT",
"reachable": "O servidor Whisper está acessível"
},
"tts": {
"openaiCompatible": "Compatível com OpenAI (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
"refreshVoices": "Atualizar vozes",
"selectVoice": "Selecione uma voz",
"saved": "Definições de TTS guardadas",
"saveFailed": "Falha ao guardar as definições de TTS",
"playing": "A reproduzir áudio de teste",
"testFailed": "Teste falhou — não foi possível contactar o servidor de TTS"
},
"smtp": {
"mailhog": "MailHog (testes locais)",
"resend": "Resend",
"custom": "SMTP personalizado",
"apiKey": "Chave de API",
"host": "Servidor",
"port": "Porta",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"tls": "TLS / SSL",
"fromName": "Nome do remetente",
"fromEmail": "Email do remetente",
"sendTest": "Enviar email de teste",
"userExample": "utilizador@exemplo.com",
"recipientExample": "destinatario@exemplo.com",
"saved": "Definições de SMTP guardadas",
"saveFailed": "Falha ao guardar as definições de SMTP",
"connected": "Ligação estabelecida",
"testSent": "Email de teste enviado para {{email}}"
},
"maintenance": {
"updateTitle": "Atualizar a plataforma",
"updateHint": "Não altera nada que esteja em execução. O que a atualização precisa está listado aqui, e a decisão é sua.",
"rebuild": "Reconstruir o frontend",
"restart": "Reiniciar o servidor",
"upToDate": "Já está atualizado",
"rebuilt": "Frontend reconstruído — atualize esta página para o ver",
"restarting": "A reiniciar — a página volta a ligar-se sozinha",
"pulled": {
"one": "{{count}} commit obtido",
"other": "{{count}} commits obtidos"
}
},
"harnesses": {
"piMono": "Pi Mono",
"checking": "A verificar…",
"localProviders": "Fornecedores locais",
"remoteProviders": "Fornecedores remotos",
"connect": "Ligar",
"authenticate": "Autenticar",
"connectLocal": "Ligar fornecedor local",
"connectProvider": "Ligar fornecedor",
"none": "Nenhum fornecedor encontrado.",
"updatePiMono": "Atualizar Pi Mono",
"removeProvider": "Remover fornecedor",
"namePlaceholder": "Nome (ex. O meu Ollama)",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"apiKey": "Chave de API",
"editKey": "Editar chave",
"disconnect": "Desligar fornecedor",
"saveKey": "Guardar chave",
"enterKey": "Introduza a chave de API",
"search": "Pesquisar fornecedores…",
"connectFailed": "Falha ao ligar",
"authFailed": "Falha na autenticação",
"saveFailed": "Falha ao guardar o fornecedor",
"connectedTo": "Ligado a {{name}}"
}
},
"users": {
"loadingPermissions": "A carregar permissões…",
"permissionsFailed": "Não foi possível carregar as permissões.",
"deniedUnlessGranted": "Tudo é negado a menos que seja concedido aqui.",
"read": "Leitura",
"full": "Total",
"noAccess": "Sem acesso",
"role": "Função",
"permissionsSaved": "Guardado o que os {{role}} podem alcançar",
"copyBeforeClosing": "Copie o que precisa antes de fechar — nada disto pode ser mostrado outra vez.",
"password": "Palavra-passe",
"linuxAccount": "Conta Linux",
"sshKey": "A sua chave pública SSH",
"addToGitea": "Adicione isto à conta Gitea dele",
"linuxIncomplete": "A conta funciona, mas a parte Linux não ficou concluída",
"done": "Concluído",
"addAccount": "Adicionar conta",
"newAccount": "Nova conta",
"email": "Email",
"name": "Nome",
"username": "Nome de utilizador",
"usernameHint": "Letras, números, pontos, hífenes e underscores.",
"generatePassword": "Gerar uma palavra-passe",
"copyPassword": "Copiar a palavra-passe",
"passwordHint": "Pelo menos 12 caracteres, com maiúsculas e minúsculas, um número e um símbolo.",
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@portatil",
"passwordCopied": "Palavra-passe copiada",
"loadingAccounts": "A carregar contas…",
"accountsFailed": "Não foi possível carregar as contas.",
"removeAccount": "Remover conta",
"generateNewPassword": "Gerar uma nova palavra-passe",
"serverOwner": "Proprietário do servidor",
"copySshTitle": "Copiar a sua chave pública SSH (adicione-a à conta Gitea dele)",
"resetPasswordTitle": "Gerar uma nova palavra-passe — mostrada uma única vez, e termina a sessão em todo o lado",
"copyNewPassword": "Copiar a nova palavra-passe",
"publicKeyCopied": "Chave pública copiada",
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
"repairFor": "Reparar a conta Linux de {{email}}",
"linuxAccountFor": "Conta Linux: {{osUser}} — clique para reparar ou substituir a chave SSH",
"copySshFor": "Copiar a chave pública SSH de {{email}}",
"resetPasswordFor": "Redefinir a palavra-passe de {{email}}",
"removeFor": "Remover {{email}}",
"provisionPrompt": "Conta Linux para {{email}}.\n\nCole uma chave pública SSH para lhe permitir aceder por SSH, ou deixe vazio para manter a atual.",
"createLinux": "Criar uma conta Linux",
"noLinux": "Sem conta Linux — clique para criar uma",
"removeTitle": "Remover {{email}}?",
"removeBody": "Isto elimina a conta e tudo o que lhe pertence — passkeys, painéis, layouts guardados e tudo o que qualquer plugin instalado guarde para ela. Não pode ser desfeito.",
"resetTitle": "Redefinir a palavra-passe de {{email}}?",
"resetBody": "É gerada uma nova palavra-passe e mostrada uma única vez — não é guardada em nenhum lugar e não pode ser consultada depois. A palavra-passe atual deixa de funcionar imediatamente, e a sessão é terminada em todo o lado.",
"resetLinuxNote": "A conta Linux ({{osUser}}) não é afetada: não tem palavra-passe, e as chaves SSH ficam inalteradas."
},
"dav": {
"newDevice": "Novo dispositivo",
"create": "Criar",
"none": "Ainda não há dispositivos.",
"placeholder": "Pixel 9, iPad, Thunderbird…",
"revoke": "Revogar",
"delete": "Eliminar",
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
"createFailed": "Não foi possível criar a palavra-passe de aplicação",
"revoked": "Revogada — esse dispositivo deixa de sincronizar",
"revokeFailed": "Não foi possível revogar",
"deleteFailed": "Não foi possível eliminar",
"copyField": "Copiar {{field}}",
"copied": "{{what}} copiado"
},
"google": {
"guideToggle": "Guia de configuração passo a passo",
"step1": {
"title": "Criar um projeto Google Cloud",
"body": "Abra a página de criação de projeto, dê-lhe um nome (por exemplo “Officer”) e clique em:",
"link": "New Project"
},
"step2": {
"title": "Ativar as APIs",
"body": "Abra a biblioteca de APIs, procure cada uma destas e clique em:",
"link": "API Library"
},
"step3": {
"title": "Configurar o ecrã de consentimento OAuth",
"link": "OAuth Branding",
"appName": "Defina o nome da sua organização, ou “Officer”, como:",
"supportEmail": "Defina o email do administrador como:",
"devContact": "Adicione o email do administrador em:",
"save": "Depois clique em:"
},
"step4": {
"title": "Definir o público",
"link": "OAuth Audience",
"internal": "Se a sua equipa usa Google Workspace, escolha — não é necessária verificação:",
"external": "Caso contrário escolha, e adicione os emails da sua equipa em Test users (obrigatório enquanto a aplicação não estiver verificada; limite de 100 utilizadores de teste):"
},
"step5": {
"title": "Adicionar âmbitos",
"link": "Data Access",
"body": "Abra o acesso a dados, procure e adicione os âmbitos abaixo e depois clique em:",
"note": "A Google classifica o âmbito do calendário como sensível e o do Gmail como restrito. Isso não é problema para aplicações Internal (Google Workspace) nem para aplicações External em modo de teste; publicar em produção com âmbitos restritos exige verificação pela Google."
},
"step6": {
"title": "Criar credenciais OAuth",
"link": "Clients",
"body": "Abra a página de clientes e clique em:",
"appType": "Tipo de aplicação:",
"nameAnything": "Nome: qualquer um (por exemplo “Officer”)",
"redirectUris": "URIs de redirecionamento autorizados — adicione:",
"thenClick": "Depois clique em:"
},
"step7": {
"title": "Copiar as credenciais",
"body": "Uma caixa de diálogo mostra o seu client ID e client secret. Copie ambos e cole-os nos campos abaixo."
},
"scopeGmail": "Ler emails",
"scopeCalendar": "Ler eventos do calendário",
"verifying": "A verificar credenciais…",
"valid": "Credenciais válidas",
"invalid": "Credenciais inválidas",
"verifyFailed": "O pedido de verificação falhou",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"redirectUri": "URI de redirecionamento",
"redirectUriHint": "Adicione este URI aos URIs de redirecionamento autorizados do seu cliente OAuth da Google",
"saved": "Configuração OAuth da Google guardada",
"saveFailed": "Falha ao guardar a configuração OAuth da Google"
},
"googleAccount": {
"title": "Conta Google",
"hint": "Ligue a sua conta Google para o Calendário, Contactos e outros serviços Google.",
"connected": "Ligada",
"disconnect": "Desligar",
"connect": "Ligar conta Google",
"connectedToast": "Conta Google ligada",
"connectFailed": "Falha ao ligar a conta Google",
"disconnectedToast": "Conta Google desligada",
"disconnectFailed": "Falha ao desligar a conta Google"
},
"apify": {
"token": "Token de API",
"saved": "Token de API do Apify guardado",
"saveFailed": "Falha ao guardar o token de API do Apify"
}
},
"header": {
"userMenu": {
"profile": "Perfil",
"systemSettings": "Definições do Sistema",
"signOut": "Terminar Sessão",
"ai": "IA",
"integrations": "Integrações",
"userManagement": "Gestão de Utilizadores"
},
"tabName": "Nome do separador",
"tabNameHint": "Clique para renomear este separador — limpe-o para voltar ao nome da página",
"logoAlt": "Officer",
"navigation": "Navegação",
"jobsRunning": "Trabalho em execução",
"jobsNothingRunning": "Nada em execução",
"jobsQueued": "Trabalhos em fila"
},
"nav": {
"home": "Início",
"files": "Ficheiros",
"chat": "Chat",
"commandHq": "Command HQ",
"editor": "Editor",
"jobs": "Trabalhos",
"terminal": "Terminal",
"browser": "Navegador",
"monitor": "Monitor",
"activity": "Atividade",
"dashboards": "Painéis",
"sendOff": "SendOff",
"calendar": "Calendário",
"contacts": "Contactos",
"appStore": "Loja de aplicações",
"marketplace": "Marketplace",
"codeEditor": "Editor de código"
},
"theme": {
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
"pillLabel": "Tema: {{name}}",
"star": "marcar",
"unstar": "desmarcar",
"cycleHint": "ctrl+shift+←/→ percorrer · ctrl+shift+space explorar",
"everyShortcutHint": "ctrl+? para todos os atalhos",
"favouritesOnly": "Apenas favoritos ({{count}})",
"favourites": "Favoritos ({{count}})",
"noFavourites": "Ainda não há favoritos — marque um com o botão na sua linha.",
"starNamed": "Marcar {{name}}",
"unstarNamed": "Desmarcar {{name}}",
"addToFavourites": "Adicionar aos favoritos",
"removeFromFavourites": "Remover dos favoritos",
"addToFavouritesKey": "Adicionar aos favoritos (f)",
"removeFromFavouritesKey": "Remover dos favoritos (f)",
"active": "ativo",
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f marcar · h ou ← reiniciar · enter manter · esc cancelar"
},
"spotlight": {
"placeholder": "Ir para…",
"noResults": "Nada corresponde a “{{query}}”."
},
"shortcutsModal": {
"title": "Atalhos de teclado",
"hint": "Ctrl+? · Esc para fechar"
},
"screenError": {
"title": "Este ecrã não conseguiu ser apresentado",
"description": "Nada foi perdido — esta é uma falha de apresentação, não de dados. As transcrições do agente, os trabalhos e os ficheiros estão intactos.",
"unknownError": "Erro desconhecido",
"tryAgain": "Tentar novamente",
"reload": "Recarregar",
"goHome": "Ir para o início",
"resetHint": "Se falhar novamente no mesmo local, a causa habitual é um layout de painéis guardado. Reiniciar devolve os painéis de todos os ecrãs às predefinições.",
"dashboardsSafe": "Os seus painéis não são afetados.",
"resetting": "A reiniciar…",
"resetConfirm": "Sim — reiniciar o layout de todos os ecrãs",
"reset": "Reiniciar layouts dos ecrãs"
},
"rescan": {
"label": "Reanalisar itens",
"title": "Reanalisar officer-items (aplicar alterações sem reiniciar)",
"failed": "Falha ao reanalisar itens",
"done": "Itens reanalisados — {{summary}}",
"counts": {
"tasks": {
"one": "{{count}} tarefa",
"other": "{{count}} tarefas"
},
"agents": {
"one": "{{count}} agente",
"other": "{{count}} agentes"
},
"tools": {
"one": "{{count}} ferramenta",
"other": "{{count}} ferramentas"
},
"skills": {
"one": "{{count}} skill",
"other": "{{count}} skills"
},
"processes": {
"one": "{{count}} processo",
"other": "{{count}} processos"
},
"affordances": {
"one": "{{count}} affordance",
"other": "{{count}} affordances"
}
},
"newChatsOnly": "Aplica-se a novos chats — um chat aberto mantém o que tinha ao iniciar.",
"noDescription": {
"one": "{{count}} skill não tem descrição, pelo que não é oferecida a nenhum agente.",
"other": "{{count}} skills não têm descrição, pelo que não são oferecidas a nenhum agente."
}
},
"bugReport": {
"label": "Comunicar um erro",
"title": "Comunicar um erro",
"describePlaceholder": "Descreva o que aconteceu...",
"screenshot": "Captura de ecrã",
"noScreenshot": "Nenhuma captura de ecrã obtida",
"collectedNote": "Os registos da consola, a informação do navegador e os erros recentes da API são recolhidos automaticamente.",
"submitted": "Comunicação de erro enviada",
"failed": "Falha ao enviar a comunicação de erro",
"submit": "Enviar"
},
"auth": {
"signInTitle": "Bem-vindo de volta",
"signInSubtitle": "Inicie sessão na sua conta",
"emailOrUsername": "Email ou nome de utilizador",
"password": "Palavra-passe",
"forgotPassword": "Esqueceu-se da palavra-passe?",
"emailPlaceholder": "utilizador@exemplo.com",
"passwordPlaceholder": "A sua palavra-passe",
"forgotTitle": "Recuperar palavra-passe",
"forgotSubtitle": "Introduza o seu email para receber um link de recuperação",
"email": "Email",
"backToLogin": "Voltar ao início de sessão",
"checkEmail": "Verifique o seu email",
"checkEmailHint": "Clique nele para redefinir a palavra-passe.",
"resetOk": "Palavra-passe redefinida com sucesso. Inicie sessão.",
"verifying": "A verificar…",
"invalidLink": "Link inválido",
"invalidLinkHint": "Este link de recuperação é inválido ou expirou.",
"resetTitle": "Redefinir palavra-passe",
"resetSubtitle": "Introduza a sua nova palavra-passe",
"confirmPassword": "Confirmar palavra-passe",
"minChars": "Mínimo 12 caracteres",
"confirmPlaceholder": "Confirme a sua palavra-passe",
"bootstrapTitle": "Bem-vindo, Administrador",
"bootstrapSubtitle": "Crie a sua conta de administrador",
"name": "Nome",
"username": "Nome de utilizador",
"namePlaceholder": "O seu nome",
"adminEmailPlaceholder": "admin@exemplo.com",
"bootstrapOk": "Conta de administrador criada. Inicie sessão.",
"debug": {
"controls": "Controlos:",
"camera": "Câmara:",
"model": "Modelo:"
}
},
"shortcuts": {
"navigation": "Navegação",
"spotlight": "Spotlight — ir para qualquer ecrã, plugin ou definição pelo nome",
"thisList": "Esta lista",
"themes": "Temas",
"themesNote": "O explorador aplica à medida que se move, para que veja a aplicação mudar em vez de ler nomes.",
"openBrowser": "Abrir o explorador de temas",
"prevNextTheme": "Tema anterior / seguinte, sem abrir nada",
"starCurrent": "Marcar ou desmarcar o tema atual, a partir de qualquer lugar",
"inBrowser": "No explorador de temas",
"moveList": "Percorrer a lista",
"applyKeepBrowsing": "Aplicar o tema focado e continuar a explorar",
"restoreStart": "Voltar ao tema em que começou",
"starFocused": "Marcar ou desmarcar o tema focado",
"keepLooking": "Manter o que está a ver",
"cancelRestore": "Cancelar e restaurar",
"inSpotlight": "No Spotlight",
"moveResults": "Percorrer os resultados",
"goThere": "Ir para lá",
"close": "Fechar",
"chat": "Chat",
"chatNote": "A recuperação só usa a tecla de seta num compositor VAZIO, e qualquer outra tecla termina a recuperação — pelo que um rascunho nunca é substituído.",
"prevNextPrompt": "Prompt anterior / seguinte que enviou, à maneira da shell",
"send": "Enviar · num compositor vazio com algo em fila, enviar isso agora",
"newline": "Nova linha",
"stopTurn": "Parar o turno que está a gerar"
},
"jobs": {
"runningQueued": "Em execução e em fila",
"loading": "A carregar…",
"nothingRunning": "Nada em execução",
"history": "Histórico",
"clearAll": "Limpar tudo",
"selectJob": "Selecione um trabalho",
"notFound": "Trabalho não encontrado.",
"backToJobs": "Voltar aos trabalhos",
"stop": "Parar",
"testNotification": "Enviar uma notificação de teste para todos os canais configurados",
"search": "Pesquisar…",
"deleteFinished": "Eliminar todos os trabalhos terminados",
"noChannels": "Nenhum canal configurado",
"notifyFailed": "Falha ao enviar a notificação",
"stopRequested": "Paragem pedida",
"stopFailed": "Falha ao parar",
"titles": "Títulos",
"download": "Descarregar",
"input": "Entrada",
"output": "Saída",
"steps": "Passos",
"selectStep": "Selecione um passo ou subtarefa para ver a saída",
"noOutput": "Ainda não há saída",
"tokens": "Tokens:",
"cost": "Custo:",
"status": {
"running": "Em execução",
"queued": "Em fila",
"completed": "Concluído",
"failed": "Falhou",
"stopped": "Parado",
"interrupted": "Interrompido"
},
"failedWithCode": "Falhou (saída {{code}})"
},
"activity": {
"title": "Atividade",
"backgroundTasks": "Tarefas em segundo plano",
"detached": "Destacada",
"backToList": "Voltar à lista",
"selectTask": "Selecione uma tarefa para seguir a sua saída ao vivo",
"noneRunning": "nenhuma em execução"
},
"passkeyGate": {
"setUp": "Configure a sua passkey"
},
"chatHistory": {
"loadFailed": "Não foi possível carregar esta conversa: {{reason}}"
},
"capability": {
"frontmatter": "Frontmatter",
"noMatches": "Sem correspondências",
"emptyFile": "Ficheiro vazio",
"createFailed": "Não foi possível criar o {{kind}}",
"deleteFailed": "Não foi possível eliminar o {{kind}}",
"searchPlaceholder": "Pesquisar {{kind}}…",
"selectOne": "Selecione um {{kind}}",
"selectToView": "Selecione um {{kind}} para ver o seu conteúdo",
"delete": "Eliminar",
"createPrompt": "Ajuda-me a criar o conteúdo deste novo ficheiro de {{kind}}",
"improvePrompt": "Ajuda-me a compreender e melhorar este ficheiro de {{kind}}"
}
}
-68
View File
@@ -1,68 +0,0 @@
{
"common": {
"save": "Guardar",
"saving": "A guardar...",
"cancel": "Cancelar"
},
"settings": {
"profile": {
"searchPlaceholder": "Pesquisar definições...",
"sections": {
"profileTitle": "Perfil",
"profileDescription": "Atualiza o teu nome e avatar.",
"changePasswordTitle": "Alterar Palavra-passe",
"changePasswordDescription": "Atualiza a palavra-passe da tua conta.",
"tasksTitle": "Tarefas",
"tasksDescription": "Modelo predefinido para tarefas do explorador de ficheiros.",
"languagesTitle": "Idiomas",
"languagesDescription": "Define os teus idiomas e preferências de tradução."
},
"userData": {
"emailLabel": "Email",
"nameLabel": "Nome",
"namePlaceholder": "O teu nome",
"profileUpdated": "Perfil atualizado",
"profileUpdateFailed": "Falha ao atualizar perfil",
"selectImageFile": "Por favor seleciona um ficheiro de imagem",
"imageTooLarge": "A imagem deve ter menos de 384KB"
},
"changePassword": {
"currentPasswordLabel": "Palavra-passe Atual",
"currentPasswordPlaceholder": "Palavra-passe atual",
"newPasswordLabel": "Nova Palavra-passe",
"newPasswordPlaceholder": "Nova palavra-passe",
"confirmPasswordLabel": "Confirmar Nova Palavra-passe",
"confirmPasswordPlaceholder": "Confirmar nova palavra-passe",
"changeButton": "Alterar Palavra-passe",
"changingButton": "A alterar...",
"passwordChanged": "Palavra-passe alterada",
"passwordChangeFailed": "Falha ao alterar palavra-passe"
},
"languages": {
"spokenLabel": "Idiomas que falas",
"addLanguage": "Adicionar idioma...",
"defaultLabel": "Idioma predefinido",
"defaultHint": "Usado para localização da interface.",
"translateToLabel": "Traduzir para",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
}
}
},
"header": {
"userMenu": {
"profile": "Perfil",
"systemSettings": "Definições do Sistema",
"signOut": "Terminar Sessão"
}
},
"dock": {
"chat": "Chat",
"files": "Ficheiros",
"terminal": "Terminal",
"plans": "Planos",
"skills": "Skills",
"tasks": "Tarefas",
"processes": "Processos",
"logs": "Registos"
}
}
+35 -28
View File
@@ -1,3 +1,5 @@
import type { AppTranslationKey } from '@/lib/i18n';
/*
Every keyboard shortcut the shell owns, as data.
@@ -22,92 +24,97 @@
export type Shortcut = {
/** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
keys: string;
description: string;
/** A catalog key. This is data with no component around it, so ShortcutsModal resolves it. */
descriptionKey: AppTranslationKey;
/** The file whose handler actually implements it, until this registry is authoritative. */
implementedIn: string;
};
export type ShortcutSection = {
title: string;
titleKey: AppTranslationKey;
/** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
note?: string;
noteKey?: AppTranslationKey;
shortcuts: Shortcut[];
};
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{
title: 'Navigation',
titleKey: 'shortcuts.navigation',
shortcuts: [
{
keys: 'Shift+Space',
description: 'Spotlight — go to any screen, plugin or setting by name',
descriptionKey: 'shortcuts.spotlight',
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
},
{ keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' },
{ keys: 'Ctrl+?', descriptionKey: 'shortcuts.thisList', implementedIn: 'Layout/ShortcutsModal.tsx' },
],
},
{
title: 'Themes',
note: 'The browser applies as you move, so you watch the app change rather than reading names.',
titleKey: 'shortcuts.themes',
noteKey: 'shortcuts.themesNote',
shortcuts: [
{
keys: 'Ctrl+Shift+Space',
description: 'Open the theme browser',
descriptionKey: 'shortcuts.openBrowser',
implementedIn: 'theme/useThemeKeys.ts',
},
{
keys: 'Ctrl+Shift+← / →',
description: 'Previous / next theme, without opening anything',
descriptionKey: 'shortcuts.prevNextTheme',
implementedIn: 'theme/useThemeKeys.ts',
},
{
keys: 'Ctrl+Shift+F',
description: 'Star or unstar the current theme, from anywhere',
descriptionKey: 'shortcuts.starCurrent',
implementedIn: 'theme/useThemeKeys.ts',
},
],
},
{
title: 'In the theme browser',
titleKey: 'shortcuts.inBrowser',
shortcuts: [
{ keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'j / k ↑ / ↓', descriptionKey: 'shortcuts.moveList', implementedIn: 'theme/useThemeKeys.ts' },
{
keys: 'l →',
description: 'Apply the focused theme and keep browsing',
descriptionKey: 'shortcuts.applyKeepBrowsing',
implementedIn: 'theme/useThemeKeys.ts',
},
{ keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'h ←', descriptionKey: 'shortcuts.restoreStart', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'f', descriptionKey: 'shortcuts.starFocused', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Enter', descriptionKey: 'shortcuts.keepLooking', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Esc', descriptionKey: 'shortcuts.cancelRestore', implementedIn: 'theme/useThemeKeys.ts' },
],
},
{
title: 'In Spotlight',
titleKey: 'shortcuts.inSpotlight',
shortcuts: [
{ keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: '↑ / ↓', descriptionKey: 'shortcuts.moveResults', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Enter', descriptionKey: 'shortcuts.goThere', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Esc', descriptionKey: 'shortcuts.close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
],
},
{
title: 'Chat',
note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.',
titleKey: 'shortcuts.chat',
noteKey: 'shortcuts.chatNote',
shortcuts: [
{
keys: '↑ / ↓',
description: 'Previous / next prompt you sent, shell-style',
descriptionKey: 'shortcuts.prevNextPrompt',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
{
keys: 'Enter',
description: 'Send · on an empty composer with something queued, send that now',
descriptionKey: 'shortcuts.send',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
{
keys: 'Shift+Enter',
descriptionKey: 'shortcuts.newline',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
{ keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
{
keys: 'Esc',
description: 'Stop the turn that is generating',
descriptionKey: 'shortcuts.stopTurn',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
],
@@ -15,6 +15,7 @@ export {
disarmCommandHqWatcher,
recordCommandHqTurn,
ingestSpawnedChildren,
parentCommanderOf,
recordSpawnedOutcome,
getCommandHqCostSummary,
listCommandHqTurns,
@@ -475,3 +475,44 @@ export async function recordSpawnedOutcome(
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
);
}
export type SpawnParent = {
/** The commander's transcript id — what `/command-hq/<id>` opens. */
sessionId: string;
/** What the owner called it, or null for a commander filed before the column existed. */
name: string | null;
firstSeenAt: string;
finishedAt: string | null;
};
/**
* Which commander spawned this session, if any.
*
* The same row `ingestSpawnedChildren` writes, read from the other end — so this needed no new table and no
* new write path. A commander's children were already recorded because deleting one has to take them with
* it; that record answers "who is my parent" for free, and nothing was asking.
*
* Null for almost every session, and that is the normal case rather than a miss: a chat somebody opened
* themselves has no parent, and a session spawned by anything other than a commander has no row here. The
* caller must treat absence as "not spawned", never as "lookup failed".
*/
export async function parentCommanderOf(spawnedSessionId: string): Promise<SpawnParent | null> {
const [row] = await db
.select({
sessionId: commandHqSessions.sessionId,
name: commandHqSessions.name,
firstSeenAt: commandHqSpawned.firstSeenAt,
finishedAt: commandHqSpawned.finishedAt,
})
.from(commandHqSpawned)
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
.where(eq(commandHqSpawned.spawnedSessionId, spawnedSessionId))
.limit(1);
if (!row) return null;
return {
sessionId: row.sessionId,
name: row.name,
firstSeenAt: row.firstSeenAt.toISOString(),
finishedAt: row.finishedAt ? row.finishedAt.toISOString() : null,
};
}
+1
View File
@@ -41,6 +41,7 @@ export * from './anthropic-accounts';
export * from './theme-favorites';
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
export * from './sendoff';
export * from './roles';
export * from './command-hq';
export * from './dashboards';
export * from './integrations';
@@ -0,0 +1,2 @@
export { roles } from './schema';
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
@@ -0,0 +1,61 @@
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../auth/schema';
import { roles } from './schema';
export type RoleRow = {
name: string;
description: string | null;
isSystem: boolean;
provisioningScript: string | null;
createdAt: Date;
};
/**
* Every role, with how many accounts hold each.
*
* The count is here rather than in a second query because every caller needs it: a picker wants to show
* it, and the delete guard is defined in terms of it. One left join beats two round trips and a race
* between them.
*/
export async function listRoles(): Promise<(RoleRow & { userCount: number })[]> {
const rows = await db
.select({
name: roles.name,
description: roles.description,
isSystem: roles.isSystem,
provisioningScript: roles.provisioningScript,
createdAt: roles.createdAt,
userCount: sql<number>`count(${users.id})::int`,
})
.from(roles)
.leftJoin(users, eq(users.role, roles.name))
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
.orderBy(asc(roles.name));
return rows;
}
export async function getRole(name: string): Promise<RoleRow | undefined> {
const [row] = await db.select().from(roles).where(eq(roles.name, name)).limit(1);
return row;
}
/**
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
*
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
* deploy — so the seed establishes a row and never touches it again.
*/
export async function seedRole(row: {
name: string;
description?: string | null;
isSystem?: boolean;
}): Promise<boolean> {
const inserted = await db
.insert(roles)
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
.onConflictDoNothing({ target: roles.name })
.returning({ name: roles.name });
return inserted.length > 0;
}
@@ -0,0 +1,52 @@
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
/**
* The roles this server has, as data rather than a TypeScript union.
*
* ── Why the NAME is the primary key ──
*
* `users.role` and `role_permissions.role` both hold a role name today, and `authorize.ts` reads a name off
* the user to look up grants. Re-keying those onto a serial id would touch the authorization path — the one
* file standing between a Member and a shell — to buy nothing the name does not already give, because
* Postgres will cascade a rename for us. So both columns become foreign keys onto this one with
* `ON UPDATE CASCADE`: renaming a role moves every user and every grant with it, atomically, and no code
* changes.
*
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
*
* ── What is NOT here ──
*
* **Ownership.** The owner is `OWNER_USER_ID`, an identity, not a role — see `super-admin.ts`. Before this
* table existed, "who owns this server" was answered by `users.role === 'Super Admin'`, which meant that
* making roles editable would have made "add a role" and "become the owner" the same feature. Several
* accounts may now hold `Super Admin`; exactly one account is the owner, and no role grants it.
*
* **Permissions.** They stay in `role_permissions`, keyed on the name. A role row is the role's identity and
* its provisioning, not its grants.
*/
export const roles = pgTable('roles', {
/** The name, and the key. Referenced by `users.role` and `role_permissions.role` with ON UPDATE CASCADE. */
name: text('name').primaryKey(),
/** One line, for a picker. Never load-bearing — nothing branches on it. */
description: text('description'),
/**
* A role the platform's own code names, so it cannot be deleted or renamed.
*
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
* discovering it as a runtime failure somewhere else.
*/
isSystem: boolean('is_system').notNull().default(false),
/**
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
* becomes a row. Null today, and nothing reads it.
*
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
* column that arrives with the feature that uses it is a second migration for no gain.
*/
provisioningScript: text('provisioning_script'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
+1
View File
@@ -38,6 +38,7 @@ export * from './anthropic-accounts/schema'; // anthropic_accounts — switchabl
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
export * from './roles/schema'; // roles
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
+10
View File
@@ -13,6 +13,7 @@ import {
getChatNotifyPreferences,
setChatNotifyPreference,
forgetChatSessions,
parentCommanderOf,
} from 'officerdb';
import type { NotifyPreference } from 'officerdb/types';
import { setNudge, getNudge, cancelNudge } from 'officerdb';
@@ -273,6 +274,15 @@ chatRouter.get('/sessions/:id', async (ctx) => {
travels with the conversation.
*/
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
/*
Which commander spawned this, if any — the row `ingestSpawnedChildren` writes, read from the other
end. A spawned agent has always been fully readable here; what it could never say was who started
it, so opening one gave no way back to the conversation that owns it.
Null for almost every session and that is the normal case, not a miss: a chat somebody opened
themselves has no parent. Never fatal — a session must render when this lookup will not.
*/
spawnedBy: await parentCommanderOf(id).catch(() => null),
});
});
+133
View File
@@ -0,0 +1,133 @@
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import { chatIdentity } from '../chat/chat';
import { checkBudget } from './budget';
/*
POST /sub-agents/spawn — an agent asks Officer to run another agent.
── Why this exists rather than leaning on the harness ──
A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher
loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a
commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what
it started.
So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every
caller gets the same one, and none of it changes when a CLI does.
── The condition that comes before the feature ──
`checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are
spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the
last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's
conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a
floor: never spend the last 15%.
── What it is NOT ──
Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any
good is the caller's problem, and a caller that wants three tries writes three calls — because a retry
inside here would be a policy nobody chose, applied to work this endpoint cannot read.
*/
export const subAgentsRouter = createRouter();
const MAX_PROMPT = 100_000;
subAgentsRouter.post('/spawn', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean };
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`);
/*
Refused BEFORE anything is spawned, and the reason travels with the refusal.
A caller that gets "no room" needs to know which window and how close, or its only move is to try again
immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now",
not a "not you".
*/
const budget = await checkBudget();
if (!budget.ok) {
return ctx.json(
{ error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem },
429,
);
}
/*
The identity is resolved from the TOKEN, never from the body.
`chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same
resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to
run in.
*/
const who = await chatIdentity(user);
// Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first
// reports one, which is after this returns — see the note in the response below.
const sessionKey = crypto.randomUUID();
const spawn = {
userId: user.id,
email: who.email,
username: user.username ?? who.email.split('@')[0] ?? String(user.id),
prompt,
sessionKey,
...(body?.cwd ? { cwd: body.cwd } : {}),
...(body?.model ? { model: body.model } : {}),
/*
Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional
one let a call site forget it, and forgetting it means a member's agent silently running as the owner in
the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says
there is no Linux user to run as.
*/
member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined,
};
try {
/*
Two modes, and the default is the one a loop wants.
`wait: false` returns as soon as the agent is running, so a watcher can start work and get on with
watching. `wait: true` blocks for the answer, which is right for a question with a short answer and
wrong for anything that touches a fleet — the request would hold a socket open for the length of the
work.
*/
if (body?.wait === true) {
const result = await sidecar.spawnClaude(spawn);
return ctx.json({ sessionKey, result });
}
await sidecar.spawnClaudeStreaming(spawn);
/*
`sessionKey` and not a transcript id, because the transcript does not exist yet.
The agent announces its own uuid on its first turn, which is after this responds. A caller that needs
the transcript — to open `/chat/<id>`, or to record what it started — reads it from the live session
list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than
saying which id this is.
*/
return ctx.json({ sessionKey, running: true });
} catch (err) {
// The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it
// later is the correct response, which is a distinction a caller can act on.
// 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct
// response — a distinction the caller can act on.
throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent');
}
});
/**
* GET /sub-agents/budget — is there room to start one, without starting one.
*
* Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle
* than be refused mid-flight asks this first; the spawn checks again regardless, because between the two
* calls somebody else may have spent the window.
*/
subAgentsRouter.get('/budget', async (ctx) => {
const budget = await checkBudget();
return ctx.json(budget);
});
+2
View File
@@ -28,6 +28,7 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { minesweeperRouter } from './api/minesweeper/router';
import { sendoffRouter } from './api/sendoff/router';
import { subAgentsRouter } from './api/sub-agents/router';
import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router';
@@ -143,6 +144,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter],
['/sendoff', sendoffRouter],
['/sub-agents', subAgentsRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
+12 -1
View File
@@ -241,8 +241,19 @@ describe('kinds', () => {
// both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
//
// `sub-agents` joined on 2026-08-23, and it is confined IN FACT on the day it was added rather than ahead of
// itself. `POST /sub-agents/spawn` resolves the identity through `chatIdentity` from the caller's token and
// passes `member: { osUser, home }` to the sidecar — the same mechanism `chat` runs under, reached by the
// same resolver. It inherits the containment rather than claiming it: there is no path in that endpoint that
// chooses a directory, so there is nothing for a caller to point somewhere else.
test('confined is a short, deliberate list', () => {
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']);
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual([
'terminal',
'chat',
'files',
'sub-agents',
]);
});
test('admin permissions are never grantable', () => {
+18
View File
@@ -360,6 +360,24 @@ const CORE_REGISTRY: Permission[] = [
person who should decide whether somebody may put a file in your home is you, not whoever administers
roles. This grant only says the feature is reachable at all.
*/
/*
`confined`, and for the same reason chat is.
Spawning an agent runs a process in a home directory — the caller's own, resolved by `chatIdentity`
from their token. So it needs exactly what chat needs: a Linux account for a member, and nothing for
the owner, with the kernel doing the containing. `authorize.ts` drops a confined grant for an account
whose `osUser` is null, which is what stops "granted but unconfined" resolving to the owner's home.
Not `execution`: that kind exists for surfaces that can ONLY ever run as the owner. This one runs as
whoever called it, which is the whole distinction.
*/
{
key: 'sub-agents',
label: 'Sub-agents',
description: 'Start another agent from a prompt, subject to the account budget',
kind: 'confined',
api: ['/sub-agents'],
},
{
key: 'sendoff',
label: 'SendOff',
+7 -3
View File
@@ -1,11 +1,15 @@
import { Input } from '@/components/ui/input';
import { useTranslation } from '@/components/locales';
type ColorPickerProps = { value?: string; name: string; label?: string };
export const ColorPicker = (props: ColorPickerProps) => {
const { value = '#000000', name, label } = props;
const { t } = useTranslation();
return (
<div style={{ borderWidth: '1px' }} className="relative w-full rounded">
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>}
{label && (
<span className="text-muted-foreground absolute z-10 -mt-2 ms-5 bg-background px-2 text-xs">{label}</span>
)}
<div className="flex items-center justify-between">
<div className="h-12 overflow-hidden rounded-full p-0">
<Input
@@ -13,14 +17,14 @@ export const ColorPicker = (props: ColorPickerProps) => {
className="absolute h-12 w-24 rounded-full border-0 text-lg"
type="color"
name={name}
placeholder="Font Weight"
/>
</div>
<div>
<Input
name={name}
className="border-0 text-right text-base outline-none focus:outline-none"
className="border-0 text-end text-base outline-none focus:outline-none"
defaultValue={value}
placeholder={t('colorPicker.placeholder')}
/>
</div>
</div>
+5 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Copy, Check, Play } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/components/locales';
type CommandBlockProps = {
label?: string;
@@ -9,6 +10,7 @@ type CommandBlockProps = {
};
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = () => {
@@ -20,14 +22,14 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
return (
<div className="inline-flex flex-col gap-1">
{label && <span className="text-xs text-duck-dark/50">{label}</span>}
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5">
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg ps-3 pe-1 py-1.5">
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
{onRun && (
<button
type="button"
onClick={() => onRun(command)}
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
title="Run in terminal"
title={t('commandBlock.run')}
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
@@ -36,7 +38,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
type="button"
onClick={copy}
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
title="Copy command"
title={t('commandBlock.copy')}
>
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
</button>
+10 -6
View File
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Loader2, TriangleAlert } from 'lucide-react';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
// Loading, failed, empty. See docs/design-language-interface.md § 6.
//
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
</div>
);
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => (
<Shell>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{label}</p>
</Shell>
);
export const LoadingBlock = ({ label }: { label?: string }) => {
const { t } = useTranslation();
return (
<Shell>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{label ?? t('state.loading')}</p>
</Shell>
);
};
/**
* The real message is shown, not a friendly substitute for it. On a self-hosted platform the person
@@ -1,7 +1,21 @@
import type { Column } from './DataTable';
import type { ReactNode } from 'react';
import { describe, expect, test } from 'bun:test';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { I18nProvider } from 'i18n';
import { DataTable, useDataControl } from './DataTable';
import enGB from '../locales/en-GB.json';
/**
* The table's own chrome ("Next", "No Data Found.") comes from the `ui.*` catalog now, so these
* assertions only hold inside a provider — the same one the app mounts. Asserting the English a user
* actually sees, rather than a key, is the point of wrapping rather than loosening the expectations.
*/
const withI18n = (ui: ReactNode) => (
<I18nProvider translations={{ 'en-GB': { ui: enGB } }} locale="en-GB" fallbackLocale="en-GB">
{ui}
</I18nProvider>
);
type TestItem = {
id: number;
@@ -55,7 +69,7 @@ const TestWrapper = ({
describe('DataTable', () => {
describe('rendering', () => {
test('renders table with data', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
expect(screen.getByRole('table')).toBeDefined();
expect(screen.getByText('Alpha')).toBeDefined();
@@ -63,7 +77,7 @@ describe('DataTable', () => {
});
test('renders column headers', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
// Component renders both desktop and mobile versions (hidden via CSS)
// so we use getAllByText and verify at least one exists
@@ -74,7 +88,7 @@ describe('DataTable', () => {
});
test('renders correct number of rows based on page size', () => {
render(<TestWrapper pageSize={3} />);
render(withI18n(<TestWrapper pageSize={3} />));
const rows = screen.getAllByRole('row');
// 1 header row + 3 data rows
@@ -82,20 +96,20 @@ describe('DataTable', () => {
});
test('shows no data message when data is empty', () => {
render(<TestWrapper data={[]} noDataMessage="No items found" />);
render(withI18n(<TestWrapper data={[]} noDataMessage="No items found" />));
expect(screen.getByText('No items found')).toBeDefined();
expect(screen.queryByRole('table')).toBeNull();
});
test('shows default no data message', () => {
render(<TestWrapper data={[]} />);
render(withI18n(<TestWrapper data={[]} />));
expect(screen.getByText('No Data Found.')).toBeDefined();
});
test('renders noDataCTA when provided', () => {
render(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />);
render(withI18n(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />));
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
});
@@ -106,7 +120,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} columns={columns} />;
};
const { container } = render(<NullWrapper />);
const { container } = render(withI18n(<NullWrapper />));
expect(container.innerHTML).toBe('');
});
});
@@ -118,7 +132,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} />;
};
render(<AutoColumnsWrapper />);
render(withI18n(<AutoColumnsWrapper />));
// Should generate columns from object keys (rendered for both mobile/desktop)
expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
@@ -130,7 +144,7 @@ describe('DataTable', () => {
describe('sorting', () => {
test('clicking sortable column header triggers sort', () => {
render(<TestWrapper pageSize={10} />);
render(withI18n(<TestWrapper pageSize={10} />));
// Get the first "Name" element (desktop version)
const nameHeaders = screen.getAllByText('Name');
@@ -143,7 +157,7 @@ describe('DataTable', () => {
});
test('clicking same column twice reverses sort order', () => {
render(<TestWrapper pageSize={10} />);
render(withI18n(<TestWrapper pageSize={10} />));
const nameHeaders = screen.getAllByText('Name');
fireEvent.click(nameHeaders[0]!); // asc
@@ -156,7 +170,7 @@ describe('DataTable', () => {
});
test('sort icon is displayed for sortable columns', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
// Each sortable column should have the ArrowUpDown icon
const headers = screen.getAllByRole('columnheader');
@@ -169,19 +183,19 @@ describe('DataTable', () => {
describe('pagination', () => {
test('shows pagination bar when data exceeds page size', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
});
test('does not show pagination when all data fits on one page', () => {
render(<TestWrapper pageSize={10} />);
render(withI18n(<TestWrapper pageSize={10} />));
expect(screen.queryByText(/Showing page/)).toBeNull();
});
test('next button navigates to next page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -192,7 +206,7 @@ describe('DataTable', () => {
});
test('previous button navigates to previous page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -205,7 +219,7 @@ describe('DataTable', () => {
});
test('first button goes to first page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -217,7 +231,7 @@ describe('DataTable', () => {
});
test('last button goes to last page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const lastButton = screen.getByRole('button', { name: 'Last' });
fireEvent.click(lastButton);
@@ -226,7 +240,7 @@ describe('DataTable', () => {
});
test('navigation buttons are disabled at boundaries', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const firstBtn = screen.getByRole('button', { name: 'First' });
const prevBtn = screen.getByRole('button', { name: 'Prev' });
@@ -258,7 +272,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: false },
];
render(<TestWrapper cols={conditionalColumns} />);
render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
@@ -271,7 +285,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: true },
];
render(<TestWrapper cols={conditionalColumns} />);
render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
});
@@ -283,11 +297,11 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: () => showStatus },
];
const { rerender } = render(<TestWrapper cols={conditionalColumns} />);
const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.queryByText('Status')).toBeNull();
showStatus = true;
rerender(<TestWrapper cols={conditionalColumns} />);
rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
});
});
@@ -303,7 +317,7 @@ describe('DataTable', () => {
},
];
render(<TestWrapper cols={formattedColumns} />);
render(withI18n(<TestWrapper cols={formattedColumns} />));
const formattedNames = screen.getAllByTestId('formatted-name');
expect(formattedNames.length).toBeGreaterThan(0);
@@ -319,7 +333,7 @@ describe('DataTable', () => {
},
];
render(<TestWrapper cols={formattedColumns} />);
render(withI18n(<TestWrapper cols={formattedColumns} />));
// Each row should have a unique test ID based on item.id
expect(screen.getByTestId('item-1')).toBeDefined();
@@ -337,7 +351,7 @@ describe('DataTable', () => {
},
];
render(<TestWrapper cols={actionColumns} />);
render(withI18n(<TestWrapper cols={actionColumns} />));
expect(screen.getByTestId('action-1')).toBeDefined();
expect(screen.getByText('Edit Alpha')).toBeDefined();
@@ -351,7 +365,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Name' },
];
render(<TestWrapper cols={tooltipColumns} />);
render(withI18n(<TestWrapper cols={tooltipColumns} />));
// The tooltip indicator (ⓘ) should be present in the ID column headers
const idHeaders = screen.getAllByText(/ID/);
@@ -367,7 +381,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Full Name', labelMobile: 'Name' },
];
render(<TestWrapper cols={mobileColumns} />);
render(withI18n(<TestWrapper cols={mobileColumns} />));
// Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
@@ -396,7 +410,7 @@ describe('DataTable', () => {
);
};
render(<SearchableTable />);
render(withI18n(<SearchableTable />));
const searchInput = screen.getByTestId('search-input');
fireEvent.change(searchInput, { target: { value: 'Alpha' } });
@@ -420,7 +434,7 @@ describe('DataTable', () => {
);
};
render(<ExternalSortTable />);
render(withI18n(<ExternalSortTable />));
fireEvent.click(screen.getByTestId('sort-btn'));
@@ -6,8 +6,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
import { PaginationBar } from './PaginationBar';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const DataTable = <T,>(props: DataTableProps<T>) => {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column<T>[]>([]);
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
const theUniqueKey = uniqueKey || 'id';
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
if (rawData.length === 0) {
return (
<div className="py-12 text-center">
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div>
<div className="text-muted-foreground">{noDataMessage || t('dataTable.empty')}</div>
{noDataCTA && <>{noDataCTA}</>}
</div>
);
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
<span className="min-[500px]:hidden">
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
</span>
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />}
{sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
</div>
</TooltipTrigger>
{tooltip && (
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/components/locales';
type PaginationBarProps<T> = {
dataManager: UseDataControlReturn<T>;
};
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
const { t } = useTranslation();
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
{/* LEFT */}
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
<span className="hidden min-[500px]:inline">
Showing page {currentPage} of {pageCount}
{t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
</span>
<span className="min-[500px]:hidden">
p {currentPage}/{pageCount}
{t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
</span>
<span className="bg-border h-5 w-px" aria-hidden="true" />
@@ -47,11 +49,11 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
hover:bg-background
data-[state=open]:bg-muted
"
aria-label="Rows per page"
aria-label={t('dataTable.pagination.rowsPerPage')}
>
<SelectValue className="text-muted-foreground">
<span className="hidden min-[500px]:inline">{pageSize} / page</span>
<span className="min-[500px]:hidden">{pageSize}/p</span>
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
</SelectValue>
</SelectTrigger>
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
data-[state=checked]:text-muted-foreground
"
>
<span className="hidden min-[500px]:inline">{n} / page</span>
<span className="min-[500px]:hidden">{n}/p</span>
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
</SelectItem>
))}
</SelectContent>
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(1)}
disabled={currentPage === 1}
aria-label="First"
aria-label={t('dataTable.pagination.first')}
>
«
</Button>
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Prev"
aria-label={t('dataTable.pagination.prev')}
>
‹
</Button>
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Next"
aria-label={t('dataTable.pagination.next')}
>
›
</Button>
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(pageCount)}
disabled={currentPage === pageCount}
aria-label="Last"
aria-label={t('dataTable.pagination.last')}
>
»
</Button>
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type ConfirmDialogProps = {
open: boolean;
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
export const ConfirmDialog = (props: ConfirmDialogProps) => {
const [confirming, setConfirming] = useState(false);
const { t } = useTranslation();
const { open, disabled = false, title, description, children } = props;
const { onConfirm, onCancel, confirmLabel = 'Confirm', confirmVariant = 'default' } = props;
const { onConfirm, onCancel, confirmLabel, confirmVariant = 'default' } = props;
const handleConfirm = async () => {
setConfirming(true);
@@ -37,16 +39,16 @@ export const ConfirmDialog = (props: ConfirmDialogProps) => {
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
{t('button.cancel')}
</Button>
<Button
type="button"
variant={confirmVariant}
onClick={handleConfirm}
disabled={disabled || confirming}
className="sm:ml-2"
className="sm:ms-2"
>
{confirmLabel}
{confirmLabel ?? t('button.confirm')}
</Button>
</div>
</DialogContent>
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import { useTranslation } from '@/components/locales';
type PaperDialogProps = {
open: boolean;
@@ -11,6 +12,8 @@ type PaperDialogProps = {
};
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
const { t } = useTranslation();
const handleOpenAutoFocus = (ev: Event) => {
ev.preventDefault();
onOpenAutoFocus?.(ev);
@@ -25,9 +28,9 @@ export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: P
className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
style={cardStyle()}
>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<span className="h-4 w-4 text-duck-dark">✕</span>
<span className="sr-only">Close</span>
<span className="sr-only">{t('button.close')}</span>
</DialogPrimitive.Close>
{children}
</DialogPrimitive.Content>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Server Error
{t('error.server.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Unauthorized Access
{t('error.forbidden.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">
Your are not authorized to perform this action.
</DialogDescription>
<DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
</DialogHeader>
<p>If you think this is an error, please contact your manager.</p>
<p>{t('error.forbidden.hint')}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>

Some files were not shown because too many files have changed in this diff Show More