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
pastilhasandClaude Opus 5 0a8fe1c2c8 release 2026.08.23.02
SendOff, and the deploy procedure's own corrections from the last rollout.

Schema change — sendoff_devices and sendoff_senders are new, so bun db:push is a
step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:52:30 +00:00
pastilhasandClaude Opus 5 2137d06498 the folder name is served, not derived three times
Mobile asked for `/targets` to carry each person's display name as the folder will
be named, so a picker can honestly say where files will appear. Right request: the
writer already computes that exact string to create a directory, and any client
computing its own would eventually disagree with it — which means telling somebody
the wrong place to look.

So `displayNameFor` is the one definition and the writer calls it too. `/targets`
gains `displayName` per person, and `myFolder` for the whole response: files land
in the RECIPIENT's tree under the SENDER's name, so that is one value rather than
a field on each row. The two are easy to confuse and the distinction matters — a
picker showing `displayName` as the destination folder would name the wrong one.

The web panel now uses both instead of its own copy of the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:50:23 +00:00
pastilhasandClaude Opus 5 ca7d3c657b a send form for SendOff, and no receiving one
/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:47:47 +00:00
pastilhasandClaude Fable 5 b22e9d86c0 COMMS: mobile adopts all three sendoff deviations, one request for /targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 03:45:04 +01:00
pastilhasandClaude Opus 5 2095b6a717 COMMS: SendOff backend is in, and it differs from the draft in two places
Opens the channel for mobile. The endpoints as built rather than as specced, both
deviations with the reasoning, and the parts that need no app build at all.

The two that matter: device registration moved to /api/sendoff/devices, because
/api/devices collided with the push-token registry and those are different
populations; and toUser is an account id rather than a username, because
users.name is nullable and a username is not a reliable address.

Both are cheap to change here and expensive to guess at, which the note says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:41:28 +00:00
pastilhasandClaude Opus 5 4ea25d76c8 todo: the SendOff size limit should be per user, from settings
512 MB was chosen as enough-for-now on the day the feature landed, and it applies
to every account equally. The owner asked for it per user before the code was an
hour old.

Notes the adjacent question while it is in view: a size limit bounds one send, and
nothing bounds what a sender accumulates in somebody's tree over a month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:34:15 +00:00
pastilhasandClaude Opus 5 b94a5e90b8 SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push.
No staging area, no transfer table, no ack, nothing to expire. Every account
already has a home, a file browser over it and a Files app on the phone, so a
delivered file is an ordinary file from the moment it arrives.

`Documents/SendOff/<machine>/` for something you sent yourself, and
`Documents/SendOff/<sender>/` for another account's files. The home is the one
that account actually browses — the owner's real login home, a member's managed
home — because the two are different directories and writing to the wrong one
makes a delivery invisible rather than wrong.

CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant
answers "may this role send files", and the person who should decide whether
somebody may write into your home is you. Absence denies, and a sender who was
not allowed does not see the recipient in their picker at all — the endpoint
answers 404 for "no such account" and for "did not allow you" alike, so it cannot
be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving
still needs their say-so.

The sender's folder name comes from the token and never from the payload, so it
cannot lie about who put a file there. Names are sanitised to one path segment and
filenames to their basename — verified: `../../../../etc` becomes a literal
folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside
the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the
sender cannot see the directory and would have no way to know.

The push carries the folder name and a file count, and the catalogue renders
"3 file(s) from andre" on the device. That relaxes "content does not travel" by a
folder name and an integer — the owner's call, on the grounds that the file never
leaves the tailnet and a notification nobody can act on is not worth sending. A
filename would be content; whose folder it is, is addressing.

`kind: 'app'` rather than `confined` even though it writes into a home: the path
is one fixed shape chosen by the server from the caller's token, so there is
nothing to traverse and nothing to read back. Requiring a Linux user would mean an
account without one could not be sent a file, which is most of the point.

Backend and verified end to end. The web send form is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:32:09 +00:00
pastilhasandClaude Opus 5 510fa3f524 deploy the tag, and stop spending a minute on a host that is known to be down
Two things the 2026.08.23.01 rollout showed.

`edge-pertento-bridge` is down and staying down for some days, and the deploy
agent had no way to know: it spent four attempts and up to sixty seconds apiece
before reporting it unreachable. Correct behaviour against a list that had not
been told, so `docs/servers.md` now has a Deliberately skipped section, with the
host's row kept verbatim inside it so restoring it is one move. A host there is
named as skipped in the report and costs nothing else.

And the procedure said `git pull --ff-only`, which follows `master` rather than
the tag being deployed. A docs commit landed mid-rollout, so every host came up on
`e6f1388a` — one past `deploy/2026.08.23.01` — and "deployed the tag" was true of
none of them. Harmless this time, and it need not have been: it is now fetch plus
`merge --ff-only <tag>`. A host that is AHEAD of the tag is reported, never reset.

