Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff647fba0a | ||
|
|
2f8a2b60d5 | ||
|
|
454a0f4244 | ||
|
|
a48a7ff696 | ||
|
|
bde59eb74e | ||
|
|
0b85d42197 | ||
|
|
2e846933e9 | ||
|
|
5e032898f8 | ||
|
|
91b9bec926 | ||
|
|
b978e09327 | ||
|
|
7f8455f1af | ||
|
|
f6b9ddc877 | ||
|
|
44f5246e38 | ||
|
|
9b0376ec39 | ||
|
|
66234cd3e0 | ||
|
|
ad8ed88519 | ||
|
|
7ce5af88aa | ||
|
|
6bfc1d38f7 | ||
|
|
c10dae3e6e | ||
|
|
41fffbebe9 | ||
|
|
c3c62828e3 | ||
|
|
926034750f | ||
|
|
6d8709c05a | ||
|
|
2765e241d3 | ||
|
|
36e18f935d | ||
|
|
0ddcdcbdbf | ||
|
|
99a78f3c7f | ||
|
|
845699e697 | ||
|
|
fe000541b7 | ||
|
|
dfbf9be61c | ||
|
|
be29ae7aa7 | ||
|
|
e3e790fd2b | ||
|
|
2b0b671895 | ||
|
|
2ec3f11cdd | ||
|
|
5ce1a2928e | ||
|
|
339d31f05c | ||
|
|
a444280cb4 | ||
|
|
c0788d15c0 | ||
|
|
7704867e05 | ||
|
|
04a4ab33dd | ||
|
|
b640ec486d | ||
|
|
0bd8c6c16a | ||
|
|
6ac9187c52 | ||
|
|
dcd11a3b17 | ||
|
|
3dd37d053b | ||
|
|
64894ad96e |
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
@@ -477,6 +477,9 @@ explaining why it was safe.
|
||||
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
|
||||
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
|
||||
remember, and the credential-resolution work that has to land first
|
||||
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
|
||||
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
|
||||
because otherwise "add a role" and "become the owner" are the same feature
|
||||
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
|
||||
web UI to more than one language. What exists and what it cannot do yet, why the server should return
|
||||
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
|
||||
|
||||
@@ -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:*",
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,61 @@
|
||||
# 2026.08.23.04
|
||||
|
||||
The web UI can be read in Portuguese. The shell and the shared components are fully keyed — 581 keys across
|
||||
two locales, with the build failing on a missing one — plus an endpoint for spawning an agent, and spawned
|
||||
agents becoming visible in both directions.
|
||||
|
||||
**No schema change.** Nothing under `src/databases/**/schema.ts` moved across
|
||||
`deploy/2026.08.23.03..HEAD`. `bun db:push` is still a step because it is cheap and it verifies the
|
||||
database agrees with the code, but it should report no changes.
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Localization, phases 0 through 2.** `src/apps/officer-web` and `src/workspaces/components` have no
|
||||
hardcoded user-facing strings left: 555 and 47 occurrences respectively, both now zero. `en-GB` and
|
||||
`pt-PT` are complete and in step at 581 keys.
|
||||
- **Every locale ships a complete catalogue.** There is no fallback between regional variants, so a
|
||||
Portuguese reader is never served Brazilian wording. What enforces that is a **key-parity test that fails
|
||||
the build** on a gap — verified by removing one key and watching it go red.
|
||||
- **The language picker offers only locales that exist.** It listed about twenty codes, eighteen of which
|
||||
silently rendered English. Stored bare codes widen on read (`en` → `en-GB`), with no database migration.
|
||||
- **Dates, numbers and plurals follow the account's locale**, not the browser's, through `useFormat()` and
|
||||
`Intl.PluralRules`. Roughly 23 call sites moved.
|
||||
- **`POST /api/sub-agents/spawn`** — an endpoint for starting an agent from a prompt, for callers that are
|
||||
not a chat and so cannot reach the harness's own tool. Budget-checked before spawning, and it refuses with
|
||||
a 429 naming the window rather than burning the last of one.
|
||||
- **Spawned agents are visible in both directions.** A commander's pane lists its children as links; opening
|
||||
one shows which commander started it, linking back.
|
||||
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
|
||||
error — so answering a question during a restart window vanished and the card spun forever.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.04`
|
||||
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: that means
|
||||
this tree and that host's database disagree about something this release did not touch.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
|
||||
report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
The visible change is the language picker in profile settings. It should offer exactly **English (UK)** and
|
||||
**Português (Portugal)** and nothing else. A host still offering twenty languages did not pick up the
|
||||
frontend bundle.
|
||||
|
||||
No direct SQL is needed and none is authorised.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||
- **`production-pertento-bridge`** — deployed cleanly last release after being two behind. Nothing expected.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
The rollout runs from here. It is already at this commit and `officer` has been restarted on it.
|
||||
+108
-53
@@ -25,46 +25,55 @@ the server does instead, which is the one decision that reaches outside this bou
|
||||
|
||||
## What exists today, and what it cannot do
|
||||
|
||||
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
|
||||
also about **5% deployed**, and that gap is the whole job.
|
||||
**Updated 2026-08-23, after phases 0, 1 and 2.** This section described the pre-phase-0 world for long
|
||||
enough that the next reader would have started from a false picture — bare `en.json`, 46 keys, three
|
||||
components. All of that is now history and is kept below only as the "before" column, because the shape of
|
||||
the gap is the argument for the plan and deleting it would leave the phases looking arbitrary.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
|
||||
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
|
||||
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
|
||||
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
|
||||
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
||||
| | before phase 0 | now |
|
||||
| -------------------------- | ---------------------- | --------------------------------------------- |
|
||||
| catalogs | `en.json`, `pt.json` | `en-GB.json`, `pt-PT.json` per workspace |
|
||||
| keys | 46 | **581** — 538 `app.*`, 43 `ui.*` |
|
||||
| workspaces with a catalog | 1 (`officer-web`) | 3 (`officer-web`, `components`, `officerdev`) |
|
||||
| components using it | 3 | all of `officer-web` and `components` |
|
||||
| plural rules | none | `Intl.PluralRules`, per locale |
|
||||
| dates and numbers | browser locale | `useFormat()`, the account's locale |
|
||||
| a missing key | rendered its dot-path | falls back to English, warns in dev |
|
||||
| locales offered vs shipped | ~20 offered, 2 existed | only shipped locales are offered |
|
||||
| parity enforcement | none | **a gap fails the build** |
|
||||
|
||||
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
|
||||
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
|
||||
typecheck failure, which is worth more than it sounds when one agent is editing 400 files.
|
||||
Still standing from the original description, and still true: the package is hand-rolled, has no npm
|
||||
dependency, and gives type-safe keys — a wrong key is a typecheck failure, which is worth more than it
|
||||
sounds when one agent is editing hundreds of files.
|
||||
|
||||
**What it cannot do yet**, each of which is a task below:
|
||||
**What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
|
||||
wired into the bridge, and empty.
|
||||
|
||||
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
|
||||
runtime and unused.
|
||||
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
|
||||
the *browser's* locale, not the user's chosen one, so a Portuguese UI shows American dates.
|
||||
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
|
||||
from `pt`.
|
||||
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
|
||||
`officerdev` and the 91 in `components` have no catalog of their own.
|
||||
5. **Silent, ugly misses.** A missing key renders its own dot-path (`settings.profile.foo`) to the
|
||||
user. No warning, no test.
|
||||
6. **Twenty languages offered, two that exist.** The picker in `Languages.tsx` lists ~20 codes
|
||||
including Hebrew and Greek. Choosing any of the eighteen silently yields English.
|
||||
Two things learned doing phases 1 and 2 that the plan did not anticipate:
|
||||
|
||||
- **`TranslationKeys` walked into a plural object**, so `t('key', { count })` — the entire point of D7 —
|
||||
did not compile while `t('key.one')` did. The documented feature was uncompilable and the workaround was
|
||||
not, which no test would ever have noticed because phase 1 contained no plurals. Fixed with type-level
|
||||
assertions.
|
||||
- **The typed wrappers rebuilt `t` on every render**, which is the trap `CLAUDE.md` already records against
|
||||
`useClient()` and the Jellyfin player. It matters the moment `t` lands in a `useMemo` dependency list,
|
||||
which phase 2 needed immediately. Memoised.
|
||||
|
||||
**A shape this format cannot express:** a sentence with an inline link. Splitting around the anchor gives a
|
||||
key whose Portuguese value is `"."`. The cheap fix is rephrasing so the link falls at a clause boundary;
|
||||
there is no node-level interpolation helper and adding one is a design decision, not a patch. Worth
|
||||
raising if a phase turns out to be full of them rather than absorbing it forty times.
|
||||
|
||||
### The size of the remaining work
|
||||
|
||||
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
|
||||
|
||||
| area | files | occurrences |
|
||||
| --- | --- | --- |
|
||||
| `officer-web` (shell) | 132 | 556 |
|
||||
| `officerdev` (panels) | 213 | 567 |
|
||||
| shared `components` | 91 | 47 |
|
||||
| **total** | **436** | **1170** |
|
||||
| area | files | occurrences |
|
||||
| --------------------- | ------- | ----------- |
|
||||
| `officer-web` (shell) | 132 | 556 |
|
||||
| `officerdev` (panels) | 213 | 567 |
|
||||
| shared `components` | 91 | 47 |
|
||||
| **total** | **436** | **1170** |
|
||||
|
||||
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
|
||||
|
||||
@@ -101,29 +110,46 @@ precise to quote. Prose drifts; a code does not. And the server stops needing to
|
||||
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
|
||||
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
|
||||
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
|
||||
the prose was careful not to be. When in doubt, one code for one *response*, not one per branch.
|
||||
the prose was careful not to be. When in doubt, one code for one _response_, not one per branch.
|
||||
|
||||
### D2. Regional locales, BCP 47, with fallback chains
|
||||
### D2. Regional locales, BCP 47, and every catalog is complete
|
||||
|
||||
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it,
|
||||
and it is the owner's own language, so getting it wrong is felt immediately.
|
||||
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
|
||||
it is the owner's own language, so getting it wrong is felt immediately.
|
||||
|
||||
Resolution walks a chain and stops at the first hit:
|
||||
**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
|
||||
every key the UI has, translated for that locale, or it does not ship.
|
||||
|
||||
```
|
||||
pt-BR → pt → en
|
||||
en-GB → en
|
||||
```
|
||||
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
|
||||
carried the shared bulk and a regional file carried only the differences. That is the conventional design
|
||||
and it was rejected for a product reason that outranks the engineering one:
|
||||
|
||||
So a **base catalog carries the shared bulk and a regional overlay carries only the differences.**
|
||||
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says
|
||||
differently. Two near-identical full catalogs would drift within a month.
|
||||
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
|
||||
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
|
||||
being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
|
||||
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
|
||||
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
|
||||
variant's words.
|
||||
|
||||
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
|
||||
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
|
||||
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
|
||||
by the test rather than by a fallback quietly papering over a gap.
|
||||
|
||||
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
|
||||
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
|
||||
base-versus-overlay merge order, and no question about which file a given string lives in.
|
||||
|
||||
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
|
||||
|
||||
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to
|
||||
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer
|
||||
widening on read over a database migration — a stale bare code should resolve, not fail.
|
||||
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
|
||||
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
|
||||
over a database migration — a stale bare code should resolve, not fail.
|
||||
|
||||
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
|
||||
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
|
||||
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
|
||||
the other.
|
||||
|
||||
### D3. RTL — prepare, do not ship
|
||||
|
||||
@@ -148,7 +174,7 @@ The gap is smaller than it looks, because the hard parts are in the runtime alre
|
||||
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
|
||||
lines of glue, listed in "What it cannot do yet".
|
||||
|
||||
Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms.
|
||||
Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms.
|
||||
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
|
||||
|
||||
### D5. Keys, not English source text
|
||||
@@ -223,7 +249,7 @@ app.errors.MINESWEEPER_NOT_RUNNING
|
||||
- `namespace` — `app` / `panel` / `ui`, per D6.
|
||||
- `area` — the route or panel it belongs to; matches the directory.
|
||||
- `component` — the file, lowercased.
|
||||
- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
|
||||
- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
|
||||
|
||||
Rules that keep 1200 keys navigable:
|
||||
|
||||
@@ -245,7 +271,19 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
|
||||
|
||||
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
||||
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error *codes*
|
||||
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
|
||||
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
|
||||
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
|
||||
account language, not by ours. Translating them sends somebody hunting for "Ativar" in a console that
|
||||
says "Enable".
|
||||
|
||||
So translate the connective prose around them and leave the quoted label in English. Do not key it with
|
||||
identical values in both catalogs either — that is a key whose two sides can never legitimately differ,
|
||||
which is a maintenance cost bought for nothing.
|
||||
|
||||
Decided 2026-08-23 on `GoogleOAuthConfig.tsx`, where 55 of the file's 67 strings are of this kind.
|
||||
|
||||
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
|
||||
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
|
||||
names, database values.
|
||||
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
|
||||
@@ -304,9 +342,13 @@ Without these, 1200 keys rot. All three are small and belong in phase 0.
|
||||
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
|
||||
meter: the count goes down as work lands.
|
||||
|
||||
**A key-parity test.** For each shipped locale, every key that `en` has must resolve through the
|
||||
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the
|
||||
one guard that stops a locale silently degrading into English.
|
||||
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
|
||||
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
|
||||
`bun docs:check` fails on a stale number.
|
||||
|
||||
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
|
||||
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
|
||||
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
|
||||
|
||||
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
|
||||
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
|
||||
@@ -339,7 +381,20 @@ else will ever notice.
|
||||
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
|
||||
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
|
||||
manifest. Their strings are not counted above.
|
||||
4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not
|
||||
|
||||
**One constraint that contract has to answer, found while converting the shared components
|
||||
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
|
||||
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
|
||||
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
|
||||
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
|
||||
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
|
||||
|
||||
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
|
||||
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
|
||||
provider-absent fallback inside the `components` package was considered and rejected: it would have
|
||||
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
|
||||
|
||||
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
|
||||
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
|
||||
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
|
||||
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Roles and teams
|
||||
|
||||
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
|
||||
design, written down so the work starts from a spec rather than from a conversation.
|
||||
|
||||
## What this is
|
||||
|
||||
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
|
||||
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
|
||||
a homelab and it cannot express a company, where the same person is an administrator of one area and an
|
||||
ordinary member of another.
|
||||
|
||||
Two things are wanted, and they are separable:
|
||||
|
||||
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
|
||||
provisioning.
|
||||
- **Teams**, so a user belongs to several and holds a different role in each.
|
||||
|
||||
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
|
||||
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
|
||||
uses both. Nothing below may make teams mandatory to reach a feature.
|
||||
|
||||
---
|
||||
|
||||
## What already landed — phase 0, 2026-08-23
|
||||
|
||||
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
|
||||
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
|
||||
|
||||
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
|
||||
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
|
||||
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
|
||||
|
||||
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
|
||||
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
|
||||
Verified against a scratch database: push reported the violation, created the table, skipped the
|
||||
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. The owner is an identity. It is never a role.
|
||||
|
||||
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
|
||||
|
||||
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
|
||||
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
|
||||
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
|
||||
whatever UI manages roles.
|
||||
|
||||
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
|
||||
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
|
||||
else can wait behind it.
|
||||
|
||||
### D2. Several Super Admins are allowed, and the role provisions sudo
|
||||
|
||||
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
|
||||
a company has an owner and several administrators, and a homelab owner may add one or never do.
|
||||
|
||||
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
|
||||
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
|
||||
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
|
||||
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
|
||||
they left behind.
|
||||
|
||||
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
|
||||
the UI to say what it is rather than presenting it as one more row in a dropdown.
|
||||
|
||||
### D3. `Admin` is app-wide authority with no operating-system privilege
|
||||
|
||||
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
|
||||
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
|
||||
"administering the platform is not developing on it". This decision only writes that down.
|
||||
|
||||
### D4. A role may be deleted, but not while anyone holds it
|
||||
|
||||
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
|
||||
nobody asked for, and "absence denies" is the posture everywhere else in this system.
|
||||
|
||||
`isSystem` roles refuse both delete and rename, because the code names them by name:
|
||||
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
|
||||
`Member`. Renaming one is a code change that has not happened yet.
|
||||
|
||||
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
|
||||
|
||||
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
|
||||
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
|
||||
rename cascades to every user and every grant, atomically, with no code change.
|
||||
|
||||
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
|
||||
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
|
||||
|
||||
### D6. A team is a scope. Permissions UNION across a user's teams.
|
||||
|
||||
Admin in marketing and member in developers means marketing's admin level does not reach developers'
|
||||
files — but a user's effective permission for a given capability is the **highest level any of their teams
|
||||
grants**, plus their global role.
|
||||
|
||||
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
|
||||
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
|
||||
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
|
||||
should be refused rather than half-built.
|
||||
|
||||
### D7. The global role remains, and answers a different question
|
||||
|
||||
Two questions, two mechanisms:
|
||||
|
||||
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
|
||||
- **"What may this account do in this area?"** — team membership with a role in that team.
|
||||
|
||||
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
|
||||
has no natural team to ask it about. A homelab uses only the first and never makes a team.
|
||||
|
||||
### D8. Per-role provisioning is a script the role carries
|
||||
|
||||
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
|
||||
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
|
||||
in code that should become a row.
|
||||
|
||||
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
|
||||
rootless specifically. `usermod -aG docker` is root by a longer route —
|
||||
`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet seed. Any
|
||||
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0 — roles are a table ✅ DONE 2026-08-23
|
||||
|
||||
Additive only. Nothing reads it.
|
||||
|
||||
### Phase 1 — the owner stops being a role
|
||||
|
||||
The load-bearing phase, and small.
|
||||
|
||||
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
|
||||
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
|
||||
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
|
||||
"owner or Super Admin" is a separate, reviewable change per route.
|
||||
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
|
||||
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
|
||||
|
||||
Reviewable on its own, and nothing downstream can start until ownership is an identity.
|
||||
|
||||
### Phase 2 — the foreign key, and role CRUD
|
||||
|
||||
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
|
||||
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
|
||||
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
|
||||
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
|
||||
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
|
||||
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
|
||||
replace what the compiler was doing.
|
||||
|
||||
### Phase 3 — per-role provisioning
|
||||
|
||||
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
|
||||
D8's warning in front of whoever writes the first script.
|
||||
|
||||
### Phase 4 — teams
|
||||
|
||||
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
|
||||
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
|
||||
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
|
||||
one table with a nullable `team_id`.
|
||||
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
|
||||
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
|
||||
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
|
||||
4. Teams UI, and the account screen showing which teams a user is in and as what.
|
||||
|
||||
### Phase 5 — the thing that actually unblocks all of it
|
||||
|
||||
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
|
||||
for accounts that cannot yet exist.
|
||||
|
||||
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
|
||||
find nothing to test it with. **It may be worth doing first.**
|
||||
|
||||
---
|
||||
|
||||
## What must not be built
|
||||
|
||||
- **Deny rules.** See D6. Teams grant; they never restrict.
|
||||
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
|
||||
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
|
||||
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
|
||||
the answer is a `confined` grant with a Linux user, which is per-account by nature.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
|
||||
binary directly.
|
||||
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
|
||||
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
|
||||
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
|
||||
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
|
||||
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
|
||||
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
|
||||
or a self-service one.
|
||||
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
|
||||
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
|
||||
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
|
||||
holds globally; the same question exists per team and is not answered here.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.23.02",
|
||||
"version": "2026.08.23.04",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -30,7 +30,7 @@
|
||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts",
|
||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
|
||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||
|
||||
@@ -9,6 +9,27 @@ one.
|
||||
|
||||
---
|
||||
|
||||
## Arming for the first time — there is nothing to rehearse against
|
||||
|
||||
General commander doctrine says to run the action once before arming, so it is known to work unattended.
|
||||
**That does not apply here, and reading it as "replay the last release" is wrong.**
|
||||
|
||||
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
|
||||
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
|
||||
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
|
||||
release, which you must never do.
|
||||
|
||||
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
|
||||
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
|
||||
fights the claim or invents an occasion.
|
||||
|
||||
Two things follow from it, and they are the reason this is fine rather than a gap:
|
||||
|
||||
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
|
||||
What is unproven is this loop, not the rollout.
|
||||
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
|
||||
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
|
||||
|
||||
## What wakes you
|
||||
|
||||
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
|
||||
@@ -192,6 +213,51 @@ assemble at the end from memory is a report that will be wrong about the host th
|
||||
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
||||
one that stopped in the middle and said nothing about the four hosts it never reached.
|
||||
|
||||
### Schema changes, and why `db:push` cannot be trusted
|
||||
|
||||
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
|
||||
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
|
||||
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
|
||||
success while the schema is half-applied.
|
||||
|
||||
So on any release whose plan says the schema changed:
|
||||
|
||||
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
|
||||
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
|
||||
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
|
||||
a guess.
|
||||
|
||||
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
|
||||
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
|
||||
table empty and adds the constraint in the same run, so every existing row violates it. There is no
|
||||
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
|
||||
|
||||
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
|
||||
|
||||
This is allowed and it is not a licence to improvise. The rules, in order:
|
||||
|
||||
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
|
||||
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
|
||||
that is a code change and it is not yours to make on five production hosts at 3am.
|
||||
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
|
||||
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
|
||||
the fleet has diverged in a way nothing will detect.
|
||||
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
|
||||
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
|
||||
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
|
||||
forensic exercise.
|
||||
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
|
||||
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
|
||||
finding for the report rather than something to push past.
|
||||
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
|
||||
the specific check, and its answer, for each.
|
||||
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
|
||||
repair you invented.
|
||||
|
||||
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
|
||||
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
|
||||
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
|
||||
|
||||
### Skip the host you are running on
|
||||
|
||||
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
||||
|
||||
@@ -38,6 +38,16 @@ const KIND_MEANING: Record<string, string> = {
|
||||
export async function renderPlatformSkill(): Promise<string> {
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
|
||||
const enCatalog = (await import('../src/apps/officer-web/locales/en-GB.json')).default as Record<string, unknown>;
|
||||
/** Resolve a dot-path against the English catalog. This doc is for agents and stays in one language. */
|
||||
const enString = (key: string): string => {
|
||||
const value = key.split('.').reduce<unknown>((node, part) => {
|
||||
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, enCatalog);
|
||||
if (typeof value !== 'string') throw new Error(`gen-platform-skill: no English string for "${key}"`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const byKind = new Map<string, typeof PERMISSIONS>();
|
||||
for (const p of PERMISSIONS) {
|
||||
@@ -112,14 +122,16 @@ export async function renderPlatformSkill(): Promise<string> {
|
||||
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
|
||||
out('list rather than remembering one.');
|
||||
out();
|
||||
// The registry holds catalog keys since the shell was localized; this doc is agent-facing and stays
|
||||
// English, so it resolves against en-GB rather than the reader's locale.
|
||||
for (const section of SHORTCUT_SECTIONS) {
|
||||
out(`## ${section.title}`);
|
||||
if (section.note) {
|
||||
out(`## ${enString(section.titleKey)}`);
|
||||
if (section.noteKey) {
|
||||
out();
|
||||
out(section.note);
|
||||
out(enString(section.noteKey));
|
||||
}
|
||||
out();
|
||||
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`);
|
||||
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${enString(s.descriptionKey)}`);
|
||||
out();
|
||||
}
|
||||
|
||||
|
||||
+102
-11
@@ -26,29 +26,52 @@ const AREAS = [
|
||||
/**
|
||||
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
|
||||
* zero — enabling every area on day one would just be red for weeks and get ignored.
|
||||
*
|
||||
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
|
||||
* that must NEVER be translated; they are reported but excluded from the check, which is why the
|
||||
* neverTranslate split below has to exist for this entry to be possible at all.
|
||||
*
|
||||
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
|
||||
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
|
||||
* `stripUntranslated` skips for the same reason.
|
||||
*/
|
||||
const ZEROED: string[] = [];
|
||||
const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
|
||||
|
||||
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
||||
|
||||
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
|
||||
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
|
||||
|
||||
const PATTERNS: { kind: string; rx: RegExp }[] = [
|
||||
/**
|
||||
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
|
||||
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
|
||||
*
|
||||
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
|
||||
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
|
||||
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
|
||||
*/
|
||||
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
||||
// >Some visible words<
|
||||
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
|
||||
// placeholder="..." and friends
|
||||
{
|
||||
kind: 'attributes',
|
||||
rx: new RegExp(String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q, 'g'),
|
||||
rx: new RegExp(
|
||||
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
|
||||
'g',
|
||||
),
|
||||
},
|
||||
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
|
||||
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
|
||||
{
|
||||
kind: 'object labels',
|
||||
rx: new RegExp(String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q, 'g'),
|
||||
rx: new RegExp(
|
||||
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
|
||||
'g',
|
||||
),
|
||||
},
|
||||
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g') },
|
||||
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
|
||||
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
|
||||
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
|
||||
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||
];
|
||||
@@ -61,10 +84,60 @@ function isHuman(value: string | undefined): value is string {
|
||||
if (!/[A-Za-z]{3}/.test(s)) return false;
|
||||
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
|
||||
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
|
||||
if (/^[A-Z0-9_]+$/.test(s)) return false;
|
||||
// Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
|
||||
if (/^[A-Z0-9_.-]+$/.test(s)) return false;
|
||||
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
|
||||
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
|
||||
// followed by a credential is never something a person reads.
|
||||
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
|
||||
// A single token carrying digits AND punctuation is a name, not a sentence — the OCR section's
|
||||
// placeholder is the example model file `Qwen2.5-VL-7B-Instruct-q4_k_m.gguf`. Prose has spaces; the
|
||||
// whitespace test is what keeps this from eating real one-word labels, which never look like this.
|
||||
if (!/\s/.test(s) && /\d/.test(s) && /[-_.]/.test(s)) return false;
|
||||
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
|
||||
// `description: 'group-[.toast]:text-muted-foreground'`.
|
||||
//
|
||||
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
|
||||
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
|
||||
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
|
||||
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
|
||||
const tokens = s.split(/\s+/);
|
||||
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
|
||||
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
|
||||
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
|
||||
*
|
||||
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
|
||||
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
|
||||
*/
|
||||
const stripComments = (source: string): string =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
||||
|
||||
/**
|
||||
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
|
||||
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
|
||||
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
|
||||
* thing it documents.
|
||||
*/
|
||||
const stripCodeElements = (source: string): string =>
|
||||
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
|
||||
|
||||
/**
|
||||
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
|
||||
* another product's UI, which docs/localization.md classifies as an identifier.
|
||||
*
|
||||
* Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
|
||||
* doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
|
||||
* carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
|
||||
* it and say why.
|
||||
*/
|
||||
const stripUntranslated = (source: string): string =>
|
||||
source.replace(/<Untranslated\b[^>]*>[\s\S]*?<\/Untranslated>/g, '<Untranslated/>');
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -96,7 +169,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
|
||||
files++;
|
||||
let source: string;
|
||||
try {
|
||||
source = readFileSync(file, 'utf8');
|
||||
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -118,7 +191,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
|
||||
const kinds = PATTERNS.map((p) => p.kind);
|
||||
const pad = (s: string | number, n: number) => String(s).padStart(n);
|
||||
|
||||
console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`);
|
||||
console.log(
|
||||
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
|
||||
);
|
||||
for (const r of results) {
|
||||
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
|
||||
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
|
||||
@@ -128,9 +203,19 @@ const all = results.flatMap((r) => r.hits);
|
||||
const distinct = new Set(all.map((h) => h.value));
|
||||
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
|
||||
|
||||
console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`);
|
||||
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
|
||||
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
|
||||
|
||||
console.log(
|
||||
`${'TOTAL'.padEnd(22)} ${pad(
|
||||
results.reduce((n, r) => n + r.files, 0),
|
||||
5,
|
||||
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
|
||||
);
|
||||
console.log();
|
||||
console.log(`occurrences ${all.length}`);
|
||||
console.log(` convertible ${convertible(all).length}`);
|
||||
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
|
||||
console.log(`distinct strings ${distinct.size}`);
|
||||
console.log(` needing params ${interpolated.size}`);
|
||||
|
||||
@@ -149,10 +234,16 @@ if (listPrefix) {
|
||||
}
|
||||
|
||||
if (args.has('--check')) {
|
||||
const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0);
|
||||
const regressed = results
|
||||
.filter((r) => ZEROED.includes(r.label))
|
||||
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
|
||||
.filter((r) => r.hits.length > 0);
|
||||
if (regressed.length > 0) {
|
||||
console.error('\nThese areas were converted and have regressed:');
|
||||
for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
|
||||
for (const r of regressed) {
|
||||
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
|
||||
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
|
||||
}
|
||||
console.error('\nUse a catalog key. See docs/localization.md.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Seed the `roles` table with the roles the platform's own code names.
|
||||
*
|
||||
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
|
||||
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
|
||||
* mechanism `backfill-session-models.ts` relies on.
|
||||
*
|
||||
* ── Why these four are `isSystem` ──
|
||||
*
|
||||
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
|
||||
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
|
||||
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
|
||||
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
|
||||
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
|
||||
*
|
||||
* ── What this deliberately does NOT do ──
|
||||
*
|
||||
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
|
||||
* and adds the constraint in the same run, so every existing account violates it — verified against a
|
||||
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
|
||||
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
|
||||
*
|
||||
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
|
||||
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
|
||||
* unattended.
|
||||
*
|
||||
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
|
||||
*/
|
||||
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||
|
||||
const SEED: { name: string; description: string }[] = [
|
||||
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
|
||||
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
|
||||
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
|
||||
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await waitForDatabase();
|
||||
let created = 0;
|
||||
for (const role of SEED) {
|
||||
if (await seedRole({ ...role, isSystem: true })) created += 1;
|
||||
}
|
||||
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
|
||||
}
|
||||
|
||||
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
|
||||
// a successful push look like a failed one — the rows are additive and the next push tries again.
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
|
||||
})
|
||||
.finally(() => process.exit(0));
|
||||
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export function ForgotPassword() {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient('/api/auth');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
@@ -34,29 +36,29 @@ export function ForgotPassword() {
|
||||
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||
{done ? (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">{t('auth.checkEmail')}</h2>
|
||||
<div className="text-sm text-duck-dark/60">
|
||||
<p>
|
||||
A password reset link has been sent to <strong>{state.email}</strong>.
|
||||
</p>
|
||||
<p>Click it to reset your password.</p>
|
||||
<p>{t('auth.checkEmailHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div>
|
||||
<div className="text-duck-dark/60">Enter your email to receive a reset link</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.forgotTitle')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.forgotSubtitle')}</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<span className="text-duck-dark/70">{t('auth.email')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
@@ -74,7 +76,7 @@ export function ForgotPassword() {
|
||||
<div className="text-center text-sm text-duck-dark/60">
|
||||
<p>
|
||||
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Back to login
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useResetPassword } from './useResetPassword';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export function ResetPassword() {
|
||||
const { t } = useTranslation();
|
||||
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
|
||||
const loading = tokenStatus === 'loading';
|
||||
const invalid = tokenStatus === 'invalid';
|
||||
@@ -15,45 +17,45 @@ export function ResetPassword() {
|
||||
<>
|
||||
{loading && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
||||
<div className="text-center text-duck-dark/60">{t('auth.verifying')}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invalid && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.invalidLink')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.invalidLinkHint')}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
||||
<div className="text-duck-dark/60">Enter your new password</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.resetTitle')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.resetSubtitle')}</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<div className="grid md:flex gap-4">
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 characters"
|
||||
placeholder={t('auth.minChars')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
placeholder={t('auth.confirmPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
@@ -65,7 +67,7 @@ export function ResetPassword() {
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||
{isSubmitting ? 'Resetting...' : t('auth.resetTitle')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { toast } from 'sonner';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const useResetPassword = () => {
|
||||
const { t } = useTranslation();
|
||||
const isMounted = useMounted();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
@@ -44,7 +46,7 @@ export const useResetPassword = () => {
|
||||
password: state.password,
|
||||
verificationCode,
|
||||
});
|
||||
toast.success('Password reset successfully. Please sign in.');
|
||||
toast.success(t('auth.resetOk'));
|
||||
navigate('/');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export function Bootstrap() {
|
||||
const { t } = useTranslation();
|
||||
const authClient = useClient('/api/auth');
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -30,7 +32,7 @@ export function Bootstrap() {
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
toast.success('Administrator account created. Please sign in.');
|
||||
toast.success(t('auth.bootstrapOk'));
|
||||
navigate('/');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
@@ -43,35 +45,35 @@ export function Bootstrap() {
|
||||
return (
|
||||
<Card className="md:min-w-[480px] flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Welcome, Admin</div>
|
||||
<div className="text-duck-dark/60">Create your administrator account</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.bootstrapTitle')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.bootstrapSubtitle')}</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<span className="text-duck-dark/70">{t('auth.name')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<span className="text-duck-dark/70">{t('auth.email')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="admin@example.com"
|
||||
placeholder={t('auth.adminEmailPlaceholder')}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Username</span>
|
||||
<span className="text-duck-dark/70">{t('auth.username')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
@@ -83,23 +85,23 @@ export function Bootstrap() {
|
||||
|
||||
<div className="grid md:flex gap-4">
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 characters"
|
||||
placeholder={t('auth.minChars')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
placeholder={t('auth.confirmPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
@@ -8,12 +8,14 @@ import { toast } from 'sonner';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const initialState: LoginFormState = {
|
||||
// email: 'pastilhas@pastilhas.dev',
|
||||
// password: '',
|
||||
};
|
||||
export function Login() {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
|
||||
const { signin } = useAuth();
|
||||
@@ -40,20 +42,20 @@ export function Login() {
|
||||
return (
|
||||
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
||||
<div className="text-duck-dark/60">Sign in to your account</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.signInTitle')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.signInSubtitle')}</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email or username</span>
|
||||
<span className="text-duck-dark/70">{t('auth.emailOrUsername')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
// `text`, not `email`: the browser refuses to submit a username under type="email", so the
|
||||
// field would have validated the user out of an account they have.
|
||||
type="text"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
// `username` rather than `email`, which is what password managers expect for a field that
|
||||
// takes either — and it is the value they already store beside the password.
|
||||
autoComplete="username"
|
||||
@@ -61,12 +63,12 @@ export function Login() {
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Label>
|
||||
@@ -90,7 +92,7 @@ export function Login() {
|
||||
{/* </p> */}
|
||||
<p>
|
||||
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Forgot password?
|
||||
{t('auth.forgotPassword')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type DebugPanelProps = {
|
||||
fullControlMode: boolean;
|
||||
cameraInfo: {
|
||||
@@ -13,9 +15,10 @@ type DebugPanelProps = {
|
||||
};
|
||||
|
||||
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
|
||||
<div className="font-bold text-blue-400">Controls:</div>
|
||||
<div className="font-bold text-blue-400">{t('auth.debug.controls')}</div>
|
||||
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
|
||||
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
|
||||
</div>
|
||||
@@ -29,12 +32,12 @@ export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPane
|
||||
<div>* Ctrl+Alt+Enter: Toggle full control</div>
|
||||
<div>* Ctrl+Alt+D: Toggle debug panel</div>
|
||||
|
||||
<div className="font-bold text-green-400 pt-2">Camera:</div>
|
||||
<div className="font-bold text-green-400 pt-2">{t('auth.debug.camera')}</div>
|
||||
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
|
||||
|
||||
<div className="font-bold text-yellow-400 pt-2">Model:</div>
|
||||
<div className="font-bold text-yellow-400 pt-2">{t('auth.debug.model')}</div>
|
||||
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
|
||||
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
|
||||
@@ -15,6 +16,7 @@ const MAX_LINES = 600;
|
||||
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
|
||||
// the screen re-derives the param from the registry row — one address shape for both kinds.
|
||||
export const ActivityScreen = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, get } = useClient();
|
||||
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
|
||||
const [regLoaded, setRegLoaded] = useState(false);
|
||||
@@ -87,30 +89,32 @@ export const ActivityScreen = () => {
|
||||
<div className="flex h-full w-full">
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<ActivityIcon size={16} className="text-primary" /> Activity
|
||||
<ActivityIcon size={16} className="text-primary" /> {t('activity.title')}
|
||||
</div>
|
||||
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Background tasks
|
||||
{t('activity.backgroundTasks')}
|
||||
</div>
|
||||
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
||||
{reg.tasks.map((t) => (
|
||||
{reg.tasks.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t('activity.noneRunning')}</p>
|
||||
)}
|
||||
{reg.tasks.map((task) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
to={`/activity/${encodeURIComponent(t.id)}`}
|
||||
className={rowCls(selectedId === t.id)}
|
||||
title={t.cwd}
|
||||
key={task.id}
|
||||
to={`/activity/${encodeURIComponent(task.id)}`}
|
||||
className={rowCls(selectedId === task.id)}
|
||||
title={task.cwd}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${task.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
/>
|
||||
<span className="truncate font-mono text-xs">{t.id}</span>
|
||||
<span className="truncate font-mono text-xs">{task.id}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{reg.detached.length > 0 && (
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Detached
|
||||
{t('activity.detached')}
|
||||
</div>
|
||||
)}
|
||||
{reg.detached.map((d) => (
|
||||
@@ -169,7 +173,7 @@ export const ActivityScreen = () => {
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
||||
it never started.{' '}
|
||||
<Link to="/activity" className="underline">
|
||||
Back to the list
|
||||
{t('activity.backToList')}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
@@ -187,7 +191,7 @@ export const ActivityScreen = () => {
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Select a task to follow its live output
|
||||
{t('activity.selectTask')}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useChat, EmbeddableChat } from 'officerdev';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
type CapabilitySummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
@@ -277,12 +278,18 @@ export const CapabilityChat = ({
|
||||
description,
|
||||
onResponseEnd,
|
||||
}: CapabilityChatProps) => {
|
||||
const { t } = useTranslation();
|
||||
const seedFile = `${kind.toUpperCase()}.md`;
|
||||
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
||||
const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix;
|
||||
/*
|
||||
The composer's PRE-FILLED text, which the reader sees and edits — so it is translated. The hidden
|
||||
`promptFrontmatter` above is not: that one instructs the agent and carries file paths, and is
|
||||
machine-facing rather than something anybody reads.
|
||||
*/
|
||||
const defaultInput = isNew
|
||||
? (description ?? `Help me create the content for this new ${kind} file`)
|
||||
: `Help me understand and improve this ${kind} file`;
|
||||
? (description ?? t('capability.createPrompt', { kind: kind.toLowerCase() }))
|
||||
: t('capability.improvePrompt', { kind: kind.toLowerCase() });
|
||||
|
||||
const chat = useChat(undefined, undefined, { replaceUrl: false });
|
||||
|
||||
@@ -301,6 +308,7 @@ export const CapabilityChat = ({
|
||||
};
|
||||
|
||||
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -310,7 +318,7 @@ export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<span className="text-xs font-medium">Frontmatter</span>
|
||||
<span className="text-xs font-medium">{t('capability.frontmatter')}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
|
||||
@@ -330,6 +338,7 @@ export const CapabilityList = ({
|
||||
showCreate,
|
||||
onShowCreateChange,
|
||||
}: CapabilityListProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [internalCreating, setInternalCreating] = useState(false);
|
||||
@@ -356,7 +365,7 @@ export const CapabilityList = ({
|
||||
setNewName('');
|
||||
onCreate(res.dirName);
|
||||
} catch {
|
||||
toast.error(`Failed to create ${kind}`);
|
||||
toast.error(t('capability.createFailed', { kind: kind.toLowerCase() }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -427,7 +436,7 @@ export const CapabilityList = ({
|
||||
<input
|
||||
value={internalSearch}
|
||||
onChange={(ev) => setInternalSearch(ev.target.value)}
|
||||
placeholder={`Search ${kind.toLowerCase()}s...`}
|
||||
placeholder={t('capability.searchPlaceholder', { kind: `${kind.toLowerCase()}s` })}
|
||||
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
@@ -452,7 +461,7 @@ export const CapabilityList = ({
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
||||
)}
|
||||
{items.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">{t('capability.noMatches')}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -469,6 +478,7 @@ export const CapabilityList = ({
|
||||
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
|
||||
*/
|
||||
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
@@ -497,7 +507,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
navigate(basePath, { replace: true });
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${kind}`);
|
||||
toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -526,7 +536,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</Link>
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
||||
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
|
||||
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
|
||||
</span>
|
||||
{detail && (
|
||||
<>
|
||||
@@ -554,10 +564,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
) : detail ? (
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">{t('capability.emptyFile')}</p>
|
||||
) : (
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">
|
||||
Select a {kind.toLowerCase()} to view its contents
|
||||
{t('capability.selectToView', { kind: kind.toLowerCase() })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -606,10 +616,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
||||
Delete
|
||||
{t('capability.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { errorText } from 'helpers/error-text';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||
import { defaultLayout, hasAppType } from './defaultLayout';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
||||
const CHAT_TAIL = 20;
|
||||
@@ -18,6 +19,7 @@ type SessionListPageProps = {
|
||||
};
|
||||
|
||||
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
|
||||
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
|
||||
const groupCwd = cwdFromSplat(splat);
|
||||
@@ -122,7 +124,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
|
||||
// no hint that the transcript could not be read, which is indistinguishable from a new session.
|
||||
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
|
||||
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
|
||||
toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') }));
|
||||
setSelected({ id: sessionId });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { CodeEditorView } from 'officerdev';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const CodeEditor = () => (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
||||
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
|
||||
export const CodeEditor = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Widget title={t('nav.codeEditor')} className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
||||
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
|
||||
wrapper deliberately does not pass this: a dashboard can hold two editors. */}
|
||||
<CodeEditorView className="h-full w-full" urlState />
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
<CodeEditorView className="h-full w-full" urlState />
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useParams, Link } from 'react-router';
|
||||
import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type TFn = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
|
||||
// from the job's persisted progress (the executor emits counter snapshots, not per-item events).
|
||||
@@ -29,26 +32,40 @@ type DownloadJob = {
|
||||
const POLL_MS = 1500;
|
||||
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
// Takes `t` because it is a module-level helper, not a component — the alternative is a hook
|
||||
// call outside a component body.
|
||||
const statusBadge = (status: string, t: TFn) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
label: 'Running',
|
||||
label: t('jobs.status.running'),
|
||||
cls: 'text-amber-600 dark:text-amber-500',
|
||||
};
|
||||
case 'pending':
|
||||
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
label: t('jobs.status.queued'),
|
||||
cls: 'text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
case 'completed':
|
||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
|
||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
|
||||
case 'failed':
|
||||
return { icon: <XCircle className="h-4 w-4" />, label: 'Failed', cls: 'text-red-600 dark:text-red-400' };
|
||||
return {
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
label: t('jobs.status.failed'),
|
||||
cls: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
case 'stopped':
|
||||
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
|
||||
return {
|
||||
icon: <Ban className="h-4 w-4" />,
|
||||
label: t('jobs.status.stopped'),
|
||||
cls: 'text-duck-dark/50 dark:text-foreground/50',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
label: 'Interrupted',
|
||||
label: t('jobs.status.interrupted'),
|
||||
cls: 'text-orange-600 dark:text-orange-400',
|
||||
};
|
||||
}
|
||||
@@ -100,6 +117,7 @@ export const PhaseBar = ({
|
||||
};
|
||||
|
||||
export const DownloadJobDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const client = useClient();
|
||||
const [job, setJob] = useState<DownloadJob | null>(null);
|
||||
@@ -134,9 +152,9 @@ export const DownloadJobDetail = () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await client.post(`/jobs/${id}/stop`, {});
|
||||
toast.success('Stop requested');
|
||||
toast.success(t('jobs.stopRequested'));
|
||||
} catch {
|
||||
toast.error('Failed to stop');
|
||||
toast.error(t('jobs.stopFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,14 +167,14 @@ export const DownloadJobDetail = () => {
|
||||
if (!job)
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
|
||||
<span>Job not found.</span>
|
||||
<span>{t('jobs.notFound')}</span>
|
||||
<Link to="/jobs" className="text-duck-teal hover:underline">
|
||||
Back to jobs
|
||||
{t('jobs.backToJobs')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const badge = statusBadge(job.status);
|
||||
const badge = statusBadge(job.status, t);
|
||||
const running = !isTerminal(job.status);
|
||||
const p = job.progress;
|
||||
const isAudio = job.inputs?.format !== 'video';
|
||||
@@ -197,7 +215,7 @@ export const DownloadJobDetail = () => {
|
||||
onClick={stop}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
|
||||
>
|
||||
<Square className="h-3 w-3" /> Stop
|
||||
<Square className="h-3 w-3" /> {t('jobs.stop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -206,14 +224,14 @@ export const DownloadJobDetail = () => {
|
||||
<div className="mx-auto flex max-w-md flex-col gap-5">
|
||||
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
|
||||
<PhaseBar
|
||||
label="Titles"
|
||||
label={t('jobs.titles')}
|
||||
c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
|
||||
active={p?.phase === 'metadata'}
|
||||
savedLabel="found"
|
||||
failedLabel="skipped"
|
||||
/>
|
||||
<PhaseBar
|
||||
label="Download"
|
||||
label={t('jobs.download')}
|
||||
c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
|
||||
active={p?.phase === 'download'}
|
||||
savedLabel="saved"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { randomId } from 'helpers/random-id';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
|
||||
@@ -162,6 +163,7 @@ type ToolCallEntryProps = {
|
||||
};
|
||||
|
||||
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -183,14 +185,14 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||
{expanded && (
|
||||
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
|
||||
<div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Input</div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.input')}</div>
|
||||
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
|
||||
{JSON.stringify(entry.toolInput, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{entry.output !== undefined && (
|
||||
<div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.output')}</div>
|
||||
<pre
|
||||
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
|
||||
>
|
||||
@@ -236,6 +238,7 @@ const DEFAULT_LAYOUT: LayoutNode = {
|
||||
};
|
||||
|
||||
const StepsPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useJobPanel();
|
||||
const {
|
||||
displaySteps,
|
||||
@@ -255,7 +258,7 @@ const StepsPanel = () => {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-duck-dark/10">
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Steps</h2>
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.steps')}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{displaySteps.map((step, i) => {
|
||||
@@ -336,6 +339,7 @@ const StepsPanel = () => {
|
||||
};
|
||||
|
||||
const OutputPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useJobPanel();
|
||||
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
|
||||
|
||||
@@ -345,7 +349,7 @@ const OutputPanel = () => {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.output')}</h2>
|
||||
{selectedKey && (
|
||||
<span className="text-xs text-duck-dark/40 truncate">
|
||||
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
|
||||
@@ -355,12 +359,12 @@ const OutputPanel = () => {
|
||||
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
|
||||
{!selectedKey && (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
||||
Select a step or sub-task to view output
|
||||
{t('jobs.selectStep')}
|
||||
</div>
|
||||
)}
|
||||
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
||||
No output yet
|
||||
{t('jobs.noOutput')}
|
||||
</div>
|
||||
)}
|
||||
{selectedOutputs.map((entry) => {
|
||||
@@ -385,6 +389,7 @@ const OutputPanel = () => {
|
||||
};
|
||||
|
||||
export const PipelineJobDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const client = useClient();
|
||||
|
||||
@@ -792,15 +797,15 @@ export const PipelineJobDetail = () => {
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
|
||||
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">{t('jobs.loading')}</div>;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
|
||||
<span>Job not found</span>
|
||||
<span>{t('jobs.notFound')}</span>
|
||||
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
|
||||
Back to jobs
|
||||
{t('jobs.backToJobs')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -821,7 +826,11 @@ export const PipelineJobDetail = () => {
|
||||
<Card className="px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* The other two job detail panes already do this; this was the last imperative back in Jobs. */}
|
||||
<Link to="/jobs" aria-label="Back to jobs" className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
||||
<Link
|
||||
to="/jobs"
|
||||
aria-label={t('jobs.backToJobs')}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -837,7 +846,7 @@ export const PipelineJobDetail = () => {
|
||||
{isRunning && (
|
||||
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
|
||||
<Square className="h-3 w-3" />
|
||||
Stop
|
||||
{t('jobs.stop')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -852,13 +861,13 @@ export const PipelineJobDetail = () => {
|
||||
{displayCost && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
||||
<span className="text-duck-dark/40">Tokens:</span>
|
||||
<span className="text-duck-dark/40">{t('jobs.tokens')}</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
||||
<span className="text-duck-dark/40">Cost:</span>
|
||||
<span className="text-duck-dark/40">{t('jobs.cost')}</span>
|
||||
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Card } from '@/components/Card';
|
||||
import { ScriptJobDetail } from './ScriptJobDetail';
|
||||
import { DownloadJobDetail } from './DownloadJobDetail';
|
||||
import { PipelineJobDetail } from './JobDetail';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type JobSummary = {
|
||||
id: string;
|
||||
@@ -99,6 +100,7 @@ const useJobsData = () => {
|
||||
|
||||
type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void };
|
||||
const JobRow = ({ job, onAction }: JobRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { id: activeId } = useParams<{ id: string }>();
|
||||
const isRunning = job.status === 'running';
|
||||
const target = basename(job.target);
|
||||
@@ -126,7 +128,7 @@ const JobRow = ({ job, onAction }: JobRowProps) => {
|
||||
</Link>
|
||||
<button
|
||||
onClick={(ev) => onAction(ev, job)}
|
||||
title={isRunning ? 'Stop' : 'Delete'}
|
||||
title={isRunning ? t('jobs.stop') : 'Delete'}
|
||||
className="shrink-0 mr-2 p-1.5 rounded-md text-duck-dark/40 hover:text-red-500 hover:bg-red-500/10 md:opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
{isRunning ? <Square className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />}
|
||||
@@ -144,6 +146,7 @@ const PanelHeader = ({ children }: { children: ReactNode }) => (
|
||||
// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord
|
||||
// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it.
|
||||
const TestNotificationButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
@@ -157,12 +160,12 @@ const TestNotificationButton = () => {
|
||||
);
|
||||
const results = res?.results ?? [];
|
||||
if (results.length === 0) {
|
||||
toast.warning('No channels configured');
|
||||
toast.warning(t('jobs.noChannels'));
|
||||
} else {
|
||||
toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', '));
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to send notification');
|
||||
toast.error(t('jobs.notifyFailed'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -172,7 +175,7 @@ const TestNotificationButton = () => {
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={sending}
|
||||
title="Send a test notification to every configured channel"
|
||||
title={t('jobs.testNotification')}
|
||||
className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
@@ -182,6 +185,7 @@ const TestNotificationButton = () => {
|
||||
};
|
||||
|
||||
const ActiveJobsPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { jobs, isLoading, act } = useJobsData();
|
||||
const active = [
|
||||
...jobs.filter((j) => j.status === 'running'),
|
||||
@@ -191,15 +195,17 @@ const ActiveJobsPanel = () => {
|
||||
<div className="h-full p-2">
|
||||
<Card className="h-full flex flex-col overflow-hidden">
|
||||
<PanelHeader>
|
||||
<h2 className="text-sm font-semibold text-duck-dark">Running & Queued</h2>
|
||||
<h2 className="text-sm font-semibold text-duck-dark">{t('jobs.runningQueued')}</h2>
|
||||
{active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>}
|
||||
<TestNotificationButton />
|
||||
</PanelHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
|
||||
) : active.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Nothing running</div>
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
|
||||
{t('jobs.nothingRunning')}
|
||||
</div>
|
||||
) : (
|
||||
active.map((job) => <JobRow key={job.id} job={job} onAction={act} />)
|
||||
)}
|
||||
@@ -211,6 +217,7 @@ const ActiveJobsPanel = () => {
|
||||
|
||||
// Bottom-left panel: finished / failed / stopped, newest first, searchable.
|
||||
const HistoryJobsPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { jobs, isLoading, act, clearHistory } = useJobsData();
|
||||
const [search, setSearch] = useState('');
|
||||
const history = jobs
|
||||
@@ -224,12 +231,12 @@ const HistoryJobsPanel = () => {
|
||||
<div className="h-full p-2">
|
||||
<Card className="h-full flex flex-col overflow-hidden">
|
||||
<PanelHeader>
|
||||
<h2 className="text-sm font-semibold text-duck-dark shrink-0">History</h2>
|
||||
<h2 className="text-sm font-semibold text-duck-dark shrink-0">{t('jobs.history')}</h2>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
placeholder={t('jobs.search')}
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
||||
@@ -238,16 +245,16 @@ const HistoryJobsPanel = () => {
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
title="Delete all finished jobs"
|
||||
title={t('jobs.deleteFinished')}
|
||||
className="shrink-0 text-xs font-medium text-duck-dark/50 hover:text-red-500 cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
{t('jobs.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</PanelHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
|
||||
{search ? 'No matches' : 'No finished jobs'}
|
||||
@@ -263,6 +270,7 @@ const HistoryJobsPanel = () => {
|
||||
|
||||
// ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ──
|
||||
const JobDetailPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const client = useClient();
|
||||
const [mode, setMode] = useState<string | null>(null);
|
||||
@@ -284,7 +292,7 @@ const JobDetailPanel = () => {
|
||||
<div className="h-full p-2">
|
||||
<Card className="h-full flex flex-col items-center justify-center gap-2 text-duck-dark/30">
|
||||
<Inbox className="h-8 w-8" />
|
||||
<span className="text-sm">Select a job</span>
|
||||
<span className="text-sm">{t('jobs.selectJob')}</span>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -301,7 +309,7 @@ const JobDetailPanel = () => {
|
||||
if (mode === 'notfound') {
|
||||
return (
|
||||
<div className="h-full p-2">
|
||||
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">Job not found.</Card>
|
||||
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">{t('jobs.notFound')}</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'l
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { PhaseBar, type DownloadProgress } from './DownloadJobDetail';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type TFn = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
type ScriptJob = {
|
||||
id: string;
|
||||
@@ -27,36 +30,46 @@ const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === '
|
||||
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
|
||||
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
|
||||
|
||||
const statusBadge = (status: string, exitCode: number | null) => {
|
||||
// Takes `t` because it is a module-level helper, not a component.
|
||||
const statusBadge = (status: string, exitCode: number | null, t: TFn) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
label: 'Running',
|
||||
label: t('jobs.status.running'),
|
||||
cls: 'text-amber-600 dark:text-amber-500',
|
||||
};
|
||||
case 'pending':
|
||||
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
label: t('jobs.status.queued'),
|
||||
cls: 'text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
case 'completed':
|
||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
|
||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
|
||||
case 'failed':
|
||||
return {
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
label: `Failed${exitCode != null ? ` (exit ${exitCode})` : ''}`,
|
||||
label: exitCode != null ? t('jobs.failedWithCode', { code: exitCode }) : t('jobs.status.failed'),
|
||||
cls: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
case 'stopped':
|
||||
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
|
||||
return {
|
||||
icon: <Ban className="h-4 w-4" />,
|
||||
label: t('jobs.status.stopped'),
|
||||
cls: 'text-duck-dark/50 dark:text-foreground/50',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
label: 'Interrupted',
|
||||
label: t('jobs.status.interrupted'),
|
||||
cls: 'text-orange-600 dark:text-orange-400',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ScriptJobDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const client = useClient();
|
||||
|
||||
@@ -137,9 +150,9 @@ export const ScriptJobDetail = () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await client.post(`/jobs/${id}/stop`, {});
|
||||
toast.success('Stop requested');
|
||||
toast.success(t('jobs.stopRequested'));
|
||||
} catch {
|
||||
toast.error('Failed to stop');
|
||||
toast.error(t('jobs.stopFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,15 +166,15 @@ export const ScriptJobDetail = () => {
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
|
||||
<span>Job not found.</span>
|
||||
<span>{t('jobs.notFound')}</span>
|
||||
<Link to="/jobs" className="text-duck-teal hover:underline">
|
||||
Back to jobs
|
||||
{t('jobs.backToJobs')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = statusBadge(job.status, job.exitCode);
|
||||
const badge = statusBadge(job.status, job.exitCode, t);
|
||||
const running = !isTerminal(job.status);
|
||||
|
||||
return (
|
||||
@@ -185,7 +198,7 @@ export const ScriptJobDetail = () => {
|
||||
onClick={stop}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
|
||||
>
|
||||
<Square className="h-3 w-3" /> Stop
|
||||
<Square className="h-3 w-3" /> {t('jobs.stop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -193,14 +206,14 @@ export const ScriptJobDetail = () => {
|
||||
{isDownloadProgress(job.progress) && (
|
||||
<div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4">
|
||||
<PhaseBar
|
||||
label="Titles"
|
||||
label={t('jobs.titles')}
|
||||
c={job.progress.meta}
|
||||
active={job.progress.phase === 'metadata'}
|
||||
savedLabel="found"
|
||||
failedLabel="skipped"
|
||||
/>
|
||||
<PhaseBar
|
||||
label="Download"
|
||||
label={t('jobs.download')}
|
||||
c={job.progress.dl}
|
||||
active={job.progress.phase === 'download'}
|
||||
savedLabel="saved"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Bug } from 'lucide-react';
|
||||
import { useBugReport } from './use-bug-report';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { BugReportDialog } from './BugReportDialog';
|
||||
|
||||
export const BugReportButton = () => {
|
||||
const bugReport = useBugReport();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={bugReport.handleOpen}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
|
||||
aria-label="Report a bug"
|
||||
aria-label={t('bugReport.label')}
|
||||
>
|
||||
<Bug size={16} />
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type BugReportDialogProps = {
|
||||
open: boolean;
|
||||
@@ -21,6 +22,7 @@ export const BugReportDialog = ({
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: BugReportDialogProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
|
||||
@@ -36,7 +38,7 @@ export const BugReportDialog = ({
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Report a Bug</DialogTitle>
|
||||
<DialogTitle>{t('bugReport.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -46,34 +48,32 @@ export const BugReportDialog = ({
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" />
|
||||
<img src={previewUrl} alt={t('bugReport.screenshot')} className="max-h-48 w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||
No screenshot captured
|
||||
{t('bugReport.noScreenshot')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="Describe what went wrong..."
|
||||
placeholder={t('bugReport.describePlaceholder')}
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Console logs, browser info, and recent API errors are collected automatically.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('bugReport.collectedNote')}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Submit
|
||||
{submitting ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t('bugReport.submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { ErrorDetails } from 'hooks/useClient';
|
||||
import { getConsoleBuffer } from '@/lib/console-buffer';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const useBugReport = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -14,6 +15,7 @@ export const useBugReport = () => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const client = useClient();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
@@ -57,15 +59,15 @@ export const useBugReport = () => {
|
||||
}
|
||||
|
||||
await client.post('/bug-report', formData);
|
||||
toast.success('Bug report submitted');
|
||||
toast.success(t('bugReport.submitted'));
|
||||
handleClose();
|
||||
} catch {
|
||||
toast.error('Failed to submit bug report');
|
||||
toast.error(t('bugReport.failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[screenshot, client, user, apiError, handleClose],
|
||||
[screenshot, client, user, apiError, handleClose, t],
|
||||
);
|
||||
|
||||
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||
import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
|
||||
import { Dock, useDockItems, defaultDockPaths } from './Dock';
|
||||
import { Spotlight } from './Spotlight/Spotlight';
|
||||
import { ShortcutsModal } from './ShortcutsModal';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
@@ -24,10 +24,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
// will then slam is worse than one that never showed it.
|
||||
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
|
||||
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
|
||||
const permitted = useMemo(
|
||||
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
|
||||
[canVisit, plugins],
|
||||
);
|
||||
const allDockItems = useDockItems(plugins);
|
||||
const permitted = useMemo(() => allDockItems.filter((item) => canVisit(item.to)), [canVisit, allDockItems]);
|
||||
const { items: visibleItems } = useDock(
|
||||
permitted,
|
||||
useMemo(() => defaultDockPaths(plugins), [plugins]),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { resolveIcon } from 'officerdev';
|
||||
import type { PluginManifest } from 'hooks/usePermissions';
|
||||
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||
|
||||
export type DockItem = {
|
||||
/** Already resolved for the active locale — see `useDockItems`. */
|
||||
label: string;
|
||||
to: string;
|
||||
color: string;
|
||||
@@ -169,22 +171,22 @@ import {
|
||||
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
|
||||
* the light profile because it fronts a remote instance and installs nothing locally.
|
||||
*/
|
||||
export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
export const CORE_DOCK_ITEMS: CoreDockItem[] = [
|
||||
{ labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
||||
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||
{ labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
// Core because it is the door into another account's filesystem, and the permission that gates it is
|
||||
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
|
||||
// every other grantable tile here gets.
|
||||
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
|
||||
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
|
||||
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
|
||||
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
|
||||
// while the dock and the settings list went silent about them, which is exactly the shape of failure
|
||||
@@ -194,16 +196,16 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
|
||||
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
|
||||
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
|
||||
{ label: 'Calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
|
||||
{ label: 'Contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
|
||||
{ labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
|
||||
{ labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
|
||||
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
||||
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
||||
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
||||
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||
// things that disappears when uninstalled.
|
||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
{ labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
||||
{ label: 'Marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
|
||||
{ labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -213,6 +215,30 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
|
||||
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
|
||||
*/
|
||||
/**
|
||||
* A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only
|
||||
* dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest,
|
||||
* which is the plugin's string and not ours to translate (docs/localization.md open question 3).
|
||||
*/
|
||||
type CoreDockItem = Omit<DockItem, 'label'> & { labelKey: AppTranslationKey };
|
||||
|
||||
/**
|
||||
* The composed dock, with the shell's own labels in the active locale.
|
||||
*
|
||||
* One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by
|
||||
* spreading the same two sources, and each would otherwise have to resolve labels itself.
|
||||
*/
|
||||
export function useDockItems(plugins: PluginManifest[]): DockItem[] {
|
||||
const { t } = useTranslation();
|
||||
return useMemo(
|
||||
() => [
|
||||
...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })),
|
||||
...dockItemsFromPlugins(plugins),
|
||||
],
|
||||
[t, plugins],
|
||||
);
|
||||
}
|
||||
|
||||
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
||||
return plugins.flatMap((plugin) => {
|
||||
const tile = (t: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RescanButton } from '../Rescan/RescanButton';
|
||||
import { usePageTitle } from '@/state/usePageTitle';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||
|
||||
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
|
||||
@@ -20,14 +21,15 @@ import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
||||
// the field hand the tab back to the route's own name.
|
||||
function EditablePageTitle() {
|
||||
const [title, rename] = usePageTitle();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<EditableTitle
|
||||
value={title}
|
||||
onCommit={rename}
|
||||
allowEmpty
|
||||
ariaLabel="Tab name"
|
||||
hint="Click to rename this tab — clear it to go back to the page name"
|
||||
ariaLabel={t('header.tabName')}
|
||||
hint={t('header.tabNameHint')}
|
||||
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
|
||||
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
|
||||
/>
|
||||
@@ -48,6 +50,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isTouch = useIsTouch();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
|
||||
const logo = useThemeAsset('--logo-src');
|
||||
|
||||
@@ -81,7 +84,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
||||
*/}
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Officer"
|
||||
aria-label={t('header.logoAlt')}
|
||||
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
|
||||
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
|
||||
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
|
||||
@@ -119,7 +122,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="left">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetTitle>{t('header.navigation')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-1 mt-4 px-2">
|
||||
{dockItems.map((item) => (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
|
||||
import { Loader2, ListOrdered } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type Counts = { running: number; runningJobId: string | null; queued: number };
|
||||
|
||||
@@ -15,6 +16,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
|
||||
export const JobsIndicator = () => {
|
||||
const client = useClient();
|
||||
const { can } = usePermissions();
|
||||
const { t } = useTranslation();
|
||||
const allowed = can('tasks');
|
||||
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
||||
|
||||
@@ -44,7 +46,7 @@ export const JobsIndicator = () => {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link
|
||||
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
|
||||
title={counts.running ? 'Running job' : 'Nothing running'}
|
||||
title={counts.running ? t('header.jobsRunning') : t('header.jobsNothingRunning')}
|
||||
className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`}
|
||||
>
|
||||
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
|
||||
@@ -52,7 +54,7 @@ export const JobsIndicator = () => {
|
||||
</Link>
|
||||
<Link
|
||||
to="/jobs"
|
||||
title="Queued jobs"
|
||||
title={t('header.jobsQueued')}
|
||||
className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`}
|
||||
>
|
||||
<ListOrdered className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
||||
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const {
|
||||
DropdownMenu,
|
||||
@@ -32,6 +33,7 @@ const {
|
||||
*/
|
||||
export const ThemeSwitcher = () => {
|
||||
const { theme, themeId, setTheme, themes } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
||||
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
||||
@@ -50,7 +52,7 @@ export const ThemeSwitcher = () => {
|
||||
*/
|
||||
const ordered = useMemo(() => {
|
||||
const rotated = rotateToSelected(themes, themeId);
|
||||
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
|
||||
const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
|
||||
return favouritesFirst(listed, favorites);
|
||||
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
||||
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||
@@ -62,10 +64,11 @@ export const ThemeSwitcher = () => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
|
||||
isFavorite(themeId) ? 'unstar' : 'star'
|
||||
}`}
|
||||
aria-label={`Theme: ${theme.name}`}
|
||||
title={t('theme.pillTitle', {
|
||||
name: theme.name,
|
||||
starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
|
||||
})}
|
||||
aria-label={t('theme.pillLabel', { name: theme.name })}
|
||||
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
||||
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
||||
// the cluster stays one shape.
|
||||
@@ -89,8 +92,8 @@ export const ThemeSwitcher = () => {
|
||||
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
||||
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
||||
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
||||
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
|
||||
<span className="block">ctrl+? for every shortcut</span>
|
||||
<span className="block">{t('theme.cycleHint')}</span>
|
||||
<span className="block">{t('theme.everyShortcutHint')}</span>
|
||||
</DropdownMenuLabel>
|
||||
<button
|
||||
type="button"
|
||||
@@ -105,35 +108,41 @@ export const ThemeSwitcher = () => {
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
||||
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
|
||||
{onlyFavorites
|
||||
? t('theme.favouritesOnly', { count: favorites.size })
|
||||
: t('theme.favourites', { count: favorites.size })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{ordered.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
No favourites yet — star one with the button on its row.
|
||||
</p>
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
|
||||
) : (
|
||||
families.map(([family, entries]) => (
|
||||
<div key={family}>
|
||||
{entries.map((t) => (
|
||||
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
|
||||
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||
{entries.map((entry) => (
|
||||
<DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
|
||||
aria-label={
|
||||
isFavorite(entry.id)
|
||||
? t('theme.unstarNamed', { name: entry.name })
|
||||
: t('theme.starNamed', { name: entry.name })
|
||||
}
|
||||
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggle(t.id);
|
||||
toggle(entry.id);
|
||||
}}
|
||||
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
|
||||
<Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -165,22 +174,24 @@ export const ThemeSwitcher = () => {
|
||||
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
||||
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
||||
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
||||
{ordered.map((t) => {
|
||||
const isFocused = t.id === focused;
|
||||
{ordered.map((entry) => {
|
||||
const isFocused = entry.id === focused;
|
||||
return (
|
||||
<li key={t.id}>
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
onMouseEnter={() => setFocused(t.id)}
|
||||
onMouseEnter={() => setFocused(entry.id)}
|
||||
onClick={() => {
|
||||
setTheme(t.id);
|
||||
setTheme(entry.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
||||
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
{entry.id === themeId && (
|
||||
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
|
||||
)}
|
||||
{/*
|
||||
A span, not a button: this sits inside the row's own <button>, and a button inside
|
||||
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
||||
@@ -190,18 +201,24 @@ export const ThemeSwitcher = () => {
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
|
||||
aria-label={
|
||||
isFavorite(entry.id)
|
||||
? t('theme.unstarNamed', { name: entry.name })
|
||||
: t('theme.starNamed', { name: entry.name })
|
||||
}
|
||||
title={
|
||||
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
|
||||
}
|
||||
onClick={(ev) => {
|
||||
// Or the row's own onClick applies the theme and closes the overlay, which is
|
||||
// the opposite of what starring is for.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggle(t.id);
|
||||
toggle(entry.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
|
||||
<Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -210,9 +227,7 @@ export const ThemeSwitcher = () => {
|
||||
</ul>
|
||||
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
||||
header does. */}
|
||||
<p className="mt-6 text-center text-xs opacity-60">
|
||||
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
|
||||
</p>
|
||||
<p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -27,13 +27,13 @@ export function UserMenu() {
|
||||
<DropdownMenuContent align="end" className="z-10 w-48">
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/profile">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<User className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.profile')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/system">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<Settings className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.systemSettings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -42,14 +42,14 @@ export function UserMenu() {
|
||||
(navigation-audit M8.) */}
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/ai">
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
<Bot className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.ai')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/integrations">
|
||||
<Puzzle className="mr-2 h-4 w-4" />
|
||||
Integrations
|
||||
<Puzzle className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.integrations')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* Owner only. This is tidiness, not access control — the route stays reachable and its
|
||||
@@ -58,15 +58,15 @@ export function UserMenu() {
|
||||
{user?.role === 'Super Admin' && (
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/user-management">
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
User Management
|
||||
<UserCog className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.userManagement')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/auth/signout">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<LogOut className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.signOut')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -4,9 +4,29 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||
|
||||
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
||||
|
||||
/**
|
||||
* One key per counted noun rather than one sentence with six numbers in it.
|
||||
*
|
||||
* Each noun pluralizes on its own count — English needs it for `1 task` vs `2 tasks`, and a locale with
|
||||
* more than two categories needs it for every value. Assembling the sentence in code and translating only
|
||||
* the frame would have hardcoded English plural rules into the frame.
|
||||
*
|
||||
* Spelled out as literal pairs rather than built from the count name: a key assembled at runtime defeats
|
||||
* the type checker and cannot be grepped (see the key naming convention in docs/localization.md).
|
||||
*/
|
||||
const RESCAN_COUNTS: readonly (readonly [string, AppTranslationKey])[] = [
|
||||
['tasks', 'rescan.counts.tasks'],
|
||||
['agents', 'rescan.counts.agents'],
|
||||
['tools', 'rescan.counts.tools'],
|
||||
['skills', 'rescan.counts.skills'],
|
||||
['processes', 'rescan.counts.processes'],
|
||||
['affordances', 'rescan.counts.affordances'],
|
||||
];
|
||||
|
||||
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the
|
||||
// Automation and Capability pages).
|
||||
const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories'];
|
||||
@@ -15,6 +35,7 @@ export function RescanButton() {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const { can } = usePermissions();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
|
||||
@@ -32,32 +53,28 @@ export function RescanButton() {
|
||||
// you next open that page. This refreshes every item cache immediately, wherever you press it.
|
||||
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
|
||||
const c = res.counts ?? {};
|
||||
toast.success(
|
||||
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`,
|
||||
{
|
||||
// Two things said here rather than left to be discovered.
|
||||
//
|
||||
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
|
||||
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
|
||||
// agent, because the loader skips it: a line that does not say when to reach for something
|
||||
// costs context in every conversation and earns nothing. So a skill can be present, listed and
|
||||
// editable while being invisible to every agent, and the only symptom is two numbers
|
||||
// disagreeing where nobody is comparing them.
|
||||
description:
|
||||
[
|
||||
c.affordances || c.skillsOffered
|
||||
? 'Applies to new chats — an open one keeps what it started with.'
|
||||
: null,
|
||||
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
|
||||
? `${c.skills - c.skillsOffered} skill(s) have no description, so no agent is offered them.`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined,
|
||||
},
|
||||
);
|
||||
const summary = RESCAN_COUNTS.map(([noun, key]) => t(key, { count: c[noun] ?? 0 })).join(', ');
|
||||
toast.success(t('rescan.done', { summary }), {
|
||||
// Two things said here rather than left to be discovered.
|
||||
//
|
||||
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
|
||||
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
|
||||
// agent, because the loader skips it: a line that does not say when to reach for something
|
||||
// costs context in every conversation and earns nothing. So a skill can be present, listed and
|
||||
// editable while being invisible to every agent, and the only symptom is two numbers
|
||||
// disagreeing where nobody is comparing them.
|
||||
description:
|
||||
[
|
||||
c.affordances || c.skillsOffered ? t('rescan.newChatsOnly') : null,
|
||||
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
|
||||
? t('rescan.noDescription', { count: c.skills - c.skillsOffered })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to rescan items');
|
||||
toast.error(t('rescan.failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -68,8 +85,8 @@ export function RescanButton() {
|
||||
onClick={rescan}
|
||||
disabled={loading}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
|
||||
aria-label="Rescan items"
|
||||
title="Rescan officer-items (apply changes without restarting)"
|
||||
aria-label={t('rescan.label')}
|
||||
title={t('rescan.title')}
|
||||
>
|
||||
<RotateCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
||||
|
||||
@@ -18,6 +19,7 @@ type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
||||
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
@@ -46,36 +48,34 @@ export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps)
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold">This screen failed to render</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
|
||||
untouched.
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold">{t('screenError.title')}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t('screenError.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="bg-muted/40 rounded-md border p-3 text-sm">
|
||||
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary>
|
||||
<summary className="cursor-pointer select-none font-medium">
|
||||
{error.message || t('screenError.unknownError')}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
|
||||
</details>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={reset}>Try again</Button>
|
||||
<Button onClick={reset}>{t('screenError.tryAgain')}</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.reload()}>
|
||||
Reload
|
||||
{t('screenError.reload')}
|
||||
</Button>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to="/">Go home</Link>
|
||||
<Link to="/">{t('screenError.goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's
|
||||
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
|
||||
{t('screenError.resetHint')} <strong>{t('screenError.dashboardsSafe')}</strong>
|
||||
</p>
|
||||
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
|
||||
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'}
|
||||
{resetting ? t('screenError.resetting') : confirming ? t('screenError.resetConfirm') : t('screenError.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
/*
|
||||
`Ctrl+?` — every shortcut the shell owns, on one screen.
|
||||
@@ -27,6 +28,7 @@ const Row = ({ keys, description }: { keys: string; description: string }) => (
|
||||
|
||||
export const ShortcutsModal = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
@@ -57,19 +59,23 @@ export const ShortcutsModal = () => {
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
|
||||
<h2 className="text-sm font-semibold">{t('shortcutsModal.title')}</h2>
|
||||
<span className="ms-auto text-xs text-muted-foreground">{t('shortcutsModal.hint')}</span>
|
||||
</div>
|
||||
|
||||
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
||||
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
|
||||
{SHORTCUT_SECTIONS.map((section) => (
|
||||
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
|
||||
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
|
||||
<section key={section.titleKey} className="rounded-xl border border-border bg-muted/20 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
||||
{t(section.titleKey)}
|
||||
</h3>
|
||||
{section.noteKey && (
|
||||
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{t(section.noteKey)}</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
|
||||
<Row key={shortcut.keys} keys={shortcut.keys} description={t(shortcut.descriptionKey)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
|
||||
import { Search } from 'lucide-react';
|
||||
import { rank } from './match';
|
||||
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// Spotlight: type a few letters, go somewhere.
|
||||
//
|
||||
@@ -181,6 +182,7 @@ export const Spotlight = () => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const items = useSpotlightItems();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const results = useMemo(() => rank(items, query), [items, query]);
|
||||
|
||||
@@ -287,14 +289,14 @@ export const Spotlight = () => {
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
placeholder="Go to…"
|
||||
placeholder={t('spotlight.placeholder')}
|
||||
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto p-2">
|
||||
{results.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">{t('spotlight.noResults', { query })}</p>
|
||||
) : (
|
||||
results.map((result, i) => (
|
||||
<Row
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
|
||||
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||
import { useDockItems } from '../Dock';
|
||||
|
||||
// What Spotlight can take you to.
|
||||
//
|
||||
@@ -40,6 +41,7 @@ import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
|
||||
export type SpotlightItem = {
|
||||
/** Stable across renders — used as the React key and for the recency store. */
|
||||
id: string;
|
||||
/** Resolved for the active locale, because `rank()` matches what the reader can actually see. */
|
||||
label: string;
|
||||
/** Where it goes. Matched against `canVisit` for anything not already filtered. */
|
||||
to: string;
|
||||
@@ -78,64 +80,137 @@ export type SpotlightItem = {
|
||||
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
|
||||
* does not say where it lives.
|
||||
*/
|
||||
const SETTINGS_ITEMS: SpotlightItem[] = [
|
||||
type SettingsItem = Omit<SpotlightItem, 'label' | 'hint'> & {
|
||||
labelKey: AppTranslationKey;
|
||||
hintKey?: AppTranslationKey;
|
||||
};
|
||||
|
||||
const SETTINGS_ITEMS: SettingsItem[] = [
|
||||
// Profile — ProfileSettings/index.tsx
|
||||
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
|
||||
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock },
|
||||
{
|
||||
id: 'set:profile',
|
||||
labelKey: 'settings.sections.profile.label',
|
||||
to: '/settings/profile',
|
||||
hintKey: 'settings.sections.profile.hint',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
id: 'set:password',
|
||||
labelKey: 'settings.sections.changePassword.label',
|
||||
to: '/settings/profile/change-password',
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
id: 'set:api-keys',
|
||||
label: 'API keys',
|
||||
labelKey: 'settings.sections.apiKeys.label',
|
||||
to: '/settings/profile/api-keys',
|
||||
hint: 'keys that sign in as you',
|
||||
hintKey: 'settings.sections.apiKeys.hint',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
id: 'set:languages',
|
||||
label: 'Languages',
|
||||
labelKey: 'settings.sections.languages.label',
|
||||
to: '/settings/profile/languages',
|
||||
hint: 'spoken and translation',
|
||||
hintKey: 'settings.sections.languages.hint',
|
||||
icon: Globe,
|
||||
},
|
||||
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 },
|
||||
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid },
|
||||
{
|
||||
id: 'set:voice',
|
||||
labelKey: 'settings.sections.voice.label',
|
||||
to: '/settings/profile/voice',
|
||||
hintKey: 'settings.sections.voice.hint',
|
||||
icon: Volume2,
|
||||
},
|
||||
{
|
||||
id: 'set:dock',
|
||||
labelKey: 'settings.sections.dock.label',
|
||||
to: '/settings/profile/dock',
|
||||
hintKey: 'settings.sections.dock.hint',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
// AI — AISettings.tsx
|
||||
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles },
|
||||
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles },
|
||||
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles },
|
||||
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot },
|
||||
{
|
||||
id: 'set:ai-my-models',
|
||||
labelKey: 'settings.sections.myModels.label',
|
||||
to: '/settings/ai/my-models',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'set:ai-channel-models',
|
||||
labelKey: 'settings.sections.channelModels.label',
|
||||
to: '/settings/ai/member-models',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'set:ai-default-models',
|
||||
labelKey: 'settings.sections.defaultModels.label',
|
||||
to: '/settings/ai/default-models',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'set:ai-harnesses',
|
||||
labelKey: 'settings.sections.providers.label',
|
||||
to: '/settings/ai/ai-harnesses',
|
||||
hintKey: 'settings.sections.providers.hint',
|
||||
icon: Bot,
|
||||
},
|
||||
// System — SystemSettings.tsx
|
||||
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 },
|
||||
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic },
|
||||
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText },
|
||||
{ id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
|
||||
{ id: 'set:stt', labelKey: 'settings.sections.stt.label', to: '/settings/system/stt', icon: Mic },
|
||||
{ id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
|
||||
{
|
||||
id: 'set:maintenance',
|
||||
label: 'Rebuild & restart',
|
||||
labelKey: 'settings.sections.maintenance.label',
|
||||
to: '/settings/system/maintenance',
|
||||
hint: 'maintenance',
|
||||
hintKey: 'settings.sections.maintenance.hint',
|
||||
icon: Cpu,
|
||||
},
|
||||
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail },
|
||||
{
|
||||
id: 'set:smtp',
|
||||
labelKey: 'settings.sections.smtp.label',
|
||||
to: '/settings/system/smtp',
|
||||
hintKey: 'settings.sections.smtp.hint',
|
||||
icon: Mail,
|
||||
},
|
||||
// Integrations — IntegrationsSettings
|
||||
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound },
|
||||
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench },
|
||||
{
|
||||
id: 'set:google-oauth',
|
||||
labelKey: 'settings.sections.googleOauth.label',
|
||||
to: '/settings/integrations/google-oauth',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
|
||||
{
|
||||
id: 'set:dav',
|
||||
label: 'Calendar & Contacts sync',
|
||||
labelKey: 'settings.sections.davSync.label',
|
||||
to: '/settings/integrations/dav-app-passwords',
|
||||
hint: 'app passwords',
|
||||
hintKey: 'settings.sections.davSync.hint',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
// Users — UserManagement
|
||||
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users },
|
||||
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck },
|
||||
{
|
||||
id: 'set:users',
|
||||
labelKey: 'settings.sections.accounts.label',
|
||||
to: '/settings/user-management/users',
|
||||
hintKey: 'settings.sections.accounts.hint',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'set:permissions',
|
||||
labelKey: 'settings.sections.permissions.label',
|
||||
to: '/settings/user-management/permissions',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
];
|
||||
|
||||
/** Everything Spotlight can offer this account, in a stable order. */
|
||||
export function useSpotlightItems(): SpotlightItem[] {
|
||||
const { canVisit, plugins } = usePermissions();
|
||||
const { t } = useTranslation();
|
||||
const dockItems = useDockItems(plugins);
|
||||
|
||||
return useMemo(() => {
|
||||
const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)]
|
||||
const fromDock: SpotlightItem[] = dockItems
|
||||
.filter((item) => canVisit(item.to))
|
||||
.map((item) => ({
|
||||
id: `nav:${item.to}`,
|
||||
@@ -149,8 +224,14 @@ export function useSpotlightItems(): SpotlightItem[] {
|
||||
|
||||
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
|
||||
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
|
||||
const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to));
|
||||
const settings: SpotlightItem[] = SETTINGS_ITEMS.filter((item) => canVisit(item.to)).map(
|
||||
({ labelKey, hintKey, ...item }) => ({
|
||||
...item,
|
||||
label: t(labelKey),
|
||||
...(hintKey ? { hint: t(hintKey) } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
return [...fromDock, ...settings];
|
||||
}, [canVisit, plugins]);
|
||||
}, [canVisit, dockItems, t]);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { KeyRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const PasskeyGate = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, createPasskeyCredentials } = useAuth();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
@@ -31,7 +33,7 @@ export const PasskeyGate = () => {
|
||||
<KeyRound className="h-8 w-8 text-duck-teal" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Set up your passkey</h2>
|
||||
<h2 className="text-2xl font-bold text-duck-dark">{t('passkeyGate.setUp')}</h2>
|
||||
|
||||
<p className="text-duck-dark/70">
|
||||
Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import type { UserSettings } from 'state/useSettings';
|
||||
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useAccessPolicy } from 'state/useAccessPolicy';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
|
||||
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
// --- My Models Section (per-user hidden models) ---
|
||||
|
||||
function MyModelsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const visibleModels = useModels();
|
||||
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
|
||||
@@ -174,11 +176,7 @@ function MyModelsSection() {
|
||||
}, []);
|
||||
|
||||
if (providerGroups.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
No models available. Configure API keys in Providers.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -199,9 +197,9 @@ function MyModelsSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DropZone label="Visible" onDrop={showModel}>
|
||||
<DropZone label={t('settings.ai.visible')} onDrop={showModel}>
|
||||
{shown.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to show</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.dragToShow')}</span>
|
||||
)}
|
||||
{shown.map((m) => (
|
||||
<ModelPill
|
||||
@@ -215,9 +213,9 @@ function MyModelsSection() {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<DropZone label="Hidden" onDrop={hideModel}>
|
||||
<DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
|
||||
{hidden.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models visible</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allVisible')}</span>
|
||||
)}
|
||||
{hidden.map((m) => (
|
||||
<ModelPill
|
||||
@@ -237,6 +235,7 @@ function MyModelsSection() {
|
||||
// --- Member Models Section (system-wide policy, admin only) ---
|
||||
|
||||
function MemberModelsSection() {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { policy, savePolicy } = useAccessPolicy();
|
||||
@@ -249,9 +248,9 @@ function MemberModelsSection() {
|
||||
try {
|
||||
await client.post('/server-settings/chat-providers/local-providers/refresh');
|
||||
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
|
||||
toast.success('Models refreshed');
|
||||
toast.success(t('settings.ai.refreshed'));
|
||||
} catch {
|
||||
toast.error('Failed to refresh models');
|
||||
toast.error(t('settings.ai.refreshFailed'));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -325,11 +324,7 @@ function MemberModelsSection() {
|
||||
}, []);
|
||||
|
||||
if (providerGroups.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
No models available. Configure API keys in Providers.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -353,15 +348,17 @@ function MemberModelsSection() {
|
||||
onClick={refreshModels}
|
||||
disabled={refreshing}
|
||||
className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
|
||||
title="Refresh models from providers"
|
||||
title={t('settings.ai.refresh')}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DropZone label="Enabled" onDrop={enableModel}>
|
||||
<DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
|
||||
{enabled.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
|
||||
{t('settings.ai.dragToEnable')}
|
||||
</span>
|
||||
)}
|
||||
{enabled.map((m) => (
|
||||
<ModelPill
|
||||
@@ -375,9 +372,9 @@ function MemberModelsSection() {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<DropZone label="Disabled" onDrop={disableModel}>
|
||||
<DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
|
||||
{disabled.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allEnabled')}</span>
|
||||
)}
|
||||
{disabled.map((m) => (
|
||||
<ModelPill
|
||||
@@ -410,49 +407,49 @@ function MemberModelsSection() {
|
||||
function useAISettingsGroups(): SettingsSectionGroup[] {
|
||||
return useMemo(() => {
|
||||
const modelsGroup: SettingsSectionGroup = {
|
||||
label: 'Models',
|
||||
labelKey: 'settings.groups.models',
|
||||
icon: Eye,
|
||||
sections: [
|
||||
{
|
||||
key: 'my-models',
|
||||
icon: Eye,
|
||||
title: 'My Models',
|
||||
description: 'Show or hide models for yourself',
|
||||
titleKey: 'settings.sections.myModels.label',
|
||||
descriptionKey: 'settings.sections.myModels.description',
|
||||
content: <MyModelsSection />,
|
||||
},
|
||||
{
|
||||
key: 'member-models',
|
||||
icon: Eye,
|
||||
title: 'Channel Models',
|
||||
description: 'Models reachable by non-owner accounts',
|
||||
titleKey: 'settings.sections.channelModels.label',
|
||||
descriptionKey: 'settings.sections.channelModels.description',
|
||||
content: <MemberModelsSection />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultsGroup: SettingsSectionGroup = {
|
||||
label: 'Defaults',
|
||||
labelKey: 'settings.groups.defaults',
|
||||
icon: Sparkles,
|
||||
sections: [
|
||||
{
|
||||
key: 'default-models',
|
||||
icon: Sparkles,
|
||||
title: 'Default Models',
|
||||
description: 'Default models for chat, projects, and tasks',
|
||||
titleKey: 'settings.sections.defaultModels.label',
|
||||
descriptionKey: 'settings.sections.defaultModels.description',
|
||||
content: <AIModels />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const providersGroup: SettingsSectionGroup = {
|
||||
label: 'Providers',
|
||||
labelKey: 'settings.groups.providers',
|
||||
icon: Terminal,
|
||||
sections: [
|
||||
{
|
||||
key: 'ai-harnesses',
|
||||
icon: Terminal,
|
||||
title: 'Providers',
|
||||
description: 'Remote and local AI providers',
|
||||
titleKey: 'settings.sections.providers.label',
|
||||
descriptionKey: 'settings.sections.providers.description',
|
||||
content: <AIHarnessesSection />,
|
||||
},
|
||||
],
|
||||
@@ -482,7 +479,7 @@ export const AISettings = () => {
|
||||
createSettingsPanelComponents({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: Bot,
|
||||
sidebarLabel: 'AI',
|
||||
sidebarLabelKey: 'settings.nav.ai',
|
||||
groups,
|
||||
}),
|
||||
[groups],
|
||||
|
||||
+5
-3
@@ -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"
|
||||
|
||||
+34
-23
@@ -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" />
|
||||
|
||||
+13
-11
@@ -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>
|
||||
|
||||
+89
-111
@@ -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. "Officer") 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 "Officer"
|
||||
{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. "Officer")</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'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,
|
||||
});
|
||||
|
||||
|
||||
+44
-32
@@ -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
|
||||
|
||||
+11
-11
@@ -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’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>
|
||||
|
||||
+16
-10
@@ -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’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’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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
||||
export * from './sendoff';
|
||||
export * from './roles';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { roles } from './schema';
|
||||
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../auth/schema';
|
||||
import { roles } from './schema';
|
||||
|
||||
export type RoleRow = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
provisioningScript: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every role, with how many accounts hold each.
|
||||
*
|
||||
* The count is here rather than in a second query because every caller needs it: a picker wants to show
|
||||
* it, and the delete guard is defined in terms of it. One left join beats two round trips and a race
|
||||
* between them.
|
||||
*/
|
||||
export async function listRoles(): Promise<(RoleRow & { userCount: number })[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: roles.name,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
provisioningScript: roles.provisioningScript,
|
||||
createdAt: roles.createdAt,
|
||||
userCount: sql<number>`count(${users.id})::int`,
|
||||
})
|
||||
.from(roles)
|
||||
.leftJoin(users, eq(users.role, roles.name))
|
||||
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
|
||||
.orderBy(asc(roles.name));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getRole(name: string): Promise<RoleRow | undefined> {
|
||||
const [row] = await db.select().from(roles).where(eq(roles.name, name)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
|
||||
*
|
||||
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
|
||||
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
|
||||
* deploy — so the seed establishes a row and never touches it again.
|
||||
*/
|
||||
export async function seedRole(row: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isSystem?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning({ name: roles.name });
|
||||
return inserted.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* The roles this server has, as data rather than a TypeScript union.
|
||||
*
|
||||
* ── Why the NAME is the primary key ──
|
||||
*
|
||||
* `users.role` and `role_permissions.role` both hold a role name today, and `authorize.ts` reads a name off
|
||||
* the user to look up grants. Re-keying those onto a serial id would touch the authorization path — the one
|
||||
* file standing between a Member and a shell — to buy nothing the name does not already give, because
|
||||
* Postgres will cascade a rename for us. So both columns become foreign keys onto this one with
|
||||
* `ON UPDATE CASCADE`: renaming a role moves every user and every grant with it, atomically, and no code
|
||||
* changes.
|
||||
*
|
||||
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
|
||||
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
|
||||
*
|
||||
* ── What is NOT here ──
|
||||
*
|
||||
* **Ownership.** The owner is `OWNER_USER_ID`, an identity, not a role — see `super-admin.ts`. Before this
|
||||
* table existed, "who owns this server" was answered by `users.role === 'Super Admin'`, which meant that
|
||||
* making roles editable would have made "add a role" and "become the owner" the same feature. Several
|
||||
* accounts may now hold `Super Admin`; exactly one account is the owner, and no role grants it.
|
||||
*
|
||||
* **Permissions.** They stay in `role_permissions`, keyed on the name. A role row is the role's identity and
|
||||
* its provisioning, not its grants.
|
||||
*/
|
||||
export const roles = pgTable('roles', {
|
||||
/** The name, and the key. Referenced by `users.role` and `role_permissions.role` with ON UPDATE CASCADE. */
|
||||
name: text('name').primaryKey(),
|
||||
/** One line, for a picker. Never load-bearing — nothing branches on it. */
|
||||
description: text('description'),
|
||||
/**
|
||||
* A role the platform's own code names, so it cannot be deleted or renamed.
|
||||
*
|
||||
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
|
||||
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
|
||||
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
|
||||
* discovering it as a runtime failure somewhere else.
|
||||
*/
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
/**
|
||||
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
|
||||
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
|
||||
* becomes a row. Null today, and nothing reads it.
|
||||
*
|
||||
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
|
||||
* column that arrives with the feature that uses it is a second migration for no gain.
|
||||
*/
|
||||
provisioningScript: text('provisioning_script'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export * from './anthropic-accounts/schema'; // anthropic_accounts — switchabl
|
||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
|
||||
export * from './roles/schema'; // roles
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { chatIdentity } from '../chat/chat';
|
||||
import { checkBudget } from './budget';
|
||||
|
||||
/*
|
||||
POST /sub-agents/spawn — an agent asks Officer to run another agent.
|
||||
|
||||
── Why this exists rather than leaning on the harness ──
|
||||
|
||||
A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher
|
||||
loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a
|
||||
commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what
|
||||
it started.
|
||||
|
||||
So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every
|
||||
caller gets the same one, and none of it changes when a CLI does.
|
||||
|
||||
── The condition that comes before the feature ──
|
||||
|
||||
`checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are
|
||||
spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the
|
||||
last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's
|
||||
conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a
|
||||
floor: never spend the last 15%.
|
||||
|
||||
── What it is NOT ──
|
||||
|
||||
Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any
|
||||
good is the caller's problem, and a caller that wants three tries writes three calls — because a retry
|
||||
inside here would be a policy nobody chose, applied to work this endpoint cannot read.
|
||||
*/
|
||||
|
||||
export const subAgentsRouter = createRouter();
|
||||
|
||||
const MAX_PROMPT = 100_000;
|
||||
|
||||
subAgentsRouter.post('/spawn', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean };
|
||||
|
||||
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`);
|
||||
|
||||
/*
|
||||
Refused BEFORE anything is spawned, and the reason travels with the refusal.
|
||||
|
||||
A caller that gets "no room" needs to know which window and how close, or its only move is to try again
|
||||
immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now",
|
||||
not a "not you".
|
||||
*/
|
||||
const budget = await checkBudget();
|
||||
if (!budget.ok) {
|
||||
return ctx.json(
|
||||
{ error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem },
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
The identity is resolved from the TOKEN, never from the body.
|
||||
|
||||
`chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same
|
||||
resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to
|
||||
run in.
|
||||
*/
|
||||
const who = await chatIdentity(user);
|
||||
|
||||
// Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first
|
||||
// reports one, which is after this returns — see the note in the response below.
|
||||
const sessionKey = crypto.randomUUID();
|
||||
const spawn = {
|
||||
userId: user.id,
|
||||
email: who.email,
|
||||
username: user.username ?? who.email.split('@')[0] ?? String(user.id),
|
||||
prompt,
|
||||
sessionKey,
|
||||
...(body?.cwd ? { cwd: body.cwd } : {}),
|
||||
...(body?.model ? { model: body.model } : {}),
|
||||
/*
|
||||
Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional
|
||||
one let a call site forget it, and forgetting it means a member's agent silently running as the owner in
|
||||
the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says
|
||||
there is no Linux user to run as.
|
||||
*/
|
||||
member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
/*
|
||||
Two modes, and the default is the one a loop wants.
|
||||
|
||||
`wait: false` returns as soon as the agent is running, so a watcher can start work and get on with
|
||||
watching. `wait: true` blocks for the answer, which is right for a question with a short answer and
|
||||
wrong for anything that touches a fleet — the request would hold a socket open for the length of the
|
||||
work.
|
||||
*/
|
||||
if (body?.wait === true) {
|
||||
const result = await sidecar.spawnClaude(spawn);
|
||||
return ctx.json({ sessionKey, result });
|
||||
}
|
||||
await sidecar.spawnClaudeStreaming(spawn);
|
||||
/*
|
||||
`sessionKey` and not a transcript id, because the transcript does not exist yet.
|
||||
|
||||
The agent announces its own uuid on its first turn, which is after this responds. A caller that needs
|
||||
the transcript — to open `/chat/<id>`, or to record what it started — reads it from the live session
|
||||
list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than
|
||||
saying which id this is.
|
||||
*/
|
||||
return ctx.json({ sessionKey, running: true });
|
||||
} catch (err) {
|
||||
// The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it
|
||||
// later is the correct response, which is a distinction a caller can act on.
|
||||
// 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct
|
||||
// response — a distinction the caller can act on.
|
||||
throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /sub-agents/budget — is there room to start one, without starting one.
|
||||
*
|
||||
* Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle
|
||||
* than be refused mid-flight asks this first; the spawn checks again regardless, because between the two
|
||||
* calls somebody else may have spent the window.
|
||||
*/
|
||||
subAgentsRouter.get('/budget', async (ctx) => {
|
||||
const budget = await checkBudget();
|
||||
return ctx.json(budget);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { minesweeperRouter } from './api/minesweeper/router';
|
||||
import { sendoffRouter } from './api/sendoff/router';
|
||||
import { subAgentsRouter } from './api/sub-agents/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
@@ -143,6 +144,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
['/sendoff', sendoffRouter],
|
||||
['/sub-agents', subAgentsRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
|
||||
@@ -241,8 +241,19 @@ describe('kinds', () => {
|
||||
// both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed
|
||||
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
|
||||
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
|
||||
//
|
||||
// `sub-agents` joined on 2026-08-23, and it is confined IN FACT on the day it was added rather than ahead of
|
||||
// itself. `POST /sub-agents/spawn` resolves the identity through `chatIdentity` from the caller's token and
|
||||
// passes `member: { osUser, home }` to the sidecar — the same mechanism `chat` runs under, reached by the
|
||||
// same resolver. It inherits the containment rather than claiming it: there is no path in that endpoint that
|
||||
// chooses a directory, so there is nothing for a caller to point somewhere else.
|
||||
test('confined is a short, deliberate list', () => {
|
||||
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']);
|
||||
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual([
|
||||
'terminal',
|
||||
'chat',
|
||||
'files',
|
||||
'sub-agents',
|
||||
]);
|
||||
});
|
||||
|
||||
test('admin permissions are never grantable', () => {
|
||||
|
||||
@@ -360,6 +360,24 @@ const CORE_REGISTRY: Permission[] = [
|
||||
person who should decide whether somebody may put a file in your home is you, not whoever administers
|
||||
roles. This grant only says the feature is reachable at all.
|
||||
*/
|
||||
/*
|
||||
`confined`, and for the same reason chat is.
|
||||
|
||||
Spawning an agent runs a process in a home directory — the caller's own, resolved by `chatIdentity`
|
||||
from their token. So it needs exactly what chat needs: a Linux account for a member, and nothing for
|
||||
the owner, with the kernel doing the containing. `authorize.ts` drops a confined grant for an account
|
||||
whose `osUser` is null, which is what stops "granted but unconfined" resolving to the owner's home.
|
||||
|
||||
Not `execution`: that kind exists for surfaces that can ONLY ever run as the owner. This one runs as
|
||||
whoever called it, which is the whole distinction.
|
||||
*/
|
||||
{
|
||||
key: 'sub-agents',
|
||||
label: 'Sub-agents',
|
||||
description: 'Start another agent from a prompt, subject to the account budget',
|
||||
kind: 'confined',
|
||||
api: ['/sub-agents'],
|
||||
},
|
||||
{
|
||||
key: 'sendoff',
|
||||
label: 'SendOff',
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type ColorPickerProps = { value?: string; name: string; label?: string };
|
||||
export const ColorPicker = (props: ColorPickerProps) => {
|
||||
const { value = '#000000', name, label } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ borderWidth: '1px' }} className="relative w-full rounded">
|
||||
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>}
|
||||
{label && (
|
||||
<span className="text-muted-foreground absolute z-10 -mt-2 ms-5 bg-background px-2 text-xs">{label}</span>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-12 overflow-hidden rounded-full p-0">
|
||||
<Input
|
||||
@@ -13,14 +17,14 @@ export const ColorPicker = (props: ColorPickerProps) => {
|
||||
className="absolute h-12 w-24 rounded-full border-0 text-lg"
|
||||
type="color"
|
||||
name={name}
|
||||
placeholder="Font Weight"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name={name}
|
||||
className="border-0 text-right text-base outline-none focus:outline-none"
|
||||
className="border-0 text-end text-base outline-none focus:outline-none"
|
||||
defaultValue={value}
|
||||
placeholder={t('colorPicker.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, Play } from 'lucide-react';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type CommandBlockProps = {
|
||||
label?: string;
|
||||
@@ -9,6 +10,7 @@ type CommandBlockProps = {
|
||||
};
|
||||
|
||||
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
@@ -20,14 +22,14 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
return (
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-duck-dark/50">{label}</span>}
|
||||
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5">
|
||||
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg ps-3 pe-1 py-1.5">
|
||||
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
|
||||
{onRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRun(command)}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||
title="Run in terminal"
|
||||
title={t('commandBlock.run')}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
@@ -36,7 +38,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||
title="Copy command"
|
||||
title={t('commandBlock.copy')}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
// Loading, failed, empty. See docs/design-language-interface.md § 6.
|
||||
//
|
||||
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => (
|
||||
<Shell>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</Shell>
|
||||
);
|
||||
export const LoadingBlock = ({ label }: { label?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Shell>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{label ?? t('state.loading')}</p>
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The real message is shown, not a friendly substitute for it. On a self-hosted platform the person
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { Column } from './DataTable';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { DataTable, useDataControl } from './DataTable';
|
||||
import enGB from '../locales/en-GB.json';
|
||||
|
||||
/**
|
||||
* The table's own chrome ("Next", "No Data Found.") comes from the `ui.*` catalog now, so these
|
||||
* assertions only hold inside a provider — the same one the app mounts. Asserting the English a user
|
||||
* actually sees, rather than a key, is the point of wrapping rather than loosening the expectations.
|
||||
*/
|
||||
const withI18n = (ui: ReactNode) => (
|
||||
<I18nProvider translations={{ 'en-GB': { ui: enGB } }} locale="en-GB" fallbackLocale="en-GB">
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
type TestItem = {
|
||||
id: number;
|
||||
@@ -55,7 +69,7 @@ const TestWrapper = ({
|
||||
describe('DataTable', () => {
|
||||
describe('rendering', () => {
|
||||
test('renders table with data', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
expect(screen.getByRole('table')).toBeDefined();
|
||||
expect(screen.getByText('Alpha')).toBeDefined();
|
||||
@@ -63,7 +77,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('renders column headers', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
// Component renders both desktop and mobile versions (hidden via CSS)
|
||||
// so we use getAllByText and verify at least one exists
|
||||
@@ -74,7 +88,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('renders correct number of rows based on page size', () => {
|
||||
render(<TestWrapper pageSize={3} />);
|
||||
render(withI18n(<TestWrapper pageSize={3} />));
|
||||
|
||||
const rows = screen.getAllByRole('row');
|
||||
// 1 header row + 3 data rows
|
||||
@@ -82,20 +96,20 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('shows no data message when data is empty', () => {
|
||||
render(<TestWrapper data={[]} noDataMessage="No items found" />);
|
||||
render(withI18n(<TestWrapper data={[]} noDataMessage="No items found" />));
|
||||
|
||||
expect(screen.getByText('No items found')).toBeDefined();
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows default no data message', () => {
|
||||
render(<TestWrapper data={[]} />);
|
||||
render(withI18n(<TestWrapper data={[]} />));
|
||||
|
||||
expect(screen.getByText('No Data Found.')).toBeDefined();
|
||||
});
|
||||
|
||||
test('renders noDataCTA when provided', () => {
|
||||
render(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />);
|
||||
render(withI18n(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
|
||||
});
|
||||
@@ -106,7 +120,7 @@ describe('DataTable', () => {
|
||||
return <DataTable dataController={controller} columns={columns} />;
|
||||
};
|
||||
|
||||
const { container } = render(<NullWrapper />);
|
||||
const { container } = render(withI18n(<NullWrapper />));
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -118,7 +132,7 @@ describe('DataTable', () => {
|
||||
return <DataTable dataController={controller} />;
|
||||
};
|
||||
|
||||
render(<AutoColumnsWrapper />);
|
||||
render(withI18n(<AutoColumnsWrapper />));
|
||||
|
||||
// Should generate columns from object keys (rendered for both mobile/desktop)
|
||||
expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
|
||||
@@ -130,7 +144,7 @@ describe('DataTable', () => {
|
||||
|
||||
describe('sorting', () => {
|
||||
test('clicking sortable column header triggers sort', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
// Get the first "Name" element (desktop version)
|
||||
const nameHeaders = screen.getAllByText('Name');
|
||||
@@ -143,7 +157,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('clicking same column twice reverses sort order', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
const nameHeaders = screen.getAllByText('Name');
|
||||
fireEvent.click(nameHeaders[0]!); // asc
|
||||
@@ -156,7 +170,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('sort icon is displayed for sortable columns', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
// Each sortable column should have the ArrowUpDown icon
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
@@ -169,19 +183,19 @@ describe('DataTable', () => {
|
||||
|
||||
describe('pagination', () => {
|
||||
test('shows pagination bar when data exceeds page size', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
|
||||
});
|
||||
|
||||
test('does not show pagination when all data fits on one page', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
expect(screen.queryByText(/Showing page/)).toBeNull();
|
||||
});
|
||||
|
||||
test('next button navigates to next page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -192,7 +206,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('previous button navigates to previous page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -205,7 +219,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('first button goes to first page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -217,7 +231,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('last button goes to last page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const lastButton = screen.getByRole('button', { name: 'Last' });
|
||||
fireEvent.click(lastButton);
|
||||
@@ -226,7 +240,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('navigation buttons are disabled at boundaries', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const firstBtn = screen.getByRole('button', { name: 'First' });
|
||||
const prevBtn = screen.getByRole('button', { name: 'Prev' });
|
||||
@@ -258,7 +272,7 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: false },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={conditionalColumns} />);
|
||||
render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
|
||||
expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
|
||||
@@ -271,7 +285,7 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: true },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={conditionalColumns} />);
|
||||
render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -283,11 +297,11 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: () => showStatus },
|
||||
];
|
||||
|
||||
const { rerender } = render(<TestWrapper cols={conditionalColumns} />);
|
||||
const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
expect(screen.queryByText('Status')).toBeNull();
|
||||
|
||||
showStatus = true;
|
||||
rerender(<TestWrapper cols={conditionalColumns} />);
|
||||
rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -303,7 +317,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={formattedColumns} />);
|
||||
render(withI18n(<TestWrapper cols={formattedColumns} />));
|
||||
|
||||
const formattedNames = screen.getAllByTestId('formatted-name');
|
||||
expect(formattedNames.length).toBeGreaterThan(0);
|
||||
@@ -319,7 +333,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={formattedColumns} />);
|
||||
render(withI18n(<TestWrapper cols={formattedColumns} />));
|
||||
|
||||
// Each row should have a unique test ID based on item.id
|
||||
expect(screen.getByTestId('item-1')).toBeDefined();
|
||||
@@ -337,7 +351,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={actionColumns} />);
|
||||
render(withI18n(<TestWrapper cols={actionColumns} />));
|
||||
|
||||
expect(screen.getByTestId('action-1')).toBeDefined();
|
||||
expect(screen.getByText('Edit Alpha')).toBeDefined();
|
||||
@@ -351,7 +365,7 @@ describe('DataTable', () => {
|
||||
{ field: 'name', label: 'Name' },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={tooltipColumns} />);
|
||||
render(withI18n(<TestWrapper cols={tooltipColumns} />));
|
||||
|
||||
// The tooltip indicator (ⓘ) should be present in the ID column headers
|
||||
const idHeaders = screen.getAllByText(/ID/);
|
||||
@@ -367,7 +381,7 @@ describe('DataTable', () => {
|
||||
{ field: 'name', label: 'Full Name', labelMobile: 'Name' },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={mobileColumns} />);
|
||||
render(withI18n(<TestWrapper cols={mobileColumns} />));
|
||||
|
||||
// Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
|
||||
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
|
||||
@@ -396,7 +410,7 @@ describe('DataTable', () => {
|
||||
);
|
||||
};
|
||||
|
||||
render(<SearchableTable />);
|
||||
render(withI18n(<SearchableTable />));
|
||||
|
||||
const searchInput = screen.getByTestId('search-input');
|
||||
fireEvent.change(searchInput, { target: { value: 'Alpha' } });
|
||||
@@ -420,7 +434,7 @@ describe('DataTable', () => {
|
||||
);
|
||||
};
|
||||
|
||||
render(<ExternalSortTable />);
|
||||
render(withI18n(<ExternalSortTable />));
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-btn'));
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
|
||||
import { PaginationBar } from './PaginationBar';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const [columns, setColumns] = useState<Column<T>[]>([]);
|
||||
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
|
||||
const theUniqueKey = uniqueKey || 'id';
|
||||
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
if (rawData.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div>
|
||||
<div className="text-muted-foreground">{noDataMessage || t('dataTable.empty')}</div>
|
||||
{noDataCTA && <>{noDataCTA}</>}
|
||||
</div>
|
||||
);
|
||||
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
<span className="min-[500px]:hidden">
|
||||
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
|
||||
</span>
|
||||
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />}
|
||||
{sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{tooltip && (
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type PaginationBarProps<T> = {
|
||||
dataManager: UseDataControlReturn<T>;
|
||||
};
|
||||
|
||||
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
|
||||
|
||||
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
|
||||
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
{/* LEFT */}
|
||||
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
|
||||
<span className="hidden min-[500px]:inline">
|
||||
Showing page {currentPage} of {pageCount}
|
||||
{t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
|
||||
</span>
|
||||
<span className="min-[500px]:hidden">
|
||||
p {currentPage}/{pageCount}
|
||||
{t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
|
||||
</span>
|
||||
|
||||
<span className="bg-border h-5 w-px" aria-hidden="true" />
|
||||
@@ -47,11 +49,11 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
hover:bg-background
|
||||
data-[state=open]:bg-muted
|
||||
"
|
||||
aria-label="Rows per page"
|
||||
aria-label={t('dataTable.pagination.rowsPerPage')}
|
||||
>
|
||||
<SelectValue className="text-muted-foreground">
|
||||
<span className="hidden min-[500px]:inline">{pageSize} / page</span>
|
||||
<span className="min-[500px]:hidden">{pageSize}/p</span>
|
||||
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
|
||||
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
data-[state=checked]:text-muted-foreground
|
||||
"
|
||||
>
|
||||
<span className="hidden min-[500px]:inline">{n} / page</span>
|
||||
<span className="min-[500px]:hidden">{n}/p</span>
|
||||
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
|
||||
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="First"
|
||||
aria-label={t('dataTable.pagination.first')}
|
||||
>
|
||||
«
|
||||
</Button>
|
||||
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Prev"
|
||||
aria-label={t('dataTable.pagination.prev')}
|
||||
>
|
||||
‹
|
||||
</Button>
|
||||
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Next"
|
||||
aria-label={t('dataTable.pagination.next')}
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(pageCount)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Last"
|
||||
aria-label={t('dataTable.pagination.last')}
|
||||
>
|
||||
»
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
|
||||
|
||||
export const ConfirmDialog = (props: ConfirmDialogProps) => {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { open, disabled = false, title, description, children } = props;
|
||||
const { onConfirm, onCancel, confirmLabel = 'Confirm', confirmVariant = 'default' } = props;
|
||||
const { onConfirm, onCancel, confirmLabel, confirmVariant = 'default' } = props;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setConfirming(true);
|
||||
@@ -37,16 +39,16 @@ export const ConfirmDialog = (props: ConfirmDialogProps) => {
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
{t('button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmVariant}
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || confirming}
|
||||
className="sm:ml-2"
|
||||
className="sm:ms-2"
|
||||
>
|
||||
{confirmLabel}
|
||||
{confirmLabel ?? t('button.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type PaperDialogProps = {
|
||||
open: boolean;
|
||||
@@ -11,6 +12,8 @@ type PaperDialogProps = {
|
||||
};
|
||||
|
||||
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOpenAutoFocus = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
onOpenAutoFocus?.(ev);
|
||||
@@ -25,9 +28,9 @@ export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: P
|
||||
className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
|
||||
style={cardStyle()}
|
||||
>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<span className="h-4 w-4 text-duck-dark">✕</span>
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t('button.close')}</span>
|
||||
</DialogPrimitive.Close>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
|
||||
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Server Error
|
||||
{t('error.server.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
|
||||
<p>{apiError?.message}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
|
||||
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Unauthorized Access
|
||||
{t('error.forbidden.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">
|
||||
Your are not authorized to perform this action.
|
||||
</DialogDescription>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p>If you think this is an error, please contact your manager.</p>
|
||||
<p>{t('error.forbidden.hint')}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user