Compare commits
5
Commits
845699e697
...
6d8709c05a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8709c05a | ||
|
|
2765e241d3 | ||
|
|
36e18f935d | ||
|
|
0ddcdcbdbf | ||
|
|
99a78f3c7f |
@@ -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,
|
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
|
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
|
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
|
- `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
|
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
|
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
|
||||||
|
|||||||
@@ -360,6 +360,19 @@ else will ever notice.
|
|||||||
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
|
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
|
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
|
||||||
manifest. Their strings are not counted above.
|
manifest. Their strings are not counted above.
|
||||||
|
|
||||||
|
**One constraint that contract has to answer, found while converting the shared components
|
||||||
|
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
|
||||||
|
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
|
||||||
|
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
|
||||||
|
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
|
||||||
|
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
|
||||||
|
|
||||||
|
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
|
||||||
|
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
|
||||||
|
provider-absent fallback inside the `components` package was considered and rejected: it would have
|
||||||
|
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
|
||||||
|
|
||||||
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
|
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.
|
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
|
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -94,6 +94,16 @@ function isHuman(value: string | undefined): value is string {
|
|||||||
return true;
|
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, '');
|
||||||
|
|
||||||
function* walk(dir: string): Generator<string> {
|
function* walk(dir: string): Generator<string> {
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
@@ -125,7 +135,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
|
|||||||
files++;
|
files++;
|
||||||
let source: string;
|
let source: string;
|
||||||
try {
|
try {
|
||||||
source = readFileSync(file, 'utf8');
|
source = stripComments(readFileSync(file, 'utf8'));
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
import type { TranslationKeys } from 'i18n';
|
import type { TranslationKeys } from 'i18n';
|
||||||
import type { InterpolationParams } from 'i18n';
|
import type { InterpolationParams } from 'i18n';
|
||||||
|
import { useCallback } from 'react';
|
||||||
import { useTranslation as useTranslationBase } from 'i18n';
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
import type en from '../locales/en-GB.json';
|
import type en from '../locales/en-GB.json';
|
||||||
|
import type uiEn from '@/components/locales/en-GB.json';
|
||||||
|
|
||||||
type AppTranslationKey = TranslationKeys<typeof en>;
|
type AppTranslationKey = TranslationKeys<typeof en>;
|
||||||
|
type UiTranslationKey = TranslationKeys<typeof uiEn>;
|
||||||
|
|
||||||
// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
/**
|
||||||
// pass the same bare key the JSON file uses.
|
* `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 = () => {
|
export const useTranslation = () => {
|
||||||
const { t: baseT, locale } = useTranslationBase();
|
const { t: baseT, locale } = useTranslationBase();
|
||||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${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 };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||||
|
import { useCallback } from 'react';
|
||||||
import { useTranslation as useTranslationBase } from 'i18n';
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
import type en from './en-GB.json';
|
import type en from './en-GB.json';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ type UiTranslationKey = TranslationKeys<typeof en>;
|
|||||||
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
|
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
|
||||||
export const useTranslation = () => {
|
export const useTranslation = () => {
|
||||||
const { t: baseT, locale } = useTranslationBase();
|
const { t: baseT, locale } = useTranslationBase();
|
||||||
const t = (key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params);
|
// Memoized: an unstable `t` is unusable in a dependency array. See platform/CLAUDE.md on the
|
||||||
|
// `useClient()` trap that silently disabled the Jellyfin player's playback reports.
|
||||||
|
const t = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
|
||||||
return { t, locale };
|
return { t, locale };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||||
import { findMissingKeys } from './parity';
|
import { findMissingKeys } from './parity';
|
||||||
import type { TranslationMap } from './types';
|
import type { TranslationKeys, TranslationMap } from './types';
|
||||||
|
|
||||||
describe('resolveKey', () => {
|
describe('resolveKey', () => {
|
||||||
const translations: TranslationMap = {
|
const translations: TranslationMap = {
|
||||||
@@ -100,3 +100,28 @@ describe('findMissingKeys', () => {
|
|||||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-level, not behavioural: a plural entry must be addressable by its own key and must NOT
|
||||||
|
* contribute its CLDR categories to the union. Asserted here because nothing else would notice —
|
||||||
|
* the failure mode is `t('files', { count })` refusing to compile while `t('files.one')` compiles.
|
||||||
|
*/
|
||||||
|
describe('TranslationKeys', () => {
|
||||||
|
test('stops at a plural leaf and still walks plain nesting', () => {
|
||||||
|
const catalog = {
|
||||||
|
files: { one: '{{count}} file', other: '{{count}} files' },
|
||||||
|
button: { save: 'Save', nested: { deep: 'Deep' } },
|
||||||
|
};
|
||||||
|
type Key = TranslationKeys<typeof catalog>;
|
||||||
|
|
||||||
|
const assertKey = <K extends Key>(k: K) => k;
|
||||||
|
expect(assertKey('files')).toBe('files');
|
||||||
|
expect(assertKey('button.save')).toBe('button.save');
|
||||||
|
expect(assertKey('button.nested.deep')).toBe('button.nested.deep');
|
||||||
|
|
||||||
|
// @ts-expect-error a plural category is not a key on its own
|
||||||
|
assertKey('files.one');
|
||||||
|
// @ts-expect-error a branch node is not a key
|
||||||
|
assertKey('button');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,10 +13,22 @@ export type I18nContextValue = {
|
|||||||
locale: string;
|
locale: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node that IS a plural entry, so `TranslationKeys` stops at it rather than walking into it.
|
||||||
|
*
|
||||||
|
* Without this, `{ one, other }` contributed `foo.one` and `foo.other` to the key union and `t('foo',
|
||||||
|
* { count })` did not typecheck at all — the plural machinery landed in phase 0 with unit tests, and the
|
||||||
|
* type gap only surfaced in phase 2 when a real catalog first used one. Mirrors `isPluralForms` at
|
||||||
|
* runtime: every key a CLDR category, every value a string.
|
||||||
|
*/
|
||||||
|
type IsPluralLeaf<T> = keyof T extends Intl.LDMLPluralRule ? (T[keyof T] extends string ? true : false) : false;
|
||||||
|
|
||||||
export type TranslationKeys<T, Prefix extends string = ''> = T extends string
|
export type TranslationKeys<T, Prefix extends string = ''> = T extends string
|
||||||
? Prefix
|
? Prefix
|
||||||
: T extends Record<string, unknown>
|
: T extends Record<string, unknown>
|
||||||
? {
|
? IsPluralLeaf<T> extends true
|
||||||
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
|
? Prefix
|
||||||
}[keyof T & string]
|
: {
|
||||||
|
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
|
||||||
|
}[keyof T & string]
|
||||||
: never;
|
: never;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||||
|
import { useCallback } from 'react';
|
||||||
import { useTranslation as useTranslationBase } from 'i18n';
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
import type en from './en-GB.json';
|
import type en from './en-GB.json';
|
||||||
|
|
||||||
@@ -8,6 +9,11 @@ type PanelTranslationKey = TranslationKeys<typeof en>;
|
|||||||
// pass the same bare key the JSON file uses.
|
// pass the same bare key the JSON file uses.
|
||||||
export const useTranslation = () => {
|
export const useTranslation = () => {
|
||||||
const { t: baseT, locale } = useTranslationBase();
|
const { t: baseT, locale } = useTranslationBase();
|
||||||
const t = (key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params);
|
// Memoized: an unstable `t` is unusable in a dependency array. See platform/CLAUDE.md on the
|
||||||
|
// `useClient()` trap that silently disabled the Jellyfin player's playback reports.
|
||||||
|
const t = useCallback(
|
||||||
|
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
|
||||||
|
[baseT],
|
||||||
|
);
|
||||||
return { t, locale };
|
return { t, locale };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user