Also lands the report itself, which the agent wrote and could not push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:12:45 +00:00
pastilhas b5d9f591ee deploy report 2026.08.23.01 — four hosts, two unreachable
edge-pertento-bridge never completes an SSH banner exchange; production-pertento-bridge
presented a changed host key and was left alone. Neither was pulled or restarted.
2026-08-23 02:07:10 +00:00
pastilhasandClaude Fable 5 e6f1388aa6 plan: localizing the web UI, and the scanner that sizes it
not built. an execution plan written to be handed to an agent who did
not have the conversation, so it carries the reasons and not just the
steps.

what it found: there is already a hand-rolled i18n package with typed
keys, two catalogs and 46 keys — and exactly three components using it.
against 884 distinct hardcoded strings across 436 files. so the system
is not missing, it is 5% deployed, and that gap is the job.

the decisions worth arguing with are D1 and D6. D1: the server should
never emit user-facing prose, it returns a stable code and the UI owns
every word — with the trap that a code must not leak more than the prose
did, or a deliberately vague 403 becomes an enumeration oracle. D6:
catalogs per workspace rather than one file, because one 900-key en.json
is a merge conflict on every parallel edit.

the scanner reproduces the numbers in the doc and doubles as the guard
once an area reaches zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:57:22 +00:00
168 changed files with 6639 additions and 1546 deletions
+8 -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). **19 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 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,13 @@ 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
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
+23
View File
@@ -0,0 +1,23 @@
# SendOff — mobile's answer
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
Good catch flagging the case explicitly; the app did build that path itself and would have opened
an empty sibling folder.
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
being siblings called "devices" would have bitten us first.
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
carries ids — one address format, no translation layer.
One request for `/targets` when the send screen gets built here: include each person's display name
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
so the picker can honestly say where the files will appear on the other side.
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
iOS now so the corrected paths are on the device.
+40
View File
@@ -0,0 +1,40 @@
# SendOff — `/targets` now serves the folder name
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
Request granted, and you were right to ask for it served rather than derived. Two fields:
```
GET /api/sendoff/targets
→ {
devices: [{ id, name, platform, lastSeenAt }],
people: [{ id, email, name, displayName }], ← displayName is new
myFolder: "Pastilhas" ← this is new
}
```
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
needs to change, it changes in one place and both sides move together.
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
that showed `displayName` as the destination folder would be naming the wrong directory.
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
what you see is what appears on disk.
## Also since the last note
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
asked for, and it is there now rather than when you get to it.
+141
View File
@@ -0,0 +1,141 @@
# SendOff — the backend is in. What mobile needs to change.
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
draft; this file is what actually shipped, and it differs in two places.
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
exercised against the live server before this note was written. The web send form is **not** built yet;
that is on this side and does not block anything below.
## The convention on disk
```
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
<home>/Documents/SendOff/<sender>/ — files another account sent you
```
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
relative to the right one.
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
sender's name alone.
## Two changes from the draft spec
**1. Device registration moved under the feature.**
```
was: POST /api/devices/register { id, name, platform }
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
```
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
`DELETE /api/sendoff/devices/:id`.
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
top-level noun collides with the **push token** registry that already exists at
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
an afternoon.
**2. `toUser` is an account id, not a username.**
```
was: meta { "toUser": "<username>" }
now: form field toUser=<account id, integer>
```
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
either; it is a small change here and a smaller one than guessing.
## The send endpoint
```
POST /api/sendoff/send
multipart/form-data:
file — repeatable, any number of parts
machine — a name of your own } exactly one of these
toUser — an account id }
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
```
Notes that matter for a client:
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
A file over the limit is a `400` naming the file.
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
they were about to destroy something. Show the returned name, not the local one.
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
one refused you. Do not present the two differently, because they are not distinguishable.
## Who may send to whom
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
handshake: I add you, and only then do you see me as a target.
```
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
POST /api/sendoff/senders { userId }
DELETE /api/sendoff/senders/:userId
```
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
their say-so, so nobody lands files in the owner's tree uninvited.
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
## The push
```
code: officer/core:sendoff.landed
id: the folder name — a username, or one of your own machine names
n: how many files landed
```
The catalogue entry renders `{n} file(s) from {id}` and its route is:
```
officer://files?path=Documents/SendOff/{id}
```
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
from the server — which is the whole point of that design. What the app does need is to keep parsing
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would
be content; whose folder it is, is addressing. If a future notification wants more than that, it is a
conversation rather than a patch.
## What is NOT built
- **The web send form.** This side, in progress. It changes no API.
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
blocks it — `POST /sendoff/send` takes multipart from any client.
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
month.
## If something here is wrong for mobile
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
ends up carrying a translation layer nobody meant to write.
+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:*",
},
+134
View File
@@ -0,0 +1,134 @@
# 2026.08.23.01 — deploy report
**4 of 6 target hosts succeeded.** `officer-dev` skipped by the plan (the commander runs there) and
verified already current. Two hosts were **not reached at all**, both for access reasons that have nothing
to do with this release: `edge-pertento-bridge` will not complete an SSH handshake, and
`production-pertento-bridge` presented a **changed host key**, which is not something a deploy agent
clears. Neither was pulled, pushed to, or restarted. No host failed a step; nothing came back `errored`.
Deployed 2026-08-23 from `officer-dev`. Tag `deploy/2026.08.23.01` → `dee0b89b`, matching `package.json`
`2026.08.23.01`.
**Hosts landed on `e6f1388a`, one commit past the tag.** The plan says `git pull --ff-only`, which follows
`master`, and `master` had moved on by one commit — `plan: localizing the web UI, and the scanner that
sizes it`, three files: `CLAUDE.md`, `docs/localization.md`, `scripts/i18n/scan-hardcoded.ts`. Documentation
and a standalone script, no runtime code, no schema and no dependency. Recorded here because "deployed the
tag" would not be true of any host.
## Summary
| Host | HEAD before | HEAD after | Steps | `officer` restart | Schema |
| ---------------------------- | ----------- | ---------- | -------- | ----------------- | ------- |
| `officer-dev` | `e6f1388a` | — | skipped | not touched (237) | ✓ |
| `alpha` | `68d60e40` | `e6f1388a` | all 4 | 66 → 67, online | ✓ |
| `fofos` | `68d60e40` | `e6f1388a` | all 4 | 20 → 21, online | ✓ |
| `macbookpro` | `8b4a5b3b` | `e6f1388a` | all 4 | 7 → 8, online | ✓ |
| `edge-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `production-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `deadpool-rezio-bridge` | `68d60e40` | `e6f1388a` | all 4 | 5 → 6, online | ✓ |
Checkout paths came from each `officer` process's `pm_cwd`, not from a hard-coded list. "Schema ✓" is a
live check, not an inference from `db:push` exiting zero: `command_hq_spawned` exists and
`command_hq_sessions.name` exists, queried per host against that host's own database.
## Per host
### `officer-dev` — SKIPPED, and current
The commander session runs inside `officer-claude-code` here, so the whole host was skipped as the plan
requires: no pull, no `db:push`, no `plugins:update`, no restart. It was already at `e6f1388a` and both
schema objects are present, so it is running this release rather than lagging. `officer` shows 237 restarts,
untouched by this deploy. Nothing is pending on it — the owner restarts it deliberately if they want to.
### `alpha` — done
Tree clean, tracked and untracked. Pulled `68d60e40` → `e6f1388a`. `bun db:push` applied changes;
`backfill-session-models` reported 0 seeded. `bun run plugins:update` returned **12/12 updated in 48s**,
exit 0, and restarted all twelve plugin sidecars itself — atelier, bitcoin-wallet, email, gitea, invoices,
jellyfin, music, offscale, photos, soulseek, transmission, vault. Then `pm2 restart officer`: 66 → 67,
`online`. Every other PM2 entry `online`.
### `fofos` — done
Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied changes. `plugins:update`: **1/1 in 8s**
(gitea), which restarted `officer-gitea`. `pm2 restart officer`: 20 → 21, `online`. No trace of the
`dubious ownership` / `EACCES` pair that broke its capabilities sync for three deploys — `capabilities:
pulled` came back clean, as it has since the 2026-08-21 `chown`.
### `macbookpro` — done
Awake and reachable. Tree clean. Pulled `8b4a5b3b` → `e6f1388a` — two releases behind, so it picked up
2026.08.22.01 and .02 as well. `db:push` applied changes (3 plugin schemas generated: atelier, kiosk,
music). `plugins:update`: `no plugins installed — nothing to update`, exit 0. `pm2 restart officer` was run
as a **separate** command per `docs/servers.md`: 7 → 8, `online`.
`PATH` was appended, never replaced — `export PATH="$PATH:/opt/homebrew/bin"` inside the interactive shell,
so `bun` resolves and `pm2` stays where nvm put it. `.zshrc` still fails on a missing `starship` on every
non-interactive login; noise, not a fault, and it has been there for at least three deploys.
### `deadpool-rezio-bridge` — done
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied
changes. `plugins:update`: **2/2 in 8s** (email, offscale), restarting both sidecars. `pm2 restart officer`:
5 → 6, `online`.
### `edge-pertento-bridge` — NOT REACHED
**Four attempts, ConnectTimeout up to 60s: `Connection timed out during banner exchange`.** The TCP
connection succeeds — `nc -z 100.64.0.15 22` reports the port open and `ssh -v` logs `Connection
established` — and then sshd never sends its version string. Tailscale shows the node `active; direct`, so
the tunnel is up and this is not a tailnet problem.
That is a host-side sshd or load problem, not a credential one and not a rate-limit one. Nothing was run
against it: it is still on whatever it was before this release, which I could not read, because reading it
needs the same handshake. It also did not answer HTTPS on its tailnet address during the probe.
**This host needs a look before the next release**, and the check is one command from anywhere on the
tailnet — `ssh edge-pertento-bridge hostname`. If it still hangs, the box wants a console.
### `production-pertento-bridge` — NOT REACHED, host key changed
```
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
The fingerprint for the ED25519 key sent by the remote host is
SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww
Offending ECDSA key in /home/pastilhas/.ssh/known_hosts:37
```
**I did not clear the entry and did not connect.** Accepting a new host key is a trust decision, and a
deploy agent that removes a `known_hosts` line to get its release out has removed the only warning the
owner would ever get. `StrictHostKeyChecking` did exactly what it is for.
What is known, all read locally without touching the host:
- `known_hosts` holds three keys for this name: ED25519 `SHA256:dw7kG2MDftuLDT1CduEO+OVYuMVHsx02Cnwikj7GS/4`
(line 33), RSA (line 36), ECDSA (line 37, the one ssh names as offending).
- The host now offers ED25519 `SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww` — a different key from
the ED25519 already trusted.
- Tailscale shows it `active; direct`, port 22 accepts TCP, and it did not answer HTTPS on 100.64.0.12
during the probe.
The benign explanation is a rebuild or a regenerated host key — this is `PPROD`, a bridge that has been
reinstalled before. The other explanation is the one the warning is about. **Settle it out of band**: read
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the machine's own console, compare it to
`63S0GK…`, and only then drop the stale lines with
`ssh-keygen -f ~/.ssh/known_hosts -R production-pertento-bridge`. Do not compare it over SSH; that is the
channel in question.
Until then this host is a release behind — it was at `68d60e40` as of the 2026.08.22.02 report.
## What I did not do
- **Did not deploy `edge-pertento-bridge` or `production-pertento-bridge`**, and did not force either.
Neither was pulled, neither had a process restarted, and neither has this release's schema.
- **Did not touch `~/.ssh/known_hosts`.**
- **Did not run `bun install`** — the plan forbids it and `bun.lock` is unchanged across
`deploy/2026.08.22.02..HEAD`.
- **Did not run `bun run docs:check`** — not the commander's job; it is run before the tag.
- **Did not restart anything the plan did not name.** `officer` only, per host. The plugin sidecars that
bounced were restarted by `plugins:update` itself, which is that script's own behaviour: 12 on `alpha`,
1 on `fofos`, 2 on `deadpool-rezio-bridge`.
- **Did not touch `officer-dev`** at all.
The local record `data/deploys/2026.08.23.01.done` was written before this report, as the protocol requires:
the record is what prevents a second attempt, and pushing this file is paperwork that is allowed to fail.
+53
View File
@@ -0,0 +1,53 @@
# 2026.08.23.02
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
push, `/sendoff` fails on a missing relation the first time it lists anything.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
moved only its version. **Do not run `bun install`.**
## What is in it
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
`Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually
browses — the owner's real login home, a member's managed home — because those are different directories
and writing to the wrong one makes a delivery invisible rather than wrong.
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
accounts. The owner bypasses this as a SENDER only.
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
the catalogue syncs.
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
days, and the last rollout spent four attempts and a minute apiece discovering that.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
2. **`bun db:push`** — new this release
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 rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
## `officer-dev` — skip it
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
+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.
+400
View File
@@ -0,0 +1,400 @@
# Localization
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
This is an execution plan. It is written to be handed to an agent who has not had the conversation
that produced it, so it states the decisions and the reasons for them, not just the steps.
Every count here is a **measurement taken on 2026-08-23**, not a claim that stays true — the whole
point is for it to fall. Re-derive with `bun scripts/i18n/scan-hardcoded.ts` rather than trusting the
number beside it, and do not wire these into `docs:check`: a guard that fails when the count drops
would be a guard against progress.
---
## What this is
Every word the UI shows a person should come from a catalog keyed by locale, so the platform can be
read in a language other than English. Routes stay English (`/minesweeper`, `/chat`) — that is a URL
decision, deferred, and nothing here depends on it.
The scope is **the web UI only**. Not the server, not the plugins, not email or push. See D1 for what
the server does instead, which is the one decision that reaches outside this boundary.
---
## What exists today, and what it cannot do
**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.
| | 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** |
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 remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
wired into the bridge, and empty.
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** |
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
That is a floor, not a ceiling. The scanner cannot see multi-line JSX, ternaries inside JSX, strings
passed down as props, or anything a plugin renders. Budget for **1200–1500 keys**.
---
## Decisions
### D1. The web UI only — and the server returns codes, not prose
The server should **never emit user-facing prose**. It returns a stable machine-readable code; the UI
owns every word a person reads and localizes it like any other string.
```jsonc
// not this
{ "error": "The Minesweeper session is not running" }
// this
{ "code": "MINESWEEPER_NOT_RUNNING" }
```
This is better than translating the server, for reasons that have nothing to do with translation. A
code maps to exactly one failure site, so it is greppable and a support conversation has something
precise to quote. Prose drifts; a code does not. And the server stops needing to know who is reading.
- **Phase 1 establishes the convention** so new code stops adding debt: new error paths emit codes,
and the catalog gets an `errors.*` namespace.
- **Phase 2 migrates the existing ~53** prose strings in `src/servers`. Not in this plan's scope.
- **Unmapped code** renders a generic localized line **with the code visible**. Never a blank, never
a raw key.
**The trap, and it is a real one: a code must not leak more than the prose it replaces.** Where a
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.
### 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.
**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.
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:
**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.
**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
No right-to-left locale ships in this phase. But since 436 files are being opened anyway, they get
opened once:
- **CSS logical properties** as components are touched — `margin-inline-start`, `padding-inline`,
`text-align: start` — instead of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this.
- **`dir` on `<html>`**, driven by the locale, wired now and set to `ltr` for every shipped locale.
The cost during a pass we are already making is close to nothing. The cost of a second pass over 436
files later is not. Hebrew stays in the picker but is not selectable until a catalog exists (see the
"twenty offered, two that exist" defect above — fix it by filtering the picker to shipped locales).
### D4. Extend the existing package. Do not adopt a library
`react-i18next`, `lingui` and `formatjs` are all better than what is here, and all of them are
dependencies. This repository froze its lockfile and hand-rolled a secret store rather than take
one, for reasons written down in `CLAUDE.md` → "Installs are frozen".
The gap is smaller than it looks, because the hard parts are in the runtime already: `Intl.PluralRules`,
`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.
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
### D5. Keys, not English source text
`t('settings.profile.changePassword.title')`, never `t('Change Password')`.
Source-text keys (the gettext and lingui style) read nicely and mean an untranslated string still
renders. They also mean **editing an English word invalidates every translation of it**, and this
codebase already has typed keys — a wrong key is a compile error today, and that is not worth
trading away.
### D6. Catalogs live per workspace, merged at the bridge
Not one god file. Three:
```
src/apps/officer-web/locales/ → namespace app.*
src/workspaces/officerdev/src/locales/ → namespace panel.*
src/workspaces/components/locales/ → namespace ui.*
```
`I18nBridge` deep-merges them before handing the map to the provider. Each workspace exports its own
typed `useTranslation` the way `officer-web/lib/i18n.ts` does today.
Two reasons, and the second is the one that matters for execution. Strings belong next to the code
that renders them, so a panel stays self-contained. And **one 900-key `en.json` is a merge conflict
on every parallel edit** — with several agents or several days of work, a single file is a queue.
Namespaces also make an unqualified key impossible to place wrongly: `ui.*` is shared, so anything
under it must not mention a feature.
### D7. Plurals and formatting go through `Intl`
A plural value becomes an object; a string stays a string:
```jsonc
"panel.files.selected": { "one": "{{count}} file selected", "other": "{{count}} files selected" }
```
`t()` picks the branch with `new Intl.PluralRules(locale).select(count)` when `count` is passed.
Categories are per-locale — English and Portuguese use `one`/`other`, Arabic uses six — so the shape
must be an open record, never a two-field type.
A `useFormat()` hook returns `date`, `time`, `dateTime`, `number` and `relative`, each bound to the
active locale. **The ~23 `toLocale*` call sites move to it.** Left alone they follow the browser's
locale and quietly contradict the rest of the UI.
### D8. A missing key falls back to English and is loud in dev
Never render a dot-path to a person. Resolution walks the chain, ends at `en`, and only if English is
missing too does it render the key — which at that point is a bug the parity test below should have
caught.
In dev, a miss logs once per key with the key and the locale. In production it is silent.
---
## The key naming convention
Three segments, sometimes four. **Name the role, never the text.**
```
<namespace>.<area>.<component>.<element>
app.settings.profile.changePassword.title
panel.files.contextMenu.rename
panel.chat.composer.placeholder
ui.button.cancel
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`.
Rules that keep 1200 keys navigable:
- **Never key by the English words.** `ui.button.save`, not `ui.button.saveText` and never
`ui.button.Save`.
- **Never build a key at runtime.** `t(\`panel.${kind}.title\`)` defeats both the type checker and
the extractor. Use an explicit map from the dynamic value to literal keys.
- **`ui.*` may not name a feature.** If a shared component needs feature words, the caller passes
them as a prop.
- **One key per meaning, not per occurrence.** "Cancel" in twelve dialogs is `ui.button.cancel` once.
But a word that is the same in English and different elsewhere — "Post" the noun and the verb —
gets two keys. When unsure, split: merging later is safe, splitting later is not.
---
## What must never be translated
Getting this wrong is worse than missing a string, because it corrupts data or breaks a contract.
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
- **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
reading a terminal at 4am, and they should stay greppable in one language.
- **"Officer"**, and product names generally.
---
## Execution
Four phases. Each is a commit that leaves the tree green — the app must run in English at every point,
because a half-converted UI is unreviewable.
### Phase 0 — infrastructure, no visible change
Everything in "What it cannot do yet". Nothing in this phase touches a component.
1. `resolve.ts`: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).
2. `I18nProvider`: accept a merged map; expose `useFormat()`.
3. `I18nBridge`: deep-merge the three catalogs (D6); set `dir` on `<html>` (D3).
4. `Languages.tsx`: regional tags, and **filter the picker to locales that actually exist**.
5. Settings migration: widen stored bare codes on read.
6. Scaffold `locales/` in `officerdev` and `components`, each with its typed hook.
7. Land the tooling below.
Phase 0 is the whole plan's risk. It is also small, and it is the only part where a wrong decision is
expensive to reverse — review it before phase 1 starts.
### Phase 1 — shared components (91 files, ~47 strings)
Smallest, highest reuse, and it establishes `ui.*` before anything depends on it. Do this first so
phases 2 and 3 can reach for `ui.button.cancel` instead of inventing it twice.
### Phase 2 — the shell (132 files, ~556 strings)
`officer-web`: settings, dock, header, screens. Highest-traffic text and the part a reviewer will
look at first. Convert per directory, one commit per area.
### Phase 3 — the panels (213 files, ~567 strings)
`officerdev` apps. The largest surface and the most parallelizable, because panels barely share
strings. **One panel per commit** — this is where per-workspace catalogs earn their keep, but two
agents in the same panel will still conflict.
### Then: `pt-PT`
Only after English is fully keyed. Translating against a moving catalog is wasted work.
---
## Tooling and guardrails
Without these, 1200 keys rot. All three are small and belong in phase 0.
**`scripts/i18n/scan-hardcoded.ts`** — already written and committed. Reports every candidate
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 — 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
it globally on day one would just be red forever.
**Also worth having:** an unused-key report. Keys outlive the components that used them, and nothing
else will ever notice.
---
## Verification
- `bunx tsgo` clean. A wrong key is a type error, so this is the primary check.
**Note for this machine:** `earlyoom` (`-r 3600`) SIGTERMs `tsgo` under memory pressure — it dies
around 15s with `Command failed`. That is not a type error. Free memory or scope the check.
- `bun test` — parity test green for every shipped locale.
- `bun scripts/i18n/scan-hardcoded.ts` — count strictly decreasing per phase.
- By hand: switch the locale to `pt-PT` and walk the converted area. Untranslated strings should read
as **English, never as dot-paths**.
- Check a plural at 0, 1 and 2, and a date, in both locales. These are the two things that look fine
in English and wrong everywhere else.
---
## Open questions
1. **Routes.** Deferred deliberately. Nothing here depends on it, and localized URLs bring
canonicalization and redirect questions that are not worth answering yet.
2. **Who translates?** Machine translation for `pt-PT` and `pt-BR` reviewed by the owner is the
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.
**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.
+20 -1
View File
@@ -29,13 +29,32 @@ Seven, all running.
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
## Deliberately skipped
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
and it must not be reported as one.
| Host | Since | Why |
| ---------------------- | ---------- | ---------------------------------------------------------------- |
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
Its row, kept so restoring it is one move:
```
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
```
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
is what this section exists to prevent: a host that is known to be down should cost a line in a report,
not a retry budget.
### Names that disagree with themselves
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.23.01",
"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",
+80 -1
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
@@ -136,6 +157,11 @@ That document holds every machine, its tailnet name, **its login user**, its che
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
platform as out of scope.
It also has a **Deliberately skipped** section. A host listed there is not a target: name it as skipped
in the report and spend nothing on it. On 2026-08-23 a host that was known to be down cost four attempts
and a minute apiece before being reported unreachable — correct behaviour against a list that had not been
told. Read that section before you try to reach anything.
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
@@ -163,7 +189,15 @@ assemble at the end from memory is a report that will be wrong about the host th
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
discard someone's work to ship a release.
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
2. **`git fetch origin --tags` then `git merge --ff-only <tag>`** — the TAG you are deploying, not
`master`. `git pull --ff-only` follows the branch, so a commit pushed while you are rolling out lands
on some hosts and not others, and "deployed the tag" stops being true of any of them. That happened on
2026-08-23: every host came up one commit past the tag because a docs commit landed mid-rollout. It was
harmless and it need not have been.
A non-fast-forward means that checkout has diverged: skip and report. A tag that will not fast-forward
because the host is AHEAD is the same answer — report it, do not reset.
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
@@ -179,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();
}
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env bun
/**
* Counts candidate hardcoded user-facing strings in the web UI.
*
* Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area
* reaches zero it becomes the guard that stops the debt coming back — see ZEROED below.
*
* It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries
* inside JSX, strings passed down as props, or anything a plugin renders. A falling number means
* progress; a zero means "nothing this scanner can see", not "nothing left".
*
* bun scripts/i18n/scan-hardcoded.ts # summary by area
* bun scripts/i18n/scan-hardcoded.ts --files # worst files first
* bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves
* bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const AREAS = [
{ label: 'officer-web (shell)', root: 'src/apps/officer-web' },
{ label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' },
{ label: 'shared components', root: 'src/workspaces/components' },
] as const;
/**
* 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[] = ['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})')`;
/**
* `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',
),
},
{ 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',
),
},
// 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 },
];
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
function isHuman(value: string | undefined): value is string {
if (!value) return false;
const s = value.trim();
if (s.length < 3) return false;
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;
// 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 {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const path = join(dir, entry);
if (SKIP.test(path)) continue;
let isDir = false;
try {
isDir = statSync(path).isDirectory();
} catch {
continue;
}
if (isDir) yield* walk(path);
else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path;
}
}
type Hit = { kind: string; value: string; file: string };
function scan(root: string): { files: number; hits: Hit[] } {
const hits: Hit[] = [];
let files = 0;
for (const file of walk(root)) {
files++;
let source: string;
try {
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
} catch {
continue;
}
for (const { kind, rx } of PATTERNS) {
rx.lastIndex = 0;
for (const match of source.matchAll(rx)) {
const value = match.slice(1).find((group) => group !== undefined);
if (isHuman(value)) hits.push({ kind, value: value.trim(), file });
}
}
}
return { files, hits };
}
const args = new Set(process.argv.slice(2));
const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined;
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)}`,
);
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)}`);
}
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));
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}`);
if (args.has('--files')) {
console.log('\nworst files:');
const perFile = new Map<string, number>();
for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1);
for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
console.log(` ${pad(n, 4)} ${file}`);
}
}
if (listPrefix) {
console.log(`\nstrings in files matching "${listPrefix}":`);
for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`);
}
if (args.has('--check')) {
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 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);
}
console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`);
}
+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));
+1
View File
@@ -107,6 +107,7 @@ export function App() {
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -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;
@@ -155,6 +157,7 @@ import {
Store,
Puzzle,
Radar,
Send,
} from 'lucide-react';
/**
@@ -168,18 +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.
{ 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
@@ -189,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' },
];
/**
@@ -208,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
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /sendoff — pick files, pick a machine of your own or a person who has allowed you, send.
//
// There is no receiving half. Files land in `Documents/SendOff/` inside the recipient's own tree, so the
// file browser already IS the inbox — and a second view of the same directory would be a worse copy of
// one that already works, kept in sync by hand.
export const SendOffScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/sendoff', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['sendoff'], fallback: 'sendoff' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The form is a column and there is nothing to put beside it — receiving already has a UI,
// which is the file browser.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'sendoff',
appType: 'sendoff',
};
@@ -0,0 +1 @@
export * from './SendOffScreen';
@@ -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,
});
@@ -23,3 +23,4 @@ export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './Minesweeper';
export * from './SendOff';
+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,
};
}
+2
View File
@@ -40,6 +40,8 @@ export * from './ai-provider-usage';
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(),
});
+2
View File
@@ -37,6 +37,8 @@ export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
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
@@ -0,0 +1,16 @@
export { sendoffDevices, sendoffSenders } from './schema';
export {
upsertSendoffDevice,
listSendoffDevices,
renameSendoffDevice,
deleteSendoffDevice,
listAllowedSenders,
listMyRecipients,
listOtherAccounts,
allowSender,
revokeSender,
maySendTo,
accountsByIds,
type SendoffDevice,
type SendoffPerson,
} from './queries';
@@ -0,0 +1,122 @@
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../auth/schema';
import { sendoffDevices, sendoffSenders } from './schema';
export type SendoffDevice = { id: string; name: string; platform: string | null; lastSeenAt: Date };
/**
* Register or refresh a device. Called on every app start, so it must be idempotent on the client's own id.
*
* `lastSeenAt` is refreshed on conflict and the name is overwritten, because the client is the authority on
* what it calls itself — renaming on the device should show up in the picker without a second endpoint.
*/
export async function upsertSendoffDevice(params: {
id: string;
userId: number;
name: string;
platform?: string | null;
}): Promise<void> {
await db
.insert(sendoffDevices)
.values({
id: params.id,
userId: params.userId,
name: params.name,
platform: params.platform ?? null,
})
.onConflictDoUpdate({
target: sendoffDevices.id,
set: { name: params.name, platform: params.platform ?? null, lastSeenAt: new Date() },
});
}
export async function listSendoffDevices(userId: number): Promise<SendoffDevice[]> {
const rows = await db
.select({
id: sendoffDevices.id,
name: sendoffDevices.name,
platform: sendoffDevices.platform,
lastSeenAt: sendoffDevices.lastSeenAt,
})
.from(sendoffDevices)
.where(eq(sendoffDevices.userId, userId));
return rows;
}
/** Scoped to the owner of the row: a device id is a client-chosen uuid and must not be a way to reach another account's. */
export async function renameSendoffDevice(userId: number, id: string, name: string): Promise<boolean> {
const rows = await db
.update(sendoffDevices)
.set({ name })
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
.returning({ id: sendoffDevices.id });
return rows.length > 0;
}
export async function deleteSendoffDevice(userId: number, id: string): Promise<boolean> {
const rows = await db
.delete(sendoffDevices)
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
.returning({ id: sendoffDevices.id });
return rows.length > 0;
}
export type SendoffPerson = { id: number; email: string; name: string | null };
/** The accounts I have allowed to send to me. */
export async function listAllowedSenders(recipientUserId: number): Promise<SendoffPerson[]> {
const rows = await db
.select({ id: users.id, email: users.email, name: users.name })
.from(sendoffSenders)
.innerJoin(users, eq(users.id, sendoffSenders.senderUserId))
.where(eq(sendoffSenders.recipientUserId, recipientUserId));
return rows;
}
/**
* The accounts I may send to — the inverse question, and the one the picker asks.
*
* Absence denies, so this returns only recipients who have named me. A sender never learns that an account
* exists and refused them; it simply is not in the list.
*/
export async function listMyRecipients(senderUserId: number): Promise<SendoffPerson[]> {
const rows = await db
.select({ id: users.id, email: users.email, name: users.name })
.from(sendoffSenders)
.innerJoin(users, eq(users.id, sendoffSenders.recipientUserId))
.where(eq(sendoffSenders.senderUserId, senderUserId));
return rows;
}
/** Every account except me — what the owner's picker shows, since the owner may send to anyone. */
export async function listOtherAccounts(exceptUserId: number): Promise<SendoffPerson[]> {
const rows = await db.select({ id: users.id, email: users.email, name: users.name }).from(users);
return rows.filter((r) => r.id !== exceptUserId);
}
export async function allowSender(recipientUserId: number, senderUserId: number): Promise<void> {
await db.insert(sendoffSenders).values({ recipientUserId, senderUserId }).onConflictDoNothing();
}
export async function revokeSender(recipientUserId: number, senderUserId: number): Promise<void> {
await db
.delete(sendoffSenders)
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)));
}
/** May this sender write into this recipient's tree? The owner check is the CALLER's job — see the schema. */
export async function maySendTo(senderUserId: number, recipientUserId: number): Promise<boolean> {
const rows = await db
.select({ id: sendoffSenders.id })
.from(sendoffSenders)
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)))
.limit(1);
return rows.length > 0;
}
/** Resolve accounts by id, for naming a recipient in a response without a second round trip. */
export async function accountsByIds(ids: number[]): Promise<SendoffPerson[]> {
if (!ids.length) return [];
return db.select({ id: users.id, email: users.email, name: users.name }).from(users).where(inArray(users.id, ids));
}
@@ -0,0 +1,55 @@
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* A named machine, so "send it to my laptop" has something to pick from.
*
* Separate from `push_devices` deliberately, and the two are not the same population. A push device is a
* token: iOS or Android only, no human name, and it exists to be pushed AT. This is a name a person chose,
* for a machine that may never receive a push at all — a desktop, a browser, a box with no app on it. The
* name's only job is to become a folder.
*
* The id is the client's own uuid rather than a serial, because registration happens on every app start and
* has to be idempotent without the client remembering a number we gave it.
*/
export const sendoffDevices = pgTable('sendoff_devices', {
id: text('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls it. Becomes a directory name, so it is sanitised before it touches the disk. */
name: text('name').notNull(),
platform: text('platform'),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
});
/**
* Who may send files to whom — held by the RECIPIENT, one row per sender they have allowed.
*
* This is the whole cross-user permission model and it is deliberately not a role grant. A grant answers
* "may this role send files", which is the wrong question: the person who should decide whether Fedra can
* put a file in your home directory is you, not whoever administers roles. So consent is data, it is
* per-pair, and absence denies — a sender who has not been allowed does not see the recipient as a target
* at all, rather than seeing one and being refused.
*
* The owner is exempt as a SENDER and only as a sender: they bypass every other check on this platform and
* there is no version of this where they cannot reach a home directory they already own. Receiving still
* needs their say-so, so nobody lands files in the owner's tree uninvited.
*/
export const sendoffSenders = pgTable(
'sendoff_senders',
{
id: serial('id').primaryKey(),
/** The account granting permission — the one whose tree gets written to. */
recipientUserId: integer('recipient_user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The account allowed to send to them. */
senderUserId: integer('sender_user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
(table) => [uniqueIndex('uq_sendoff_senders').on(table.recipientUserId, table.senderUserId)],
);
+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),
});
});

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