feat: updated saas
This commit is contained in:
+255
@@ -0,0 +1,255 @@
|
|||||||
|
# Console handover
|
||||||
|
|
||||||
|
For the next developer. What this console was, what it is now, what changed, and
|
||||||
|
what is still waiting for somebody.
|
||||||
|
|
||||||
|
Two companion documents sit one directory up:
|
||||||
|
|
||||||
|
- `../REVIEW.md` — the assessment across both halves. §9 covers the console.
|
||||||
|
- `../SAAS_HARDENING.md` — the chronological log, Part 4 onwards.
|
||||||
|
|
||||||
|
The API this talks to has its own `../backend/HANDOVER.md`. **Read that one
|
||||||
|
first** — most of what this console does is expose something the server enforces,
|
||||||
|
and the reasoning lives on the server side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. In one paragraph
|
||||||
|
|
||||||
|
React 19 + Vite + TypeScript, react-router, i18next with English and Arabic
|
||||||
|
(including RTL), Tailwind with CSS custom properties for theming. It was a
|
||||||
|
working admin console covering roughly a third of what the API could do, with no
|
||||||
|
tests. It now covers all of it, has 194 tests, and paints its first screen in
|
||||||
|
**507 kB against a 550 kB budget** — *lower* than before this work began, despite
|
||||||
|
eleven more screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What was here before
|
||||||
|
|
||||||
|
| Screen | State |
|
||||||
|
|---|---|
|
||||||
|
| Sign in / sign up / reset password | Present |
|
||||||
|
| Dashboard | Present |
|
||||||
|
| Profile | Present, without sessions or security |
|
||||||
|
| Tenants (workspaces) | Present |
|
||||||
|
| Subscriptions / plans | Present |
|
||||||
|
| Roles | Present |
|
||||||
|
| Users | Present |
|
||||||
|
| Theme | Present |
|
||||||
|
| Settings | A shell |
|
||||||
|
| Modules (admin) | Present |
|
||||||
|
| Logs | Present |
|
||||||
|
|
||||||
|
**No tests at all.** No bundle budget. Both languages' translations were loaded
|
||||||
|
eagerly at startup.
|
||||||
|
|
||||||
|
Everything in this repository is uncommitted on branch `furqan`. `git diff` shows
|
||||||
|
every change to a pre-existing file; `git ls-files --others --exclude-standard`
|
||||||
|
shows the 83 new ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Screens added
|
||||||
|
|
||||||
|
Each one exposes a capability the API gained. All are lazy-loaded.
|
||||||
|
|
||||||
|
| Route | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `/settings/api-keys` | Issue and revoke keys, with scopes. The secret is shown once. |
|
||||||
|
| `/settings/webhooks` | Endpoints, delivery history, HMAC secret rotation. |
|
||||||
|
| `/settings/sign-in` | Inbound SSO — connect the customer's Azure AD / Okta / Google. |
|
||||||
|
| `/settings/email` | Send from the workspace's own address. |
|
||||||
|
| `/settings/reference` | Reference lists — the things dropdowns are made of. |
|
||||||
|
| `/settings/organisation` | Org units, membership, scoped administration, **seats**. |
|
||||||
|
| `/documents` | The workspace's file library. |
|
||||||
|
| `/users/invitations` | Invite somebody rather than choosing their password. |
|
||||||
|
| `/operations` | What the background jobs have been doing. Superadmin only. |
|
||||||
|
|
||||||
|
Added to existing screens:
|
||||||
|
|
||||||
|
- **Profile** — `SessionsPanel` (see and end your sessions), `SecurityPanel`
|
||||||
|
(MFA enrolment), and `NotificationPreferencesPanel`.
|
||||||
|
- **Users** — `DeletedUsersPanel`, since deletion is now soft.
|
||||||
|
- **Header** — `NotificationBell`.
|
||||||
|
- **Layout** — `SubscriptionBanner`, warning before a subscription lapses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Conventions worth knowing before you change anything
|
||||||
|
|
||||||
|
### `apiClient` is the only way to reach the API
|
||||||
|
|
||||||
|
`src/lib/apiClient.ts`. It refreshes an expired access token and retries once. Do
|
||||||
|
not use `fetch` directly — a request that goes around it is the one request that
|
||||||
|
fails on an expired token, for a reason nobody can see.
|
||||||
|
|
||||||
|
That includes file downloads: use `apiClient.blob`, which exists precisely so a
|
||||||
|
download is not the exception.
|
||||||
|
|
||||||
|
Toast options: `toast: false` suppresses **both** success and error toasts;
|
||||||
|
there is no error-only mode. Where a silent failure would be dangerous, the
|
||||||
|
component reports it inline instead — see `NotificationPreferencesPanel`.
|
||||||
|
|
||||||
|
### On create, absent; on edit, null
|
||||||
|
|
||||||
|
The rule that turns form state into a request, stated once because getting it
|
||||||
|
wrong is invisible:
|
||||||
|
|
||||||
|
- **Create** — "not set" is `undefined`, and the key is dropped.
|
||||||
|
- **Edit** — clearing a field means `null`, because `undefined` leaves the old
|
||||||
|
value in place and looks like the save did not work.
|
||||||
|
|
||||||
|
This is not theoretical. The workspace edit form sent `undefined` for a cleared
|
||||||
|
billing address while sending `null` for a cleared logo two lines above, so **a
|
||||||
|
billing address could be set and never removed**. The rule now lives in
|
||||||
|
`buildTenantPayload` / `buildPlanPayload`, extracted from the components and
|
||||||
|
tested.
|
||||||
|
|
||||||
|
### Translations: English is bundled, Arabic is fetched
|
||||||
|
|
||||||
|
`src/i18n/config.ts`. Both languages used to be imported statically — 81 kB of
|
||||||
|
JSON, half of it in a language the visitor had not chosen. English is now the
|
||||||
|
bundled fallback and Arabic is fetched on demand, **before** switching rather
|
||||||
|
than after, because changing to a language whose bundles have not arrived renders
|
||||||
|
a screen of raw keys.
|
||||||
|
|
||||||
|
**When you add a screen, add both `en/` and `ar/` files.** There is no test that
|
||||||
|
catches a missing Arabic key.
|
||||||
|
|
||||||
|
### The bundle has a budget, and it ratchets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build && npm run check:size # fails over 550 kB first paint
|
||||||
|
```
|
||||||
|
|
||||||
|
It has fired once, at 536 kB, and the cause was the translations above rather
|
||||||
|
than any screen. It is there to make the cost visible at the moment it becomes
|
||||||
|
worth paying attention to, rather than in six months.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Running it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # NOT `npm test` — that is the dev server in test mode
|
||||||
|
npm run test:unit # 194 tests
|
||||||
|
npm run lint
|
||||||
|
npm run build && npm run check:size
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` runs Vite against the `test` environment. The unit suite is
|
||||||
|
`test:unit`. This is the repository's existing naming and I left it alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing approach, and its limit
|
||||||
|
|
||||||
|
194 tests across 20 files. They cover the api client, the route guard, the
|
||||||
|
permission gate, the payload every form builds, and the components where being
|
||||||
|
wrong is expensive — sessions, MFA enrolment, sign-in, notifications, documents,
|
||||||
|
API keys, invitations, operations, seats.
|
||||||
|
|
||||||
|
**They are rule-level and component-level, not screen-level.** No screen is
|
||||||
|
rendered end to end against a real API. That was a deliberate trade: the payload
|
||||||
|
each form builds was the actual risk and is now tested, and rendering tests on
|
||||||
|
top would add little.
|
||||||
|
|
||||||
|
Three things learned the hard way, in case you hit them:
|
||||||
|
|
||||||
|
- `vi.fn().mockResolvedValue()` inside a hoisted `vi.mock` factory returns
|
||||||
|
`undefined`. Use a plain `async () => …`.
|
||||||
|
- Required fields render as `Label *`, so exact-string label queries miss. Use a
|
||||||
|
regex.
|
||||||
|
- Running this suite concurrently with the backend suite produces timeout
|
||||||
|
failures that are not real. Run them one at a time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. A defect worth knowing about
|
||||||
|
|
||||||
|
`CustomInput` had `htmlFor={props.id}` and **no caller passed an id**, so every
|
||||||
|
label in the product was decoration — not associated with its input, unusable
|
||||||
|
with a screen reader, and not clickable. Found while writing a test that could
|
||||||
|
not find a field by its label.
|
||||||
|
|
||||||
|
Fixed with a `useId()` fallback. If you write a new input component, this is the
|
||||||
|
mistake to not repeat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Pending
|
||||||
|
|
||||||
|
### 8.1 Nothing is blocked on the console
|
||||||
|
|
||||||
|
Every API capability now has a screen. The check that found the last gap is worth
|
||||||
|
re-running whenever the API grows — it compares what the server exposes against
|
||||||
|
what the console actually calls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rho '"/api/[a-z0-9/-]*' src/ | sort -u
|
||||||
|
```
|
||||||
|
|
||||||
|
Three capabilities were built on the server and had no screen for a while
|
||||||
|
precisely because nobody ran that comparison. It should run at the end of each
|
||||||
|
feature, not at the end of a batch.
|
||||||
|
|
||||||
|
### 8.2 No screen is rendered end to end
|
||||||
|
|
||||||
|
Stated above as a trade rather than an omission, but it is the honest next step
|
||||||
|
if you want more confidence than the current suite gives.
|
||||||
|
|
||||||
|
### 8.3 Arabic has no parity check
|
||||||
|
|
||||||
|
Every new key must be added to `en/` and `ar/` by hand. A test asserting the two
|
||||||
|
key sets match would catch what review does not. Not written.
|
||||||
|
|
||||||
|
### 8.4 CI has never been run by GitHub Actions
|
||||||
|
|
||||||
|
The repository has no remote. The workflow has been run locally step by step,
|
||||||
|
which is most of the value, but the YAML has only been parsed.
|
||||||
|
|
||||||
|
### 8.5 Depends on the backend's pending items
|
||||||
|
|
||||||
|
The console will not behave correctly until the server-side steps in
|
||||||
|
`../backend/HANDOVER.md` §7 are done — in particular the RLS role, without which
|
||||||
|
either everything or nothing is visible depending on which role the API connects
|
||||||
|
as. The operations page's audit-retention panel will read "not reporting" until
|
||||||
|
`AUDIT_RETENTION_DATABASE_URL` is set; that is the panel working, not failing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Where to look
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
lib/
|
||||||
|
apiClient.ts Read first. Every request goes through here.
|
||||||
|
queryParams.ts Table filters ↔ URL.
|
||||||
|
tablePageSize.ts
|
||||||
|
routes/
|
||||||
|
index.tsx All routes. Lazy imports at the top.
|
||||||
|
ProtectedRoutes.tsx The auth guard.
|
||||||
|
context/
|
||||||
|
AuthContext.tsx Session, permissions, superadmin flag.
|
||||||
|
ThemeContext.tsx
|
||||||
|
i18n/
|
||||||
|
config.ts English bundled, Arabic fetched. See §4.
|
||||||
|
locales/en/, ar/
|
||||||
|
application/
|
||||||
|
<feature>/
|
||||||
|
<Feature>Page.tsx The screen.
|
||||||
|
<Feature>Api.ts Its API calls.
|
||||||
|
<Feature>Types.ts Its types.
|
||||||
|
*.test.tsx
|
||||||
|
components/
|
||||||
|
custom/ Shared inputs, modals, tables, loaders.
|
||||||
|
layout/ Header, sidebar, subscription banner.
|
||||||
|
scripts/
|
||||||
|
check-bundle-size.mjs The budget.
|
||||||
|
```
|
||||||
|
|
||||||
|
The `application/<feature>/` shape — page, api, types, tests in one folder — is
|
||||||
|
the existing convention. Follow it; the codebase is consistent about it and it
|
||||||
|
makes a feature easy to delete.
|
||||||
Generated
+1499
-8
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -13,10 +13,14 @@
|
|||||||
"build:test": "tsc -b && vite build --mode test",
|
"build:test": "tsc -b && vite build --mode test",
|
||||||
"build:prod": "tsc -b && vite build --mode production",
|
"build:prod": "tsc -b && vite build --mode production",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"check:size": "node scripts/check-bundle-size.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"i18next": "^25.7.4",
|
"i18next": "^25.7.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
@@ -24,6 +28,7 @@
|
|||||||
"leaflet-control-geocoder": "^3.3.1",
|
"leaflet-control-geocoder": "^3.3.1",
|
||||||
"leaflet.fullscreen": "^5.3.0",
|
"leaflet.fullscreen": "^5.3.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.50.0",
|
"react-hook-form": "^7.50.0",
|
||||||
@@ -35,6 +40,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
|
"@testing-library/user-event": "^14.6.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -43,8 +51,10 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Fail the build if first paint grows past its budget.
|
||||||
|
*
|
||||||
|
* Route splitting is pinned by a test that reads the source, which catches a
|
||||||
|
* `lazy()` reverted to a plain import. It cannot catch the other way in: a
|
||||||
|
* dependency added to a shared component — a date library in a table cell, an
|
||||||
|
* icon pack in a layout — lands in the entry chunk and puts hundreds of
|
||||||
|
* kilobytes back into first paint with every test still green.
|
||||||
|
*
|
||||||
|
* `react-phone-number-input` is the cautionary example already in this
|
||||||
|
* repository: 459 kB of country metadata, more than the React runtime, for a
|
||||||
|
* component two admin screens use. It sits in its own chunk today. One import
|
||||||
|
* from a shared component is all it would take to move it.
|
||||||
|
*
|
||||||
|
* node scripts/check-bundle-size.mjs # check against the budget
|
||||||
|
* node scripts/check-bundle-size.mjs --report # print only, never fail
|
||||||
|
*
|
||||||
|
* Measures only what `index.html` asks for on load. Lazy chunks are the point of
|
||||||
|
* the exercise and are deliberately not counted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
// First paint is 480 kB today (398 kB of JS, 82 kB of CSS). The budget leaves
|
||||||
|
// about 70 kB of headroom: enough that ordinary work — a component, a few
|
||||||
|
// strings, an icon — never trips it, tight enough that any library worth
|
||||||
|
// worrying about does. A budget with no headroom cries wolf, and a check people
|
||||||
|
// have learned to ignore is worse than no check.
|
||||||
|
//
|
||||||
|
// Raise it deliberately, and say here what grew, so the next person can tell a
|
||||||
|
// decision from a drift.
|
||||||
|
const BUDGET_KB = 550;
|
||||||
|
|
||||||
|
const DIST = path.join(process.cwd(), "dist");
|
||||||
|
const reportOnly = process.argv.includes("--report");
|
||||||
|
|
||||||
|
if (!fs.existsSync(DIST)) {
|
||||||
|
console.error("No dist/ — run `npm run build` first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(DIST, "index.html"), "utf8");
|
||||||
|
|
||||||
|
// Everything the document pulls in before anything is interactive: the entry
|
||||||
|
// script, its stylesheet, and whatever it preloads.
|
||||||
|
const assets = [
|
||||||
|
...html.matchAll(/(?:src|href)="\/?(assets\/[^"]+\.(?:js|css))"/g),
|
||||||
|
].map((match) => match[1]);
|
||||||
|
|
||||||
|
if (assets.length === 0) {
|
||||||
|
console.error("Found no assets in dist/index.html — has the build changed?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = 0;
|
||||||
|
let gzipped = 0;
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (const asset of [...new Set(assets)]) {
|
||||||
|
const file = path.join(DIST, asset);
|
||||||
|
if (!fs.existsSync(file)) continue;
|
||||||
|
const contents = fs.readFileSync(file);
|
||||||
|
const gz = zlib.gzipSync(contents).length;
|
||||||
|
raw += contents.length;
|
||||||
|
gzipped += gz;
|
||||||
|
rows.push({ asset, kb: contents.length / 1024, gzipKb: gz / 1024 });
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => b.kb - a.kb);
|
||||||
|
|
||||||
|
console.log("First paint:");
|
||||||
|
for (const row of rows) {
|
||||||
|
console.log(
|
||||||
|
` ${row.asset.padEnd(44)} ${row.kb.toFixed(1).padStart(8)} kB` +
|
||||||
|
` (gzip ${row.gzipKb.toFixed(1)} kB)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalKb = raw / 1024;
|
||||||
|
console.log(
|
||||||
|
` ${"total".padEnd(44)} ${totalKb.toFixed(1).padStart(8)} kB` +
|
||||||
|
` (gzip ${(gzipped / 1024).toFixed(1)} kB)`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reportOnly) process.exit(0);
|
||||||
|
|
||||||
|
if (totalKb > BUDGET_KB) {
|
||||||
|
console.error(
|
||||||
|
`\nFirst paint is ${totalKb.toFixed(1)} kB, over the ${BUDGET_KB} kB budget.\n\n` +
|
||||||
|
"Something large has entered the initial payload. Usually that is a\n" +
|
||||||
|
"dependency imported by a shared component rather than by the screen that\n" +
|
||||||
|
"needs it — check the largest chunk above.\n\n" +
|
||||||
|
"If the growth is deliberate, raise BUDGET_KB in this file and say what\n" +
|
||||||
|
"grew, so the next person can tell a decision from a drift."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nWithin budget (${BUDGET_KB} kB).`);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type { ApiKeyCreateRequest, ApiKeyCreated, ApiKeyList } from "./ApiKeyTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys the workspace automates with.
|
||||||
|
*
|
||||||
|
* There is no "get one" and no "show the key again": only a hash is stored, so
|
||||||
|
* the raw key exists in exactly one response and nowhere else. A convenience
|
||||||
|
* endpoint that returned it would undo the reason for hashing it.
|
||||||
|
*/
|
||||||
|
export const apiKeyApi = {
|
||||||
|
list: () => apiClient.get<ApiKeyList>("/api/api-keys"),
|
||||||
|
|
||||||
|
issue: (payload: ApiKeyCreateRequest) =>
|
||||||
|
apiClient.post<ApiKeyCreated>("/api/api-keys", payload, {
|
||||||
|
// No success toast: the response is a secret the person has to act on, and
|
||||||
|
// a cheerful "Created successfully" beside it invites dismissing the one
|
||||||
|
// dialog they must not dismiss.
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
revoke: (id: string) =>
|
||||||
|
apiClient.delete<null>(`/api/api-keys/${id}`, {
|
||||||
|
successMessage: "Key revoked",
|
||||||
|
errorMessage: "Could not revoke the key",
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type ApiKeyState = "active" | "expired" | "revoked";
|
||||||
|
|
||||||
|
export type ApiKey = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** The visible half. Safe to show in a list or paste into a support ticket;
|
||||||
|
* useless as a credential on its own. */
|
||||||
|
prefix: string;
|
||||||
|
/** Empty means "whatever the person who issued it can do" — the honest
|
||||||
|
* default for a first key, and exactly what pasting a password would give. */
|
||||||
|
scopes: string[];
|
||||||
|
user_id: string;
|
||||||
|
last_used_at?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
state: ApiKeyState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyCreated = {
|
||||||
|
api_key: ApiKey;
|
||||||
|
/** Shown once. Only a hash is stored, so there is no endpoint that can return
|
||||||
|
* it again — which is why the screen makes this hard to dismiss. */
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyList = {
|
||||||
|
items: ApiKey[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyCreateRequest = {
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
expires_in_days?: number | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import ApiKeysPage from "./ApiKeysPage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API keys screen.
|
||||||
|
*
|
||||||
|
* One behaviour matters more than the rest: **the key is shown once**. Only a
|
||||||
|
* hash is stored, so a dialog somebody closes by reflex is a credential they
|
||||||
|
* have to revoke and reissue — which is why the "Done" button is gated behind an
|
||||||
|
* acknowledgement rather than being a plain close.
|
||||||
|
*
|
||||||
|
* The other two are about the list telling the truth: revoked keys stay on it,
|
||||||
|
* because "this key was revoked in March" is the question asked after an
|
||||||
|
* incident; and an empty scope list means "everything its owner can do", which
|
||||||
|
* is a real default and not the same as nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const list = vi.fn();
|
||||||
|
const issue = vi.fn();
|
||||||
|
const revoke = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./ApiKeyApi", () => ({
|
||||||
|
apiKeyApi: {
|
||||||
|
list: () => list(),
|
||||||
|
issue: (payload: unknown) => issue(payload),
|
||||||
|
revoke: (id: string) => revoke(id),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) =>
|
||||||
|
options && typeof options.count === "number"
|
||||||
|
? `${key}:${options.count}`
|
||||||
|
: key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const key = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: "k1",
|
||||||
|
name: "nightly-import",
|
||||||
|
prefix: "a1b2c3d4e5f6",
|
||||||
|
scopes: [] as string[],
|
||||||
|
user_id: "u1",
|
||||||
|
last_used_at: null,
|
||||||
|
expires_at: null,
|
||||||
|
revoked_at: null,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
state: "active" as const,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
list.mockReset().mockResolvedValue({ items: [], total: 0 });
|
||||||
|
issue.mockReset();
|
||||||
|
revoke.mockReset().mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the list", () => {
|
||||||
|
it("says what an empty scope list actually means", async () => {
|
||||||
|
// "Full access — whatever you can do" is a real default. An empty cell
|
||||||
|
// would read as "no permissions", which is the opposite.
|
||||||
|
list.mockResolvedValue({ items: [key()], total: 1 });
|
||||||
|
render(<ApiKeysPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/scopes\.inherited/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps revoked keys on the list", async () => {
|
||||||
|
list.mockResolvedValue({
|
||||||
|
items: [key({ state: "revoked", revoked_at: "2026-03-01T00:00:00Z" })],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
render(<ApiKeysPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("nightly-import")).toBeTruthy();
|
||||||
|
expect(screen.getByText("state.revoked")).toBeTruthy();
|
||||||
|
// Nothing left to revoke, so the button goes rather than the row.
|
||||||
|
expect(screen.queryByText("revoke")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits when it could not load", async () => {
|
||||||
|
list.mockRejectedValue(new Error("network"));
|
||||||
|
render(<ApiKeysPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("errors.loadFailed")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("issuing one", () => {
|
||||||
|
it("will not let the key be dismissed unread", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
issue.mockResolvedValue({ api_key: key(), key: "sk_a1b2c3d4e5f6_secret" });
|
||||||
|
render(<ApiKeysPage />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("issue"));
|
||||||
|
await user.type(await screen.findByLabelText(/form\.name/), "nightly");
|
||||||
|
await user.click(screen.getByText("form.submit"));
|
||||||
|
|
||||||
|
const shown = await screen.findByText("sk_a1b2c3d4e5f6_secret");
|
||||||
|
expect(shown).toBeTruthy();
|
||||||
|
|
||||||
|
const done = screen.getByText("created.done");
|
||||||
|
expect(done.closest("button")).toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText("created.acknowledge"));
|
||||||
|
expect(done.closest("button")).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be submitted without a name", async () => {
|
||||||
|
// A key nobody can identify is one nobody dares revoke.
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<ApiKeysPage />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("issue"));
|
||||||
|
const submit = await screen.findByText("form.submit");
|
||||||
|
|
||||||
|
expect(submit.closest("button")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends no scopes, so the key inherits its owner's access", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
issue.mockResolvedValue({ api_key: key(), key: "sk_x_y" });
|
||||||
|
render(<ApiKeysPage />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("issue"));
|
||||||
|
await user.type(await screen.findByLabelText(/form\.name/), "ci");
|
||||||
|
await user.click(screen.getByText("form.submit"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(issue).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: "ci", scopes: [] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows what the server said when it refuses", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
issue.mockRejectedValue(new Error("This workspace already has 50 active keys."));
|
||||||
|
render(<ApiKeysPage />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("issue"));
|
||||||
|
await user.type(await screen.findByLabelText(/form\.name/), "one-too-many");
|
||||||
|
await user.click(screen.getByText("form.submit"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("This workspace already has 50 active keys.")
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomInput,
|
||||||
|
CustomLoader,
|
||||||
|
CustomModal,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { formatDate } from "../../lib/dateFormat";
|
||||||
|
import { apiKeyApi } from "./ApiKeyApi";
|
||||||
|
import type { ApiKey } from "./ApiKeyTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keys a workspace automates with.
|
||||||
|
*
|
||||||
|
* Three things this screen is responsible for that the API cannot enforce:
|
||||||
|
*
|
||||||
|
* - **The key is shown once.** Only a hash is stored, so a dialog somebody
|
||||||
|
* closes by reflex is a key they have to revoke and reissue. It is gated
|
||||||
|
* behind an acknowledgement for the same reason recovery codes are.
|
||||||
|
* - **Revoked keys stay listed.** "This key was revoked in March" is the
|
||||||
|
* question asked after an incident, and a list that quietly drops them cannot
|
||||||
|
* answer it.
|
||||||
|
* - **What a key can do is visible.** An empty scope list means "everything its
|
||||||
|
* owner can do", which is a real and reasonable default — but it is not
|
||||||
|
* *nothing*, and showing an empty cell would read as if it were.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NewKeyDialog: React.FC<{ value: string; onDone: () => void }> = ({
|
||||||
|
value,
|
||||||
|
onDone,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation(["apikeys", "common"]);
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("created.explain")}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-[var(--card-border)] p-3">
|
||||||
|
<code className="min-w-0 flex-1 break-all font-mono text-sm text-[var(--text-primary)]">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void navigator.clipboard?.writeText(value)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm text-[var(--text-primary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acknowledged}
|
||||||
|
onChange={(event) => setAcknowledged(event.target.checked)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>{t("created.acknowledge")}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!acknowledged}
|
||||||
|
onClick={onDone}
|
||||||
|
>
|
||||||
|
{t("created.done")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiKeysPage: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation(["apikeys", "common"]);
|
||||||
|
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [expiresInDays, setExpiresInDays] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [issued, setIssued] = useState<string | null>(null);
|
||||||
|
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const list = await apiKeyApi.list();
|
||||||
|
setKeys(list.items);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const issue = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const created = await apiKeyApi.issue({
|
||||||
|
name: name.trim(),
|
||||||
|
// Empty on purpose: a first key that can do what its owner can do is
|
||||||
|
// what a customer would otherwise achieve by pasting a password.
|
||||||
|
// Narrowing is a later, deliberate step.
|
||||||
|
scopes: [],
|
||||||
|
expires_in_days: expiresInDays ? Number(expiresInDays) : null,
|
||||||
|
});
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setName("");
|
||||||
|
setExpiresInDays("");
|
||||||
|
setIssued(created.key);
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async () => {
|
||||||
|
if (!pendingRevoke) return;
|
||||||
|
const target = pendingRevoke;
|
||||||
|
setPendingRevoke(null);
|
||||||
|
try {
|
||||||
|
await apiKeyApi.revoke(target.id);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
// The toast has already said so; the list is reloaded either way so the
|
||||||
|
// screen never disagrees with the server about what is live.
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateBadge = (key: ApiKey) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
active:
|
||||||
|
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
expired:
|
||||||
|
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||||
|
revoked: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${styles[key.state]}`}
|
||||||
|
>
|
||||||
|
{t(`state.${key.state}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton variant="primary" onClick={() => setIsCreateOpen(true)}>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
{t("issue")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="px-6 py-12 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<KeyRound className="mx-auto mb-3 h-8 w-8 text-[var(--text-secondary)]" />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<li
|
||||||
|
key={key.id}
|
||||||
|
className="flex flex-wrap items-center gap-3 px-6 py-4"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{key.name}
|
||||||
|
</span>
|
||||||
|
{stateBadge(key)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-xs text-[var(--text-secondary)]">
|
||||||
|
sk_{key.prefix}…
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{[
|
||||||
|
key.scopes.length === 0
|
||||||
|
? t("scopes.inherited")
|
||||||
|
: t("scopes.limited", { count: key.scopes.length }),
|
||||||
|
key.last_used_at
|
||||||
|
? t("lastUsed", {
|
||||||
|
when: formatDate(key.last_used_at, i18n.language),
|
||||||
|
})
|
||||||
|
: t("neverUsed"),
|
||||||
|
key.expires_at
|
||||||
|
? t("expires", {
|
||||||
|
when: formatDate(key.expires_at, i18n.language),
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revoked keys keep their row — see the file comment — so the
|
||||||
|
button goes rather than the entry. */}
|
||||||
|
{key.state !== "revoked" && (
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPendingRevoke(key)}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-2 h-4 w-4" />
|
||||||
|
{t("revoke")}
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isCreateOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
title={t("issue")}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.name")}
|
||||||
|
type="text"
|
||||||
|
placeholder={t("form.namePlaceholder")}
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.expiry")}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={3650}
|
||||||
|
placeholder={t("form.expiryPlaceholder")}
|
||||||
|
value={expiresInDays}
|
||||||
|
onChange={(event) => setExpiresInDays(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.scopeNote")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={issue}
|
||||||
|
disabled={isBusy || name.trim().length === 0}
|
||||||
|
>
|
||||||
|
{t("form.submit")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={issued !== null}
|
||||||
|
onClose={() => setIssued(null)}
|
||||||
|
title={t("created.title")}
|
||||||
|
>
|
||||||
|
{issued && <NewKeyDialog value={issued} onDone={() => setIssued(null)} />}
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={pendingRevoke !== null}
|
||||||
|
onClose={() => setPendingRevoke(null)}
|
||||||
|
onConfirm={revoke}
|
||||||
|
title={t("confirmRevoke.title")}
|
||||||
|
description={t("confirmRevoke.message", { name: pendingRevoke?.name ?? "" })}
|
||||||
|
confirmText={t("revoke")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeysPage;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiClient } from "../../lib/apiClient";
|
import { apiClient } from "../../lib/apiClient";
|
||||||
import type { AuthUser, SigninRequest, SignupRequest, TokenResponse } from "./AuthTypes";
|
import type { AuthUser, SigninRequest, SignupRequest, TokenResponse } from "./AuthTypes";
|
||||||
|
import type { UserSession } from "../profile/ProfileTypes";
|
||||||
|
|
||||||
type AuthApiOptions = {
|
type AuthApiOptions = {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
@@ -71,4 +72,22 @@ export const authApi = {
|
|||||||
|
|
||||||
refresh: () =>
|
refresh: () =>
|
||||||
apiClient.post<TokenResponse>("/api/auth/refresh", {}, { silent: true }),
|
apiClient.post<TokenResponse>("/api/auth/refresh", {}, { silent: true }),
|
||||||
|
|
||||||
|
listSessions: () => apiClient.get<UserSession[]>("/api/auth/sessions"),
|
||||||
|
|
||||||
|
endSession: (sessionId: string) =>
|
||||||
|
apiClient.delete<{ message: string }>(`/api/auth/sessions/${sessionId}`, {
|
||||||
|
successMessage: "Session ended",
|
||||||
|
errorMessage: "Failed to end session",
|
||||||
|
}),
|
||||||
|
|
||||||
|
endOtherSessions: () =>
|
||||||
|
apiClient.post<{ message: string; ended: number }>(
|
||||||
|
"/api/auth/sessions/revoke-others",
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
successMessage: "Signed out everywhere else",
|
||||||
|
errorMessage: "Failed to sign out other sessions",
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
@@ -16,8 +16,24 @@ export type SubscriptionDetails = {
|
|||||||
plan_name?: string | null;
|
plan_name?: string | null;
|
||||||
start_date?: string | null;
|
start_date?: string | null;
|
||||||
end_date?: string | null;
|
end_date?: string | null;
|
||||||
|
/** The *stored* status: an administrative decision. */
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
is_active?: boolean | null;
|
is_active?: boolean | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The *derived* state, from the single lifecycle authority on the server.
|
||||||
|
* ACTIVE | GRACE | EXPIRED | CANCELLED | SUSPENDED | NONE.
|
||||||
|
*/
|
||||||
|
state?: string | null;
|
||||||
|
can_sign_in?: boolean | null;
|
||||||
|
/** False during grace: the workspace is read-only, not locked out. */
|
||||||
|
can_write?: boolean | null;
|
||||||
|
grace_until?: string | null;
|
||||||
|
grace_period_days?: number | null;
|
||||||
|
|
||||||
|
seats_used?: number | null;
|
||||||
|
seats_remaining?: number | null;
|
||||||
|
seats_over_limit?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
@@ -27,6 +43,12 @@ export type AuthUser = {
|
|||||||
last_name?: string | null;
|
last_name?: string | null;
|
||||||
phone_number?: string | null;
|
phone_number?: string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
/**
|
||||||
|
* Platform superadmin — an explicit property of the account, never inferred
|
||||||
|
* from a missing tenant_id. The backend has returned this since finding S-1;
|
||||||
|
* the frontend had no field for it, so the console could not tell.
|
||||||
|
*/
|
||||||
|
is_superadmin?: boolean;
|
||||||
tenant_id?: string | null;
|
tenant_id?: string | null;
|
||||||
tenant_name?: string | null;
|
tenant_name?: string | null;
|
||||||
tenant_logo_url?: string | null;
|
tenant_logo_url?: string | null;
|
||||||
@@ -41,6 +63,9 @@ export type SigninRequest = {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
remember_me?: boolean;
|
remember_me?: boolean;
|
||||||
|
// Absent on the first attempt: a client cannot know a factor is required
|
||||||
|
// until the server says so, which is what the 401 with X-MFA-Required is for.
|
||||||
|
mfa_code?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignupRequest = {
|
export type SignupRequest = {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import SignInForm from "./SignInForm";
|
||||||
|
import { ApiError } from "../../../lib/apiClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second-factor step on the sign-in form.
|
||||||
|
*
|
||||||
|
* The interesting behaviour is that the code field is *not* there until the
|
||||||
|
* server asks for it: prompting everybody for something almost none of them have
|
||||||
|
* is how a sign-in page teaches people to ignore it. And the trigger is a
|
||||||
|
* header, not the wording of an error — matching on English prose sent over a
|
||||||
|
* network boundary breaks the day somebody improves the message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const login = vi.fn();
|
||||||
|
const navigate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../../context/AuthContext", () => ({
|
||||||
|
useAuth: () => ({ login }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("react-router-dom")>(
|
||||||
|
"react-router-dom"
|
||||||
|
);
|
||||||
|
return { ...actual, useNavigate: () => navigate };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mfaRequired = () => {
|
||||||
|
const response = new Response(null, {
|
||||||
|
status: 401,
|
||||||
|
headers: { "X-MFA-Required": "true" },
|
||||||
|
});
|
||||||
|
return new ApiError("A verification code is required", response);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refused = () =>
|
||||||
|
new ApiError("Invalid credentials", new Response(null, { status: 401 }));
|
||||||
|
|
||||||
|
const renderForm = () =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<SignInForm />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
const signIn = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
await user.type(screen.getByLabelText(/email/i), "person@example.com");
|
||||||
|
await user.type(screen.getByLabelText(/^password/i), "CorrectHorse!9");
|
||||||
|
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
login.mockReset();
|
||||||
|
navigate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the second-factor step", () => {
|
||||||
|
it("does not ask for a code until the server does", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
login.mockResolvedValue(undefined);
|
||||||
|
renderForm();
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(/verification code/i)).toBeNull();
|
||||||
|
|
||||||
|
await signIn(user);
|
||||||
|
|
||||||
|
await waitFor(() => expect(navigate).toHaveBeenCalledWith("/dashboard"));
|
||||||
|
expect(screen.queryByLabelText(/verification code/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals the code field when the server asks for one", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
login.mockRejectedValueOnce(mfaRequired());
|
||||||
|
renderForm();
|
||||||
|
|
||||||
|
await signIn(user);
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText(/verification code/i)).toBeTruthy();
|
||||||
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show being asked for a code as an error", async () => {
|
||||||
|
// The password was right. Telling somebody "Invalid credentials" while
|
||||||
|
// showing them a code field is a contradiction they cannot resolve.
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
login.mockRejectedValueOnce(mfaRequired());
|
||||||
|
renderForm();
|
||||||
|
|
||||||
|
await signIn(user);
|
||||||
|
|
||||||
|
await screen.findByLabelText(/verification code/i);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/A verification code is required/i)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the code on the second attempt", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
login.mockRejectedValueOnce(mfaRequired());
|
||||||
|
login.mockResolvedValueOnce(undefined);
|
||||||
|
renderForm();
|
||||||
|
|
||||||
|
await signIn(user);
|
||||||
|
await user.type(await screen.findByLabelText(/verification code/i), "123456");
|
||||||
|
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(navigate).toHaveBeenCalledWith("/dashboard"));
|
||||||
|
expect(login).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: "person@example.com",
|
||||||
|
password: "CorrectHorse!9",
|
||||||
|
mfa_code: "123456",
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a spent code so the next attempt is not confusing", async () => {
|
||||||
|
// Leaving a wrong code in the field means the next attempt fails for a
|
||||||
|
// reason the person cannot see.
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
login.mockRejectedValueOnce(mfaRequired());
|
||||||
|
login.mockRejectedValueOnce(refused());
|
||||||
|
renderForm();
|
||||||
|
|
||||||
|
await signIn(user);
|
||||||
|
const field = await screen.findByLabelText(/verification code/i);
|
||||||
|
await user.type(field, "000000");
|
||||||
|
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText(/verification code/i)).toHaveValue("")
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/invalid credentials/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an ordinary failure as an error and asks for nothing", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
login.mockRejectedValueOnce(refused());
|
||||||
|
renderForm();
|
||||||
|
|
||||||
|
await signIn(user);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/invalid credentials/i)).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText(/verification code/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/custom";
|
import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/custom";
|
||||||
|
import { ApiError } from "../../../lib/apiClient";
|
||||||
import type { SigninRequest } from "../AuthTypes";
|
import type { SigninRequest } from "../AuthTypes";
|
||||||
import { useAuth } from "../../../context/AuthContext";
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
|
|
||||||
@@ -13,6 +14,10 @@ export default function SignInForm() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
// Revealed only once the server says a factor is needed. Asking for a code
|
||||||
|
// up front would prompt every person for something almost none of them have.
|
||||||
|
const [mfaRequired, setMfaRequired] = useState(false);
|
||||||
|
const [mfaCode, setMfaCode] = useState("");
|
||||||
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ export default function SignInForm() {
|
|||||||
const payload: SigninRequest = {
|
const payload: SigninRequest = {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
...(mfaCode ? { mfa_code: mfaCode } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -31,11 +37,22 @@ export default function SignInForm() {
|
|||||||
|
|
||||||
navigate("/dashboard");
|
navigate("/dashboard");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
// The server distinguishes "wrong credentials" from "right credentials,
|
||||||
error instanceof Error
|
// code still needed" with a header rather than with different prose, so
|
||||||
? error.message
|
// this does not depend on matching an English string over the wire.
|
||||||
: "Unable to sign in. Please try again.";
|
if (error instanceof ApiError && error.mfaRequired) {
|
||||||
setErrorMessage(message);
|
setMfaRequired(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
} else {
|
||||||
|
// A wrong code clears itself: leaving a spent one in the field means
|
||||||
|
// the next attempt fails for a reason the person cannot see.
|
||||||
|
setMfaCode("");
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unable to sign in. Please try again.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -77,6 +94,21 @@ export default function SignInForm() {
|
|||||||
className="!text-gray-900"
|
className="!text-gray-900"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{mfaRequired && (
|
||||||
|
<CustomInput
|
||||||
|
label="Verification code"
|
||||||
|
type="text"
|
||||||
|
placeholder="6-digit code, or a recovery code"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={(event) => setMfaCode(event.target.value)}
|
||||||
|
className="!text-gray-900"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<p className="text-sm text-red-400">
|
<p className="text-sm text-red-400">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import DocumentsPanel from "./DocumentsPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspace's own document library.
|
||||||
|
*
|
||||||
|
* A place for the files that belong to the workspace rather than to any one
|
||||||
|
* record — a handbook, a policy, a signed agreement. It is the panel with the
|
||||||
|
* workspace as its subject, and the panel is the reusable part: any screen that
|
||||||
|
* decides attachments belong on its record drops the same component in with a
|
||||||
|
* different `entityType` and `entityId`.
|
||||||
|
*
|
||||||
|
* There is deliberately no "all documents everywhere" view. A listing with no
|
||||||
|
* subject is a dump, and the API refuses to serve one for that reason.
|
||||||
|
*/
|
||||||
|
const DocumentsPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation(["documents", "common"]);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
if (!user?.tenant_id) {
|
||||||
|
return (
|
||||||
|
<p className="mx-auto max-w-3xl text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("noWorkspace")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t("pageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("pageSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentsPanel
|
||||||
|
entityType="workspace"
|
||||||
|
entityId={user.tenant_id}
|
||||||
|
title={t("title")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentsPage;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import DocumentsPanel from "./DocumentsPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attachments panel.
|
||||||
|
*
|
||||||
|
* The behaviour worth pinning is what it does *before* the network: an oversized
|
||||||
|
* file is refused here as well as on the server, because finding out after the
|
||||||
|
* upload has crossed the wire is a slow way to learn a number the screen already
|
||||||
|
* has. And the quota warning has to appear before somebody picks a file, not
|
||||||
|
* after.
|
||||||
|
*
|
||||||
|
* The download goes through the API client rather than a raw fetch, which is
|
||||||
|
* what keeps it on the same refresh-an-expired-token path as every other
|
||||||
|
* request — a download that alone fails on a stale token would fail for a reason
|
||||||
|
* nobody could see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const get = vi.fn();
|
||||||
|
const post = vi.fn();
|
||||||
|
const del = vi.fn();
|
||||||
|
const blob = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/apiClient", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: (path: string, options?: unknown) => get(path, options),
|
||||||
|
post: (path: string, body?: unknown, options?: unknown) =>
|
||||||
|
post(path, body, options),
|
||||||
|
delete: (path: string, options?: unknown) => del(path, options),
|
||||||
|
blob: (path: string) => blob(path),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) =>
|
||||||
|
options && options.limit ? `${key}:${options.limit}` : key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const document_ = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: "d1",
|
||||||
|
filename: "contract.pdf",
|
||||||
|
content_type: "application/pdf",
|
||||||
|
size_bytes: 2048,
|
||||||
|
description: null,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
used_bytes: 1024,
|
||||||
|
quota_bytes: 1024 * 1024,
|
||||||
|
max_upload_bytes: 1024 * 100,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const withData = (documents: unknown[], room = usage()) => {
|
||||||
|
get.mockImplementation((path: string) =>
|
||||||
|
path.includes("/usage")
|
||||||
|
? Promise.resolve(room)
|
||||||
|
: Promise.resolve(documents)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
post.mockReset().mockResolvedValue({});
|
||||||
|
del.mockReset().mockResolvedValue(null);
|
||||||
|
blob.mockReset().mockResolvedValue(new Blob(["x"]));
|
||||||
|
withData([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = () => <DocumentsPanel entityType="workspace" entityId="w1" />;
|
||||||
|
|
||||||
|
describe("the list", () => {
|
||||||
|
it("asks only for this record's attachments", async () => {
|
||||||
|
render(panel());
|
||||||
|
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const paths = get.mock.calls.map((call) => String(call[0]));
|
||||||
|
const listPath = paths.find((path) => !path.includes("/usage"));
|
||||||
|
|
||||||
|
expect(listPath).toBeDefined();
|
||||||
|
expect(listPath).toContain("entity_type=workspace");
|
||||||
|
expect(listPath).toContain("entity_id=w1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows what is attached", async () => {
|
||||||
|
withData([document_()]);
|
||||||
|
render(panel());
|
||||||
|
expect(await screen.findByText("contract.pdf")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says what it accepts when there is nothing yet", async () => {
|
||||||
|
render(panel());
|
||||||
|
expect(await screen.findByText("accepted")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits when it could not load", async () => {
|
||||||
|
get.mockRejectedValue(new Error("network"));
|
||||||
|
render(panel());
|
||||||
|
expect(await screen.findByText("errors.loadFailed")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the quota", () => {
|
||||||
|
it("warns before somebody picks a file, not after", async () => {
|
||||||
|
withData([], usage({ used_bytes: 999_000, quota_bytes: 1_000_000 }));
|
||||||
|
render(panel());
|
||||||
|
expect(await screen.findByText("nearlyFull")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet when there is room", async () => {
|
||||||
|
withData([], usage());
|
||||||
|
render(panel());
|
||||||
|
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByText("nearlyFull")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploading", () => {
|
||||||
|
it("refuses an oversized file without asking the server", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
withData([], usage({ max_upload_bytes: 10 }));
|
||||||
|
const { container } = render(panel());
|
||||||
|
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const input = container.querySelector('input[type="file"]')!;
|
||||||
|
await user.upload(
|
||||||
|
input as HTMLInputElement,
|
||||||
|
new File(["x".repeat(500)], "big.pdf", { type: "application/pdf" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/errors\.tooLarge/)).toBeTruthy();
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the file with the record it belongs to", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(panel());
|
||||||
|
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]')!;
|
||||||
|
await user.upload(
|
||||||
|
input as HTMLInputElement,
|
||||||
|
new File(["x"], "note.pdf", { type: "application/pdf" })
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||||
|
const [path, body] = post.mock.calls[0];
|
||||||
|
expect(path).toBe("/api/documents");
|
||||||
|
expect((body as FormData).get("entity_type")).toBe("workspace");
|
||||||
|
expect((body as FormData).get("entity_id")).toBe("w1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("downloading", () => {
|
||||||
|
it("goes through the API client rather than around it", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
withData([document_()]);
|
||||||
|
// jsdom has neither of these.
|
||||||
|
const createObjectURL = vi.fn().mockReturnValue("blob:x");
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL });
|
||||||
|
|
||||||
|
render(panel());
|
||||||
|
await screen.findByText("contract.pdf");
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
// The download is the first control on the row.
|
||||||
|
await user.click(buttons[buttons.length - 2]);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(blob).toHaveBeenCalledWith("/api/documents/d1/content")
|
||||||
|
);
|
||||||
|
// Revoked immediately: leaving these leaks on the tab nobody reloads.
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalled();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Download, FileText, Trash2, Upload } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomLoader,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import { formatDate } from "../../lib/dateFormat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachments on one record.
|
||||||
|
*
|
||||||
|
* Written as a panel rather than a page so any screen can drop it in with the
|
||||||
|
* record it belongs to — `entityType="tenant"`, `entityId={workspace.id}` — and
|
||||||
|
* so the one screen that exists today is not the only place it can ever live.
|
||||||
|
*
|
||||||
|
* ## Downloading
|
||||||
|
*
|
||||||
|
* A plain `<a href>` would not carry the session, so the bytes are fetched
|
||||||
|
* through the API client — which is what keeps a download on the same
|
||||||
|
* refresh-an-expired-token path as everything else — turned into an object URL,
|
||||||
|
* and handed to a click. A failure is then an error message rather than a
|
||||||
|
* browser showing its own JSON.
|
||||||
|
*
|
||||||
|
* The object URL is revoked immediately afterwards. Leaving them is a leak that
|
||||||
|
* only shows up on a long-lived tab, which is exactly the tab nobody reloads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DocumentSummary = {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
content_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
description?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Usage = {
|
||||||
|
used_bytes: number;
|
||||||
|
quota_bytes: number;
|
||||||
|
max_upload_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readableSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["kB", "MB", "GB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentsPanel: React.FC<{
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
/** Shown above the list. Omitted, the panel is just the list. */
|
||||||
|
title?: string;
|
||||||
|
}> = ({ entityType, entityId, title }) => {
|
||||||
|
const { t, i18n } = useTranslation(["documents", "common"]);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<DocumentSummary[]>([]);
|
||||||
|
const [usage, setUsage] = useState<Usage | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<DocumentSummary | null>(null);
|
||||||
|
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const [documents, room] = await Promise.all([
|
||||||
|
apiClient.get<DocumentSummary[]>(
|
||||||
|
`/api/documents?entity_type=${encodeURIComponent(entityType)}` +
|
||||||
|
`&entity_id=${encodeURIComponent(entityId)}`,
|
||||||
|
{ toast: false }
|
||||||
|
),
|
||||||
|
apiClient.get<Usage>("/api/documents/usage", { toast: false }),
|
||||||
|
]);
|
||||||
|
setItems(documents);
|
||||||
|
setUsage(room);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [entityType, entityId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const upload = async (file: File) => {
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Checked here as well as on the server, because finding out after the
|
||||||
|
// upload has crossed the network is a slow way to learn a number we already
|
||||||
|
// know.
|
||||||
|
if (usage && file.size > usage.max_upload_bytes) {
|
||||||
|
setError(
|
||||||
|
t("errors.tooLarge", { limit: readableSize(usage.max_upload_bytes) })
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
body.append("entity_type", entityType);
|
||||||
|
body.append("entity_id", entityId);
|
||||||
|
|
||||||
|
await apiClient.post("/api/documents", body, {
|
||||||
|
successMessage: t("uploaded"),
|
||||||
|
errorMessage: t("errors.uploadFailed"),
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInput.current) fileInput.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async (document_: DocumentSummary) => {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const blob = await apiClient.blob(
|
||||||
|
`/api/documents/${document_.id}/content`
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = document_.filename;
|
||||||
|
anchor.click();
|
||||||
|
// Immediately. Leaving these is a leak that only shows on a long-lived
|
||||||
|
// tab, which is exactly the tab nobody reloads.
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!pendingDelete) return;
|
||||||
|
const target = pendingDelete;
|
||||||
|
setPendingDelete(null);
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/documents/${target.id}`, {
|
||||||
|
successMessage: t("deleted"),
|
||||||
|
errorMessage: t("errors.generic"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nearlyFull =
|
||||||
|
usage !== null && usage.used_bytes / usage.quota_bytes > 0.9;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{title ?? t("title")}
|
||||||
|
</h3>
|
||||||
|
{usage && (
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("usage", {
|
||||||
|
used: readableSize(usage.used_bytes),
|
||||||
|
quota: readableSize(usage.quota_bytes),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) void upload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<Upload className="me-2 h-4 w-4" />
|
||||||
|
{t("upload")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* A running-low quota is the useful signal: somebody about to attach a
|
||||||
|
large file should know before they pick it, not after. */}
|
||||||
|
{nearlyFull && (
|
||||||
|
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
|
||||||
|
{t("nearlyFull")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<FileText className="mx-auto mb-2 h-7 w-7 text-[var(--text-secondary)]" />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("empty")}</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{t("accepted")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-[var(--text-secondary)]" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="break-all text-sm text-[var(--text-primary)]">
|
||||||
|
{item.filename}
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
{[
|
||||||
|
readableSize(item.size_bytes),
|
||||||
|
formatDate(item.created_at, i18n.language),
|
||||||
|
].join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void download(item)}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPendingDelete(item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
onClose={() => setPendingDelete(null)}
|
||||||
|
onConfirm={remove}
|
||||||
|
title={t("confirmDelete.title")}
|
||||||
|
description={t("confirmDelete.message", {
|
||||||
|
name: pendingDelete?.filename ?? "",
|
||||||
|
})}
|
||||||
|
confirmText={t("confirmDelete.confirm")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentsPanel;
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, CheckCircle2, Mail, Send, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomCheckBox,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomInput,
|
||||||
|
CustomLoader,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import { formatDate } from "../../lib/dateFormat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace sending from its own address.
|
||||||
|
*
|
||||||
|
* The reason this screen exists is a symptom rather than a feature:
|
||||||
|
* **invitations land in spam.** A message about a customer's own domain,
|
||||||
|
* arriving from an unfamiliar sender with no SPF or DKIM alignment, is the
|
||||||
|
* definition of what a filter is looking for.
|
||||||
|
*
|
||||||
|
* ## The ordering the screen has to make obvious
|
||||||
|
*
|
||||||
|
* Saving does **not** switch it on — a test send does. A workspace that saves a
|
||||||
|
* typo and immediately stops receiving invitations has no way to tell what
|
||||||
|
* changed, so the state after saving is "not verified", said plainly, with the
|
||||||
|
* test button as the obvious next thing.
|
||||||
|
*
|
||||||
|
* And when it is off, the platform's own account still sends everything. That is
|
||||||
|
* a fallback rather than a failure, and saying so stops somebody treating an
|
||||||
|
* unverified configuration as an outage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type EmailSettings = {
|
||||||
|
smtp_host: string;
|
||||||
|
smtp_port: number;
|
||||||
|
smtp_user?: string | null;
|
||||||
|
use_ssl: boolean;
|
||||||
|
from_address: string;
|
||||||
|
from_name?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
last_verified_at?: string | null;
|
||||||
|
last_error?: string | null;
|
||||||
|
password_set: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmailSettingsPage: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation(["email", "common"]);
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<EmailSettings | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [port, setPort] = useState("587");
|
||||||
|
const [user, setUser] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [useSsl, setUseSsl] = useState(false);
|
||||||
|
const [fromAddress, setFromAddress] = useState("");
|
||||||
|
const [fromName, setFromName] = useState("");
|
||||||
|
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [testTo, setTestTo] = useState("");
|
||||||
|
const [pendingClear, setPendingClear] = useState(false);
|
||||||
|
|
||||||
|
const apply = useCallback((found: EmailSettings | null) => {
|
||||||
|
setSettings(found);
|
||||||
|
setHost(found?.smtp_host ?? "");
|
||||||
|
setPort(String(found?.smtp_port ?? 587));
|
||||||
|
setUser(found?.smtp_user ?? "");
|
||||||
|
// Always blank: the stored password cannot be read back, so pre-filling
|
||||||
|
// anything would be a lie that overwrites it on save.
|
||||||
|
setPassword("");
|
||||||
|
setUseSsl(found?.use_ssl ?? false);
|
||||||
|
setFromAddress(found?.from_address ?? "");
|
||||||
|
setFromName(found?.from_name ?? "");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
apply(
|
||||||
|
await apiClient.get<EmailSettings | null>("/api/settings/email", {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [apply]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const saved = await apiClient.put<EmailSettings>(
|
||||||
|
"/api/settings/email",
|
||||||
|
{
|
||||||
|
smtp_host: host.trim(),
|
||||||
|
smtp_port: Number(port) || 587,
|
||||||
|
smtp_user: user.trim() || null,
|
||||||
|
...(password ? { smtp_password: password } : {}),
|
||||||
|
use_ssl: useSsl,
|
||||||
|
from_address: fromAddress.trim(),
|
||||||
|
from_name: fromName.trim() || null,
|
||||||
|
},
|
||||||
|
{ successMessage: t("saved"), errorMessage: t("errors.saveFailed") }
|
||||||
|
);
|
||||||
|
apply(saved);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await apiClient.post<EmailSettings>(
|
||||||
|
"/api/settings/email/test",
|
||||||
|
{ to_email: testTo.trim() },
|
||||||
|
{ toast: false }
|
||||||
|
);
|
||||||
|
apply(result);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = async () => {
|
||||||
|
setPendingClear(false);
|
||||||
|
await apiClient.delete("/api/settings/email", {
|
||||||
|
successMessage: t("cleared"),
|
||||||
|
errorMessage: t("errors.generic"),
|
||||||
|
});
|
||||||
|
apply(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The current state, first and unambiguous. "Not verified" is not an
|
||||||
|
outage — the platform's own account is still sending — and somebody
|
||||||
|
who reads it as one will go looking for a problem that is not there. */}
|
||||||
|
{settings && (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${settings.is_active
|
||||||
|
? "border-green-300 bg-green-50 text-green-800 dark:border-green-700/50 dark:bg-green-900/20 dark:text-green-300"
|
||||||
|
: "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{settings.is_active ? (
|
||||||
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p>
|
||||||
|
{settings.is_active
|
||||||
|
? t("status.active", {
|
||||||
|
when: formatDate(settings.last_verified_at, i18n.language),
|
||||||
|
})
|
||||||
|
: t("status.unverified")}
|
||||||
|
</p>
|
||||||
|
{settings.last_error && (
|
||||||
|
<p className="mt-1 break-words font-mono text-xs">
|
||||||
|
{settings.last_error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!settings && (
|
||||||
|
<div className="rounded-md border border-[var(--card-border)] p-3 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("status.none")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6 shadow-sm">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.host")}
|
||||||
|
type="text"
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
required
|
||||||
|
value={host}
|
||||||
|
onChange={(event) => setHost(event.target.value)}
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.port")}
|
||||||
|
type="number"
|
||||||
|
value={port}
|
||||||
|
onChange={(event) => setPort(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.user")}
|
||||||
|
type="text"
|
||||||
|
value={user}
|
||||||
|
onChange={(event) => setUser(event.target.value)}
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label={
|
||||||
|
settings?.password_set ? t("form.passwordReplace") : t("form.password")
|
||||||
|
}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{settings?.password_set && (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.passwordNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomCheckBox
|
||||||
|
label={t("form.ssl")}
|
||||||
|
checked={useSsl}
|
||||||
|
onChange={(event) => setUseSsl(event.target.checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.fromAddress")}
|
||||||
|
type="email"
|
||||||
|
placeholder="no-reply@yourcompany.com"
|
||||||
|
required
|
||||||
|
value={fromAddress}
|
||||||
|
onChange={(event) => setFromAddress(event.target.value)}
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.fromName")}
|
||||||
|
type="text"
|
||||||
|
placeholder="Your Company"
|
||||||
|
value={fromName}
|
||||||
|
onChange={(event) => setFromName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("form.spfNote")}</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={save}
|
||||||
|
disabled={isBusy || !host.trim() || !fromAddress.trim()}
|
||||||
|
>
|
||||||
|
{t("form.save")}
|
||||||
|
</CustomButton>
|
||||||
|
{settings && (
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPendingClear(true)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-2 h-4 w-4" />
|
||||||
|
{t("form.clear")}
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4 text-[var(--text-secondary)]" />
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{t("test.title")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("test.note")}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-56 flex-1">
|
||||||
|
<CustomInput
|
||||||
|
label={t("test.to")}
|
||||||
|
type="email"
|
||||||
|
value={testTo}
|
||||||
|
onChange={(event) => setTestTo(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={test}
|
||||||
|
disabled={isBusy || !testTo.trim()}
|
||||||
|
>
|
||||||
|
<Send className="me-2 h-4 w-4" />
|
||||||
|
{t("test.send")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={pendingClear}
|
||||||
|
onClose={() => setPendingClear(false)}
|
||||||
|
onConfirm={clear}
|
||||||
|
title={t("confirmClear.title")}
|
||||||
|
description={t("confirmClear.message")}
|
||||||
|
confirmText={t("form.clear")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmailSettingsPage;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import AcceptInvitationPage from "./AcceptInvitationPage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one screen in the product for somebody who has no account yet.
|
||||||
|
*
|
||||||
|
* Three properties matter more than the layout:
|
||||||
|
*
|
||||||
|
* - **A bad link says nothing useful.** Expired, revoked, already used and never
|
||||||
|
* existed answer identically, because distinguishing them tells somebody
|
||||||
|
* working through guesses which of them were real.
|
||||||
|
* - **Accepting does not sign you in.** It creates the account and stops, so
|
||||||
|
* sign-in stays the single place that decides about second factors and lapsed
|
||||||
|
* subscriptions.
|
||||||
|
* - **The passwords are matched here.** It is the one check the server cannot
|
||||||
|
* do, because it only ever receives one of the two.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const preview = vi.fn();
|
||||||
|
const accept = vi.fn();
|
||||||
|
const navigate = vi.fn();
|
||||||
|
let search = "?token=good-token";
|
||||||
|
|
||||||
|
vi.mock("./InvitationApi", () => ({
|
||||||
|
invitationApi: {
|
||||||
|
preview: (token: string) => preview(token),
|
||||||
|
accept: (payload: unknown) => accept(payload),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigate,
|
||||||
|
useSearchParams: () => [new URLSearchParams(search)],
|
||||||
|
Link: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/layout/AuthLayout", () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
search = "?token=good-token";
|
||||||
|
preview.mockReset().mockResolvedValue({
|
||||||
|
email: "newcomer@example.com",
|
||||||
|
workspace_name: "Contoso",
|
||||||
|
expires_at: "2030-01-01T00:00:00Z",
|
||||||
|
});
|
||||||
|
accept.mockReset().mockResolvedValue({
|
||||||
|
email: "newcomer@example.com",
|
||||||
|
workspace_id: "w1",
|
||||||
|
});
|
||||||
|
navigate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a usable link", () => {
|
||||||
|
it("says who it is for before asking for anything", async () => {
|
||||||
|
render(<AcceptInvitationPage />);
|
||||||
|
expect(await screen.findByText("accept.forAddress")).toBeTruthy();
|
||||||
|
expect(preview).toHaveBeenCalledWith("good-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the account and sends you to sign in", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<AcceptInvitationPage />);
|
||||||
|
|
||||||
|
await user.type(await screen.findByLabelText(/accept\.password/), "Str0ng!pass");
|
||||||
|
await user.type(screen.getByLabelText(/accept\.confirm/), "Str0ng!pass");
|
||||||
|
await user.click(screen.getByText("accept.submit"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accept).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ token: "good-token", password: "Str0ng!pass" })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// No session is issued here, so the screen hands over rather than
|
||||||
|
// pretending to be signed in.
|
||||||
|
expect(await screen.findByText("accept.doneTitle")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses two passwords that do not match, without asking the server", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<AcceptInvitationPage />);
|
||||||
|
|
||||||
|
await user.type(await screen.findByLabelText(/accept\.password/), "Str0ng!pass");
|
||||||
|
await user.type(screen.getByLabelText(/accept\.confirm/), "something-else");
|
||||||
|
await user.click(screen.getByText("accept.submit"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("accept.mismatch")).toBeTruthy();
|
||||||
|
expect(accept).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows what the server said when it refuses", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
accept.mockRejectedValue(new Error("Password too weak"));
|
||||||
|
render(<AcceptInvitationPage />);
|
||||||
|
|
||||||
|
await user.type(await screen.findByLabelText(/accept\.password/), "password");
|
||||||
|
await user.type(screen.getByLabelText(/accept\.confirm/), "password");
|
||||||
|
await user.click(screen.getByText("accept.submit"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Password too weak")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a link that is no good", () => {
|
||||||
|
it("says only that it is not valid", async () => {
|
||||||
|
preview.mockRejectedValue(new Error("nope"));
|
||||||
|
render(<AcceptInvitationPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("accept.invalidTitle")).toBeTruthy();
|
||||||
|
// Not "expired", not "revoked" — one answer for every reason.
|
||||||
|
expect(screen.queryByLabelText(/accept\.password/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing token the same way, without asking", async () => {
|
||||||
|
search = "";
|
||||||
|
render(<AcceptInvitationPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("accept.invalidTitle")).toBeTruthy();
|
||||||
|
expect(preview).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import AuthLayout from "../../components/layout/AuthLayout";
|
||||||
|
import { CustomButton, CustomInput, CustomLoader } from "../../components/custom";
|
||||||
|
import { invitationApi } from "./InvitationApi";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepting an invitation — the one screen in the product for somebody who has
|
||||||
|
* no account yet.
|
||||||
|
*
|
||||||
|
* ## What it deliberately does not do
|
||||||
|
*
|
||||||
|
* **It does not sign you in.** Acceptance creates the account and stops; you
|
||||||
|
* then sign in normally. That is one extra step for the person and removes a
|
||||||
|
* whole class of question from this page — whether a second factor applies,
|
||||||
|
* what happens if the workspace's subscription lapsed while the invitation sat
|
||||||
|
* in a mailbox, whether the session should be remembered. Sign-in answers all of
|
||||||
|
* those already, in one place.
|
||||||
|
*
|
||||||
|
* **It does not explain why a bad link is bad.** Expired, revoked, already used
|
||||||
|
* and never existed all answer the same way, because distinguishing them tells
|
||||||
|
* somebody working through guesses which of them were real.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AcceptInvitationPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation(["invitations", "common"]);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const token = params.get("token") ?? "";
|
||||||
|
|
||||||
|
const [preview, setPreview] = useState<{
|
||||||
|
email: string;
|
||||||
|
workspace_name: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [invalid, setInvalid] = useState(false);
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setInvalid(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const found = await invitationApi.preview(token);
|
||||||
|
setPreview(found);
|
||||||
|
} catch {
|
||||||
|
setInvalid(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
setError("");
|
||||||
|
if (password !== confirmation) {
|
||||||
|
// Checked here rather than on the server: it is the one validation the
|
||||||
|
// server cannot do, because it only ever receives one of the two.
|
||||||
|
setError(t("accept.mismatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
await invitationApi.accept({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
first_name: firstName.trim() || null,
|
||||||
|
last_name: lastName.trim() || null,
|
||||||
|
});
|
||||||
|
setDone(true);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalid) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-white">
|
||||||
|
{t("accept.invalidTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-300">{t("accept.invalidBody")}</p>
|
||||||
|
<Link to="/signin">
|
||||||
|
<CustomButton variant="primary" className="w-full">
|
||||||
|
{t("accept.toSignIn")}
|
||||||
|
</CustomButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-white">
|
||||||
|
{t("accept.doneTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-300">{t("accept.doneBody")}</p>
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate("/signin")}
|
||||||
|
>
|
||||||
|
{t("accept.toSignIn")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-2 text-2xl font-semibold text-white">
|
||||||
|
{t("accept.title", { workspace: preview?.workspace_name ?? "" })}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
{t("accept.forAddress", { email: preview?.email ?? "" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.firstName")}
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(event) => setFirstName(event.target.value)}
|
||||||
|
className="!text-gray-900"
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.lastName")}
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(event) => setLastName(event.target.value)}
|
||||||
|
className="!text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("accept.password")}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
className="!text-gray-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("accept.confirm")}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(event) => setConfirmation(event.target.value)}
|
||||||
|
className="!text-gray-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-300">{t("accept.privacy")}</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={accept}
|
||||||
|
loading={isBusy}
|
||||||
|
disabled={isBusy || password.length === 0}
|
||||||
|
>
|
||||||
|
{t("accept.submit")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<div className="flex flex-1 flex-col [&_.text-gray-700]:!text-white [&_label]:!text-gray-300">
|
||||||
|
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center">
|
||||||
|
{body()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AcceptInvitationPage;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type { InvitationCreated, InvitationList } from "./InvitationTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inviting somebody, rather than choosing their password for them.
|
||||||
|
*
|
||||||
|
* `preview` and `accept` are the signed-out half: whoever is holding the link
|
||||||
|
* has no account yet, which is the whole point. They are the only two calls in
|
||||||
|
* the product that deliberately carry no session.
|
||||||
|
*/
|
||||||
|
export const invitationApi = {
|
||||||
|
list: () => apiClient.get<InvitationList>("/api/user/invitations"),
|
||||||
|
|
||||||
|
invite: (payload: {
|
||||||
|
email: string;
|
||||||
|
role_id?: string | null;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
}) =>
|
||||||
|
apiClient.post<InvitationCreated>("/api/user/invitations", payload, {
|
||||||
|
// The response carries a link the administrator may need to pass on by
|
||||||
|
// hand, so the dialog is the message rather than a toast beside it.
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
resend: (id: string) =>
|
||||||
|
apiClient.post<InvitationCreated>(`/api/user/invitations/${id}/resend`, null, {
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
revoke: (id: string) =>
|
||||||
|
apiClient.delete<null>(`/api/user/invitations/${id}`, {
|
||||||
|
successMessage: "Invitation revoked",
|
||||||
|
errorMessage: "Could not revoke the invitation",
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Signed out. Says only the address and the workspace name — a guessed token
|
||||||
|
* must not become a way to read a workspace's staff list. */
|
||||||
|
preview: (token: string) =>
|
||||||
|
apiClient.get<{
|
||||||
|
email: string;
|
||||||
|
workspace_name: string;
|
||||||
|
expires_at: string;
|
||||||
|
}>(`/api/invitations/preview?token=${encodeURIComponent(token)}`, {
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
accept: (payload: {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
}) =>
|
||||||
|
apiClient.post<{ email: string; workspace_id: string }>(
|
||||||
|
"/api/invitations/accept",
|
||||||
|
payload,
|
||||||
|
{ toast: false }
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type InvitationState = "pending" | "accepted" | "revoked" | "expired";
|
||||||
|
|
||||||
|
export type Invitation = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
role_id?: string | null;
|
||||||
|
invited_by_id?: string | null;
|
||||||
|
expires_at: string;
|
||||||
|
accepted_at?: string | null;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
/** Derived from the clock rather than stored — "expired" is a fact about
|
||||||
|
* today, and a column would go stale the moment it was written. */
|
||||||
|
state: InvitationState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvitationCreated = {
|
||||||
|
invitation: Invitation;
|
||||||
|
/** Returned so an administrator can pass the link on by hand when mail does
|
||||||
|
* not arrive. Leaving it out means keeping a second copy of the token
|
||||||
|
* somewhere worse — in an email thread, usually. */
|
||||||
|
acceptance_url: string;
|
||||||
|
email_sent: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvitationList = {
|
||||||
|
items: Invitation[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Copy, MailPlus, RotateCw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomInput,
|
||||||
|
CustomLoader,
|
||||||
|
CustomModal,
|
||||||
|
CustomSearchableDropdown,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { formatDate } from "../../lib/dateFormat";
|
||||||
|
import { rolesApi } from "../roles/RolesApi";
|
||||||
|
import type { Role } from "../roles/RolesTypes";
|
||||||
|
import { invitationApi } from "./InvitationApi";
|
||||||
|
import type {
|
||||||
|
Invitation,
|
||||||
|
InvitationCreated,
|
||||||
|
InvitationState,
|
||||||
|
} from "./InvitationTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitations.
|
||||||
|
*
|
||||||
|
* This is the screen that replaces an administrator typing somebody else's
|
||||||
|
* password into a form. That mattered more than it sounds: the password was then
|
||||||
|
* known to two people, and the one it did not belong to was the one with
|
||||||
|
* administrative access — so "the account holder did this" was never a claim the
|
||||||
|
* audit trail could support.
|
||||||
|
*
|
||||||
|
* Two things the screen has to be honest about:
|
||||||
|
*
|
||||||
|
* - **Whether the email actually went.** `email_sent` is false when the mail
|
||||||
|
* host was down, and the invitation is still perfectly valid. Hiding that
|
||||||
|
* would leave an administrator waiting for somebody who never heard.
|
||||||
|
* - **Resending issues a new link.** The old one is not recoverable — only its
|
||||||
|
* hash was ever stored — and it is revoked, which is usually the point: the
|
||||||
|
* reason to resend is that the first message went somewhere it should not
|
||||||
|
* have.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LinkDialog: React.FC<{
|
||||||
|
created: InvitationCreated;
|
||||||
|
onDone: () => void;
|
||||||
|
}> = ({ created, onDone }) => {
|
||||||
|
const { t } = useTranslation(["invitations", "common"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-primary)]">
|
||||||
|
{created.email_sent
|
||||||
|
? t("created.sent", { email: created.invitation.email })
|
||||||
|
: t("created.notSent", { email: created.invitation.email })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!created.email_sent && (
|
||||||
|
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
|
||||||
|
{t("created.notSentExplain")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-[var(--card-border)] p-3">
|
||||||
|
<code className="min-w-0 flex-1 break-all font-mono text-xs text-[var(--text-primary)]">
|
||||||
|
{created.acceptance_url}
|
||||||
|
</code>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
void navigator.clipboard?.writeText(created.acceptance_url)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("created.warning")}</p>
|
||||||
|
|
||||||
|
<CustomButton variant="primary" className="w-full" onClick={onDone}>
|
||||||
|
{t("created.done")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InvitationsPage: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation(["invitations", "common"]);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<Invitation[]>([]);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const [isInviteOpen, setIsInviteOpen] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [roleId, setRoleId] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [created, setCreated] = useState<InvitationCreated | null>(null);
|
||||||
|
const [pendingRevoke, setPendingRevoke] = useState<Invitation | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const list = await invitationApi.list();
|
||||||
|
setItems(list.items);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// Roles are optional here — an invitation without one creates an account
|
||||||
|
// with no role, which an administrator can set afterwards. A failure to
|
||||||
|
// load them must not stop somebody inviting a colleague.
|
||||||
|
void rolesApi
|
||||||
|
.getAll()
|
||||||
|
.then((loaded) => setRoles(loaded))
|
||||||
|
.catch(() => setRoles([]));
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const invite = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await invitationApi.invite({
|
||||||
|
email: email.trim(),
|
||||||
|
first_name: firstName.trim() || null,
|
||||||
|
last_name: lastName.trim() || null,
|
||||||
|
role_id: roleId || null,
|
||||||
|
});
|
||||||
|
setIsInviteOpen(false);
|
||||||
|
setEmail("");
|
||||||
|
setFirstName("");
|
||||||
|
setLastName("");
|
||||||
|
setRoleId("");
|
||||||
|
setCreated(result);
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resend = async (invitation: Invitation) => {
|
||||||
|
const result = await invitationApi.resend(invitation.id);
|
||||||
|
setCreated(result);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async () => {
|
||||||
|
if (!pendingRevoke) return;
|
||||||
|
const target = pendingRevoke;
|
||||||
|
setPendingRevoke(null);
|
||||||
|
try {
|
||||||
|
await invitationApi.revoke(target.id);
|
||||||
|
} finally {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateBadge = (invitation: Invitation) => {
|
||||||
|
const styles: Record<InvitationState, string> = {
|
||||||
|
pending: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||||
|
accepted:
|
||||||
|
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
expired:
|
||||||
|
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||||
|
revoked: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${styles[invitation.state]}`}
|
||||||
|
>
|
||||||
|
{t(`state.${invitation.state}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton variant="primary" onClick={() => setIsInviteOpen(true)}>
|
||||||
|
<MailPlus className="me-2 h-4 w-4" />
|
||||||
|
{t("invite")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="px-6 py-12 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<MailPlus className="mx-auto mb-3 h-8 w-8 text-[var(--text-secondary)]" />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{items.map((invitation) => (
|
||||||
|
<li
|
||||||
|
key={invitation.id}
|
||||||
|
className="flex flex-wrap items-center gap-3 px-6 py-4"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="break-all font-medium text-[var(--text-primary)]">
|
||||||
|
{invitation.email}
|
||||||
|
</span>
|
||||||
|
{stateBadge(invitation)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{invitation.state === "accepted"
|
||||||
|
? t("acceptedOn", {
|
||||||
|
when: formatDate(invitation.accepted_at, i18n.language),
|
||||||
|
})
|
||||||
|
: t("expiresOn", {
|
||||||
|
when: formatDate(invitation.expires_at, i18n.language),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invitation.state !== "accepted" && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void resend(invitation)}
|
||||||
|
>
|
||||||
|
<RotateCw className="me-2 h-4 w-4" />
|
||||||
|
{t("resend")}
|
||||||
|
</CustomButton>
|
||||||
|
{invitation.state === "pending" && (
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPendingRevoke(invitation)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isInviteOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsInviteOpen(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
title={t("invite")}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.email")}
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.firstName")}
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(event) => setFirstName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.lastName")}
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(event) => setLastName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{roles.length > 0 && (
|
||||||
|
<CustomSearchableDropdown
|
||||||
|
label={t("form.role")}
|
||||||
|
value={roleId}
|
||||||
|
onChange={(value) => setRoleId(value)}
|
||||||
|
options={roles.map((role) => ({
|
||||||
|
label: role.role_name,
|
||||||
|
value: role.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.explain")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={invite}
|
||||||
|
disabled={isBusy || email.trim().length === 0}
|
||||||
|
>
|
||||||
|
{t("form.submit")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={created !== null}
|
||||||
|
onClose={() => setCreated(null)}
|
||||||
|
title={t("created.title")}
|
||||||
|
>
|
||||||
|
{created && (
|
||||||
|
<LinkDialog created={created} onDone={() => setCreated(null)} />
|
||||||
|
)}
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={pendingRevoke !== null}
|
||||||
|
onClose={() => setPendingRevoke(null)}
|
||||||
|
onConfirm={revoke}
|
||||||
|
title={t("confirmRevoke.title")}
|
||||||
|
description={t("confirmRevoke.message", {
|
||||||
|
email: pendingRevoke?.email ?? "",
|
||||||
|
})}
|
||||||
|
confirmText={t("confirmRevoke.confirm")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InvitationsPage;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type {
|
||||||
|
NotificationList,
|
||||||
|
NotificationPreference,
|
||||||
|
UnreadCount,
|
||||||
|
} from "./NotificationTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Your own notifications.
|
||||||
|
*
|
||||||
|
* `unreadCount` is a separate call on purpose: the bell polls it and nothing
|
||||||
|
* else, and fetching the whole list to render a number would be a query per
|
||||||
|
* poll per signed-in person.
|
||||||
|
*
|
||||||
|
* Nothing here shows a toast. A notification *is* the notice — announcing that
|
||||||
|
* we fetched your notices, or that one was marked read, is noise on top of the
|
||||||
|
* thing itself.
|
||||||
|
*/
|
||||||
|
export const notificationApi = {
|
||||||
|
unreadCount: () =>
|
||||||
|
apiClient.get<UnreadCount>("/api/notifications/unread-count", {
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
list: (unreadOnly = false) =>
|
||||||
|
apiClient.get<NotificationList>(
|
||||||
|
`/api/notifications?unread_only=${unreadOnly ? "true" : "false"}&limit=50`,
|
||||||
|
{ toast: false }
|
||||||
|
),
|
||||||
|
|
||||||
|
markRead: (id: string) =>
|
||||||
|
apiClient.post<null>(`/api/notifications/${id}/read`, null, { toast: false }),
|
||||||
|
|
||||||
|
markAllRead: () =>
|
||||||
|
apiClient.post<{ marked: number }>("/api/notifications/read-all", null, {
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every kind with your answer for each — not only the ones you have changed.
|
||||||
|
*
|
||||||
|
* Absence means enabled on the server, so a listing of stored rows would be
|
||||||
|
* empty for almost everybody. The catalogue arrives filled in instead.
|
||||||
|
*/
|
||||||
|
preferences: () =>
|
||||||
|
apiClient.get<NotificationPreference[]>("/api/notifications/preferences", {
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One kind at a time, on your own account. There is no user id to pass: a
|
||||||
|
* preference somebody else set on your behalf is not a preference.
|
||||||
|
*
|
||||||
|
* No toast either way. A success toast per flip is noise on a list of seven
|
||||||
|
* switches, and the panel reports a failure better than a toast can: it puts
|
||||||
|
* the switch back where it was and says so inline. Being left believing you
|
||||||
|
* turned something off when you did not is the failure that shows up weeks
|
||||||
|
* later as silence — so it has to be visible *on the switch*.
|
||||||
|
*/
|
||||||
|
setPreference: (kind: string, enabled: boolean) =>
|
||||||
|
apiClient.put<NotificationPreference[]>(
|
||||||
|
"/api/notifications/preferences",
|
||||||
|
{ kind, channel: "in_app", enabled },
|
||||||
|
{ toast: false }
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import NotificationBell from "./NotificationBell";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bell.
|
||||||
|
*
|
||||||
|
* Two things it must not do, and both are easy to do by accident:
|
||||||
|
*
|
||||||
|
* - **Mark everything read on open.** "I glanced at the bell" is not "I dealt
|
||||||
|
* with these", and a list that empties itself as you look at it is one you
|
||||||
|
* cannot come back to.
|
||||||
|
* - **Poll a hidden tab.** A laptop left open on this page overnight would make
|
||||||
|
* several thousand pointless requests.
|
||||||
|
*
|
||||||
|
* The optimistic update also has to be reversible: showing something as read
|
||||||
|
* when the server still holds it unread means it drops out of the filtered view
|
||||||
|
* and never returns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const unreadCount = vi.fn();
|
||||||
|
const list = vi.fn();
|
||||||
|
const markRead = vi.fn();
|
||||||
|
const markAllRead = vi.fn();
|
||||||
|
const navigate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./NotificationApi", () => ({
|
||||||
|
notificationApi: {
|
||||||
|
unreadCount: () => unreadCount(),
|
||||||
|
list: (unreadOnly?: boolean) => list(unreadOnly),
|
||||||
|
markRead: (id: string) => markRead(id),
|
||||||
|
markAllRead: () => markAllRead(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", () => ({ useNavigate: () => navigate }));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) =>
|
||||||
|
options && typeof options.count === "number"
|
||||||
|
? `${key}:${options.count}`
|
||||||
|
: key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notice = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||||
|
id: "n1",
|
||||||
|
kind: "webhook.disabled",
|
||||||
|
severity: "warning" as const,
|
||||||
|
title: "A webhook endpoint was switched off",
|
||||||
|
body: "https://hooks.example.com — fix the receiver",
|
||||||
|
link: "/settings/webhooks",
|
||||||
|
read_at: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
unreadCount.mockReset().mockResolvedValue({ unread: 0 });
|
||||||
|
list.mockReset().mockResolvedValue({ items: [], unread: 0 });
|
||||||
|
markRead.mockReset().mockResolvedValue(null);
|
||||||
|
markAllRead.mockReset().mockResolvedValue({ marked: 0 });
|
||||||
|
navigate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the badge", () => {
|
||||||
|
it("shows nothing when there is nothing", async () => {
|
||||||
|
render(<NotificationBell />);
|
||||||
|
await waitFor(() => expect(unreadCount).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByText("0")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the count", async () => {
|
||||||
|
unreadCount.mockResolvedValue({ unread: 3 });
|
||||||
|
render(<NotificationBell />);
|
||||||
|
expect(await screen.findByText("3")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a large count rather than printing it", async () => {
|
||||||
|
// A three-digit badge is unreadable, and the exact number stops meaning
|
||||||
|
// anything long before that.
|
||||||
|
unreadCount.mockResolvedValue({ unread: 250 });
|
||||||
|
render(<NotificationBell />);
|
||||||
|
expect(await screen.findByText("99+")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet when the count cannot be fetched", async () => {
|
||||||
|
// A bell showing an error is worse than one showing nothing: the count
|
||||||
|
// is a convenience and the notices are still there.
|
||||||
|
unreadCount.mockRejectedValue(new Error("network"));
|
||||||
|
render(<NotificationBell />);
|
||||||
|
await waitFor(() => expect(unreadCount).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByText(/error/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the list", () => {
|
||||||
|
it("loads only when opened", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<NotificationBell />);
|
||||||
|
await waitFor(() => expect(unreadCount).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(list).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
await waitFor(() => expect(list).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark everything read just because it was opened", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
list.mockResolvedValue({ items: [notice()], unread: 1 });
|
||||||
|
render(<NotificationBell />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
await screen.findByText("A webhook endpoint was switched off");
|
||||||
|
|
||||||
|
expect(markAllRead).not.toHaveBeenCalled();
|
||||||
|
expect(markRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when it is empty rather than showing nothing", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<NotificationBell />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
expect(await screen.findByText("empty")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits when it could not load", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
list.mockRejectedValue(new Error("network"));
|
||||||
|
render(<NotificationBell />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
expect(await screen.findByText("loadFailed")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("acting on one", () => {
|
||||||
|
it("marks it read and follows its link", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
list.mockResolvedValue({ items: [notice()], unread: 1 });
|
||||||
|
render(<NotificationBell />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
await user.click(
|
||||||
|
await screen.findByText("A webhook endpoint was switched off")
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(markRead).toHaveBeenCalledWith("n1"));
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/settings/webhooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not navigate when there is nowhere to go", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
list.mockResolvedValue({ items: [notice({ link: null })], unread: 1 });
|
||||||
|
render(<NotificationBell />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
await user.click(
|
||||||
|
await screen.findByText("A webhook endpoint was switched off")
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(markRead).toHaveBeenCalled());
|
||||||
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts it back when the server refuses", async () => {
|
||||||
|
// Showing it as read when the server still holds it unread means it
|
||||||
|
// drops out of the filtered view and never comes back.
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
list.mockResolvedValue({ items: [notice({ link: null })], unread: 1 });
|
||||||
|
markRead.mockRejectedValue(new Error("nope"));
|
||||||
|
render(<NotificationBell />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
await user.click(
|
||||||
|
await screen.findByText("A webhook endpoint was switched off")
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("1")).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers mark-all only while something is unread", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
list.mockResolvedValue({
|
||||||
|
items: [notice({ read_at: new Date().toISOString() })],
|
||||||
|
unread: 0,
|
||||||
|
});
|
||||||
|
render(<NotificationBell />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
await screen.findByText("A webhook endpoint was switched off");
|
||||||
|
|
||||||
|
expect(screen.queryByText("markAll")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("polling", () => {
|
||||||
|
it("stops while the tab is hidden and catches up on return", async () => {
|
||||||
|
render(<NotificationBell />);
|
||||||
|
await waitFor(() => expect(unreadCount).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const hidden = vi.spyOn(document, "hidden", "get");
|
||||||
|
|
||||||
|
hidden.mockReturnValue(true);
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
expect(unreadCount).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
hidden.mockReturnValue(false);
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
|
||||||
|
// Immediately, rather than leaving a stale count for up to a minute
|
||||||
|
// after somebody comes back to the tab.
|
||||||
|
await waitFor(() => expect(unreadCount).toHaveBeenCalledTimes(2));
|
||||||
|
hidden.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, Bell, Check, Info } from "lucide-react";
|
||||||
|
|
||||||
|
import { CustomLoader } from "../../components/custom";
|
||||||
|
import { notificationApi } from "./NotificationApi";
|
||||||
|
import type { Notification } from "./NotificationTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bell, and the list behind it.
|
||||||
|
*
|
||||||
|
* ## Polling, and why it is slow
|
||||||
|
*
|
||||||
|
* Sixty seconds. These are not chat messages — the things that raise one are a
|
||||||
|
* webhook endpoint being switched off, an account being locked, a key being
|
||||||
|
* issued. Minutes-late is fine for all of them, and a five-second poll would be
|
||||||
|
* a request per signed-in person every five seconds for the rest of time.
|
||||||
|
*
|
||||||
|
* The poll stops while the tab is hidden. A laptop left open on this page
|
||||||
|
* overnight would otherwise make several thousand pointless requests.
|
||||||
|
*
|
||||||
|
* ## What it does not do
|
||||||
|
*
|
||||||
|
* It does not mark everything read on open. "I glanced at the bell" is not "I
|
||||||
|
* dealt with these", and a list that empties itself as you look at it is one you
|
||||||
|
* cannot come back to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
const relativeTime = (value: string, locale: string) => {
|
||||||
|
const then = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(then)) return value;
|
||||||
|
|
||||||
|
const seconds = Math.round((then - Date.now()) / 1000);
|
||||||
|
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||||
|
const steps: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
["second", 60],
|
||||||
|
["minute", 60],
|
||||||
|
["hour", 24],
|
||||||
|
["day", 7],
|
||||||
|
["week", 4.35],
|
||||||
|
["month", 12],
|
||||||
|
];
|
||||||
|
|
||||||
|
let amount = seconds;
|
||||||
|
for (const [unit, size] of steps) {
|
||||||
|
if (Math.abs(amount) < size) return formatter.format(Math.round(amount), unit);
|
||||||
|
amount /= size;
|
||||||
|
}
|
||||||
|
return formatter.format(Math.round(amount), "year");
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationBell: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation(["notifications", "common"]);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [items, setItems] = useState<Notification[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const refreshCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { unread: count } = await notificationApi.unreadCount();
|
||||||
|
setUnread(count);
|
||||||
|
} catch {
|
||||||
|
// Silent. A bell that shows an error is worse than a bell that shows
|
||||||
|
// nothing — the count is a convenience, and the notices are still there.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshCount();
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (timer === null) timer = setInterval(() => void refreshCount(), POLL_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stop();
|
||||||
|
} else {
|
||||||
|
// Catch up immediately on return, rather than leaving a stale count for
|
||||||
|
// up to a minute after somebody comes back to the tab.
|
||||||
|
void refreshCount();
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!document.hidden) start();
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
|
}, [refreshCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onClickOutside = (event: MouseEvent) => {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const list = await notificationApi.list();
|
||||||
|
setItems(list.items);
|
||||||
|
setUnread(list.unread);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsOpen(false);
|
||||||
|
} else {
|
||||||
|
void open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markRead = async (notification: Notification) => {
|
||||||
|
if (notification.read_at) return;
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === notification.id
|
||||||
|
? { ...item, read_at: new Date().toISOString() }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setUnread((count) => Math.max(0, count - 1));
|
||||||
|
try {
|
||||||
|
await notificationApi.markRead(notification.id);
|
||||||
|
} catch {
|
||||||
|
// Put it back. Showing it as read when the server still has it unread
|
||||||
|
// means it disappears from the filtered view and never comes back.
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === notification.id ? { ...item, read_at: null } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setUnread((count) => count + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAll = async () => {
|
||||||
|
const previous = items;
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((item) => ({ ...item, read_at: item.read_at ?? stamp }))
|
||||||
|
);
|
||||||
|
setUnread(0);
|
||||||
|
try {
|
||||||
|
await notificationApi.markAllRead();
|
||||||
|
} catch {
|
||||||
|
setItems(previous);
|
||||||
|
void refreshCount();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activate = (notification: Notification) => {
|
||||||
|
void markRead(notification);
|
||||||
|
if (notification.link) {
|
||||||
|
setIsOpen(false);
|
||||||
|
navigate(notification.link);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={panelRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
className="relative flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-white/50 transition-all hover:border-gray-300 hover:bg-gray-50"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={
|
||||||
|
unread > 0
|
||||||
|
? t("aria.withCount", { count: unread })
|
||||||
|
: t("aria.none")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4 text-gray-600" />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -top-1 ltr:-right-1 rtl:-left-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold text-white">
|
||||||
|
{/* Capped, because a three-digit badge is unreadable and the exact
|
||||||
|
number stops meaning anything long before that. */}
|
||||||
|
{unread > 99 ? "99+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 mt-2 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-lg ltr:right-0 rtl:left-0">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--card-border)] px-4 py-3">
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</span>
|
||||||
|
{items.some((item) => !item.read_at) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={markAll}
|
||||||
|
className="text-xs font-medium text-blue-600 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
{t("markAll")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.severity === "warning" ? AlertTriangle : Info;
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => activate(item)}
|
||||||
|
className={`flex w-full items-start gap-3 px-4 py-3 text-start transition-colors hover:bg-[var(--card-border)]/20 ${item.read_at ? "opacity-60" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`mt-0.5 h-4 w-4 shrink-0 ${item.severity === "warning"
|
||||||
|
? "text-amber-500"
|
||||||
|
: "text-blue-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{item.body && (
|
||||||
|
<span className="mt-0.5 block text-xs text-[var(--text-secondary)]">
|
||||||
|
{item.body}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="mt-1 block text-xs text-[var(--text-secondary)]">
|
||||||
|
{relativeTime(item.created_at, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{!item.read_at && (
|
||||||
|
<span
|
||||||
|
className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-blue-500"
|
||||||
|
aria-label={t("aria.unread")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.read_at && (
|
||||||
|
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--text-secondary)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationBell;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import NotificationPreferencesPanel from "./NotificationPreferencesPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole feature is "let somebody turn a notification off". Every test here
|
||||||
|
* is about the two ways that promise breaks: a switch that says off when the
|
||||||
|
* server says on, and a failure that looks like a success.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const preferences = vi.fn();
|
||||||
|
const setPreference = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./NotificationApi", () => ({
|
||||||
|
notificationApi: {
|
||||||
|
preferences: () => preferences(),
|
||||||
|
setPreference: (kind: string, enabled: boolean) => setPreference(kind, enabled),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: { defaultValue?: string }) =>
|
||||||
|
options?.defaultValue !== undefined && options.defaultValue !== ""
|
||||||
|
? options.defaultValue
|
||||||
|
: key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const preference = (kind: string, enabled = true) => ({
|
||||||
|
kind,
|
||||||
|
channel: "in_app",
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
preferences.mockReset();
|
||||||
|
setPreference.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NotificationPreferencesPanel", () => {
|
||||||
|
it("shows every kind, not only the ones already changed", async () => {
|
||||||
|
// A row exists on the server only where somebody turned something off,
|
||||||
|
// so a screen listing stored rows would be empty for almost everybody.
|
||||||
|
preferences.mockResolvedValue([
|
||||||
|
preference("security.account_locked"),
|
||||||
|
preference("webhook.disabled", false),
|
||||||
|
preference("invitation.accepted"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
const switches = await screen.findAllByRole("switch");
|
||||||
|
expect(switches).toHaveLength(3);
|
||||||
|
expect(switches[0]).toBeChecked();
|
||||||
|
expect(switches[1]).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the ones somebody would regret turning off", async () => {
|
||||||
|
// Marked rather than prevented: somebody who does not want mail about
|
||||||
|
// their own sign-ins is entitled not to get it.
|
||||||
|
preferences.mockResolvedValue([
|
||||||
|
preference("security.account_locked"),
|
||||||
|
preference("invitation.accepted"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findAllByText("preferences.security")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the opposite of what is shown, for that kind only", async () => {
|
||||||
|
preferences.mockResolvedValue([
|
||||||
|
preference("security.account_locked"),
|
||||||
|
preference("webhook.disabled"),
|
||||||
|
]);
|
||||||
|
setPreference.mockResolvedValue([
|
||||||
|
preference("security.account_locked"),
|
||||||
|
preference("webhook.disabled", false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
const switches = await screen.findAllByRole("switch");
|
||||||
|
await user.click(switches[1]);
|
||||||
|
|
||||||
|
expect(setPreference).toHaveBeenCalledWith("webhook.disabled", false);
|
||||||
|
expect(setPreference).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the switch back when the server refuses", async () => {
|
||||||
|
// The failure this whole panel exists to prevent: somebody believes a
|
||||||
|
// notification is off, and finds out weeks later as silence.
|
||||||
|
preferences.mockResolvedValue([preference("webhook.disabled")]);
|
||||||
|
setPreference.mockRejectedValue(new Error("nope"));
|
||||||
|
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
const toggle = await screen.findByRole("switch");
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
|
||||||
|
await user.click(toggle);
|
||||||
|
|
||||||
|
await waitFor(() => expect(toggle).toBeChecked());
|
||||||
|
expect(screen.getByText("preferences.saveFailed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the server's answer over its own optimistic one", async () => {
|
||||||
|
// The server returns every kind after a write. If it disagreed with the
|
||||||
|
// guess made on click, its answer is the one that is true.
|
||||||
|
preferences.mockResolvedValue([preference("webhook.disabled")]);
|
||||||
|
setPreference.mockResolvedValue([preference("webhook.disabled", true)]);
|
||||||
|
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
const toggle = await screen.findByRole("switch");
|
||||||
|
await user.click(toggle);
|
||||||
|
|
||||||
|
await waitFor(() => expect(toggle).toBeChecked());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says it does not know rather than showing everything off", async () => {
|
||||||
|
// An empty list would render as "you have turned everything off" —
|
||||||
|
// both wrong and alarming.
|
||||||
|
preferences.mockRejectedValue(new Error("down"));
|
||||||
|
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("preferences.loadFailed")).toBeInTheDocument();
|
||||||
|
expect(screen.queryAllByRole("switch")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every switch a label that names the notification", async () => {
|
||||||
|
// Without one the row is a checkbox floating beside some text, which is
|
||||||
|
// unusable with a screen reader and ambiguous with a mouse.
|
||||||
|
preferences.mockResolvedValue([preference("invitation.accepted")]);
|
||||||
|
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
// The title falls back to the kind when a translation is missing, which
|
||||||
|
// is what the stub above returns — so this asserts the label is wired to
|
||||||
|
// the switch, not what any particular language says.
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("switch", { name: /invitation\.accepted/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
|
||||||
|
import { CustomLoader } from "../../components/custom";
|
||||||
|
import { notificationApi } from "./NotificationApi";
|
||||||
|
import type { NotificationPreference } from "./NotificationTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which notifications you want.
|
||||||
|
*
|
||||||
|
* The server has enforced these since they existed; nothing ever showed them.
|
||||||
|
* A preference nobody can find is not a preference — the workspace with a busy
|
||||||
|
* audit trail still sends its administrators mail they did not choose, and it
|
||||||
|
* still ends the same way: a filing rule, and then nobody reads any of them,
|
||||||
|
* including the one that mattered.
|
||||||
|
*
|
||||||
|
* Every kind arrives with an answer already filled in. Absence means enabled on
|
||||||
|
* the server, so a listing of stored rows would be empty for almost everybody
|
||||||
|
* and this panel would have to invent what that meant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The ones somebody would regret turning off, marked as such rather than
|
||||||
|
* prevented. Somebody who does not want mail about their own sign-ins is
|
||||||
|
* entitled not to get it, and the audit trail records the event either way —
|
||||||
|
* but they should know which lever they are pulling. */
|
||||||
|
const SENSITIVE = new Set([
|
||||||
|
"security.account_locked",
|
||||||
|
"security.mfa_enabled",
|
||||||
|
"security.mfa_disabled",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NotificationPreferencesPanel: React.FC = () => {
|
||||||
|
const { t } = useTranslation(["notifications", "common"]);
|
||||||
|
|
||||||
|
const [preferences, setPreferences] = useState<NotificationPreference[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [busyKind, setBusyKind] = useState<string | null>(null);
|
||||||
|
const [saveFailed, setSaveFailed] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setPreferences(await notificationApi.preferences());
|
||||||
|
} catch {
|
||||||
|
// Not an empty list: that would render as "you have turned
|
||||||
|
// everything off", which is both wrong and alarming.
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const toggle = async (preference: NotificationPreference) => {
|
||||||
|
const wanted = !preference.enabled;
|
||||||
|
setBusyKind(preference.kind);
|
||||||
|
setSaveFailed(null);
|
||||||
|
|
||||||
|
// Moved before the request so the switch responds to the finger rather
|
||||||
|
// than to the network — and put back below if the server disagrees.
|
||||||
|
setPreferences((current) =>
|
||||||
|
current.map((row) =>
|
||||||
|
row.kind === preference.kind ? { ...row, enabled: wanted } : row
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setPreferences(await notificationApi.setPreference(preference.kind, wanted));
|
||||||
|
} catch {
|
||||||
|
setPreferences((current) =>
|
||||||
|
current.map((row) =>
|
||||||
|
row.kind === preference.kind
|
||||||
|
? { ...row, enabled: preference.enabled }
|
||||||
|
: row
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// The switch springing back is the signal; this says why. Being left
|
||||||
|
// believing you turned something off when you did not is the failure
|
||||||
|
// that only surfaces weeks later, as silence.
|
||||||
|
setSaveFailed(preference.kind);
|
||||||
|
} finally {
|
||||||
|
setBusyKind(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{t("preferences.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("preferences.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("preferences.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{preferences.map((preference) => {
|
||||||
|
const isSensitive = SENSITIVE.has(preference.kind);
|
||||||
|
const inputId = `notification-preference-${preference.kind}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={preference.kind} className="py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="flex flex-wrap items-center gap-2 font-medium text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
{t(`kinds.${preference.kind}.title`, {
|
||||||
|
defaultValue: preference.kind,
|
||||||
|
})}
|
||||||
|
{isSensitive && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
<ShieldAlert className="h-3 w-3" />
|
||||||
|
{t("preferences.security")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
{t(`kinds.${preference.kind}.description`, {
|
||||||
|
defaultValue: "",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
checked={preference.enabled}
|
||||||
|
disabled={busyKind === preference.kind}
|
||||||
|
onChange={() => void toggle(preference)}
|
||||||
|
className="h-5 w-5 shrink-0 cursor-pointer rounded accent-[var(--primary)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveFailed === preference.kind && (
|
||||||
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{t("preferences.saveFailed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationPreferencesPanel;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export type NotificationSeverity = "info" | "warning";
|
||||||
|
|
||||||
|
export type Notification = {
|
||||||
|
id: string;
|
||||||
|
/** A short machine-readable kind beside the human text, so the icon and the
|
||||||
|
* click target are chosen from a value rather than parsed out of a sentence. */
|
||||||
|
kind: string;
|
||||||
|
severity: NotificationSeverity;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
/** Where to go about it. A notice with nothing to do about it is one people
|
||||||
|
* learn to ignore. */
|
||||||
|
link?: string | null;
|
||||||
|
data?: Record<string, unknown> | null;
|
||||||
|
read_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationList = {
|
||||||
|
items: Notification[];
|
||||||
|
unread: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnreadCount = {
|
||||||
|
unread: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One kind, one channel, and whether it is wanted.
|
||||||
|
*
|
||||||
|
* The server fills these in from its catalogue rather than returning stored
|
||||||
|
* rows: a row exists only where somebody has turned something off, so a raw
|
||||||
|
* listing would be empty for almost everybody and this screen would have to
|
||||||
|
* invent what that meant.
|
||||||
|
*/
|
||||||
|
export type NotificationPreference = {
|
||||||
|
kind: string;
|
||||||
|
channel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type {
|
||||||
|
AuditRetentionStatus,
|
||||||
|
NoticeSummary,
|
||||||
|
OpenAlert,
|
||||||
|
OutboxSummary,
|
||||||
|
SessionSummary,
|
||||||
|
} from "./OperationsTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only, superadmin-only. Silent because the page renders its own failure
|
||||||
|
* state — a toast per panel on a page with four of them is noise.
|
||||||
|
*/
|
||||||
|
export const operationsApi = {
|
||||||
|
notices: (days = 30) =>
|
||||||
|
apiClient.get<NoticeSummary>(
|
||||||
|
`/api/admin/operations/subscription-notices?days=${days}`,
|
||||||
|
{ silent: true }
|
||||||
|
),
|
||||||
|
outbox: () =>
|
||||||
|
apiClient.get<OutboxSummary>("/api/admin/operations/outbox", {
|
||||||
|
silent: true,
|
||||||
|
}),
|
||||||
|
sessions: () =>
|
||||||
|
apiClient.get<SessionSummary>("/api/admin/operations/sessions", {
|
||||||
|
silent: true,
|
||||||
|
}),
|
||||||
|
alerts: () =>
|
||||||
|
apiClient.get<OpenAlert[]>("/api/admin/operations/alerts", {
|
||||||
|
silent: true,
|
||||||
|
}),
|
||||||
|
auditRetention: () =>
|
||||||
|
apiClient.get<AuditRetentionStatus>(
|
||||||
|
"/api/admin/operations/audit-retention",
|
||||||
|
{ silent: true }
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import OperationsPage from "./OperationsPage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page exists so three numbers stop needing somebody to grep a worker's
|
||||||
|
* stdout. What it must not do is look calm while one of them is non-zero — the
|
||||||
|
* whole value is that a glance is enough.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const notices = vi.fn();
|
||||||
|
const outbox = vi.fn();
|
||||||
|
const sessions = vi.fn();
|
||||||
|
const alerts = vi.fn();
|
||||||
|
const auditRetention = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./OperationsApi", () => ({
|
||||||
|
operationsApi: {
|
||||||
|
notices: () => notices(),
|
||||||
|
outbox: () => outbox(),
|
||||||
|
sessions: () => sessions(),
|
||||||
|
alerts: () => alerts(),
|
||||||
|
auditRetention: () => auditRetention(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) =>
|
||||||
|
options && options.count !== undefined
|
||||||
|
? `${key}:${options.count}`
|
||||||
|
: key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const healthy = () => {
|
||||||
|
notices.mockResolvedValue({
|
||||||
|
window_days: 30,
|
||||||
|
by_kind: {},
|
||||||
|
total: 0,
|
||||||
|
recorded_but_not_sent: 0,
|
||||||
|
workspaces_with_no_billing_contact: 0,
|
||||||
|
recent: [],
|
||||||
|
});
|
||||||
|
outbox.mockResolvedValue({
|
||||||
|
by_status: {},
|
||||||
|
stuck: 0,
|
||||||
|
oldest_pending_at: null,
|
||||||
|
failing_targets: [],
|
||||||
|
});
|
||||||
|
sessions.mockResolvedValue({
|
||||||
|
active: 3,
|
||||||
|
ended_by_reason: {},
|
||||||
|
reuse_detected: 0,
|
||||||
|
awaiting_sweep: 0,
|
||||||
|
});
|
||||||
|
alerts.mockResolvedValue([]);
|
||||||
|
auditRetention.mockResolvedValue({
|
||||||
|
total_entries: 1200,
|
||||||
|
oldest_entry: "2026-01-01T00:00:00Z",
|
||||||
|
past_retention: 0,
|
||||||
|
retention_days: 365,
|
||||||
|
security_retention_days: 730,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
[notices, outbox, sessions, alerts].forEach((m) => m.mockReset());
|
||||||
|
healthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OperationsPage", () => {
|
||||||
|
it("reads all four summaries", async () => {
|
||||||
|
render(<OperationsPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText("title")).toBeInTheDocument());
|
||||||
|
|
||||||
|
[notices, outbox, sessions, alerts].forEach((m) =>
|
||||||
|
expect(m).toHaveBeenCalled()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no alert banner when nothing is firing", async () => {
|
||||||
|
render(<OperationsPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText("title")).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.queryByText(/alerts\.open/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts anything firing above everything else", async () => {
|
||||||
|
alerts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
key: "outbox_stuck",
|
||||||
|
severity: "critical",
|
||||||
|
detail: "12 events are overdue",
|
||||||
|
observed: 12,
|
||||||
|
opened_at: "2026-01-01T00:00:00Z",
|
||||||
|
last_notified_at: "2026-01-01T00:05:00Z",
|
||||||
|
notify_count: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("alerts.open:1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("outbox_stuck")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("12 events are overdue")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says when an open alert reached nobody", async () => {
|
||||||
|
// Otherwise the absence of a message reads as the absence of a problem,
|
||||||
|
// which is the exact failure an alerting system must not have.
|
||||||
|
alerts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
key: "token_reuse",
|
||||||
|
severity: "critical",
|
||||||
|
detail: "a token was used twice",
|
||||||
|
observed: 1,
|
||||||
|
opened_at: "2026-01-01T00:00:00Z",
|
||||||
|
last_notified_at: null,
|
||||||
|
notify_count: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("alerts.undelivered")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag an alert that was delivered", async () => {
|
||||||
|
alerts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
key: "outbox_failed",
|
||||||
|
severity: "critical",
|
||||||
|
detail: "gave up",
|
||||||
|
observed: 2,
|
||||||
|
opened_at: "2026-01-01T00:00:00Z",
|
||||||
|
last_notified_at: "2026-01-01T00:01:00Z",
|
||||||
|
notify_count: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
await screen.findByText("outbox_failed");
|
||||||
|
expect(screen.queryByText("alerts.undelivered")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the three headline figures", async () => {
|
||||||
|
notices.mockResolvedValue({
|
||||||
|
window_days: 30,
|
||||||
|
by_kind: { expiring_soon: 4 },
|
||||||
|
total: 4,
|
||||||
|
recorded_but_not_sent: 2,
|
||||||
|
workspaces_with_no_billing_contact: 7,
|
||||||
|
recent: [],
|
||||||
|
});
|
||||||
|
outbox.mockResolvedValue({
|
||||||
|
by_status: { PENDING: 9 },
|
||||||
|
stuck: 9,
|
||||||
|
oldest_pending_at: "2026-01-01T00:00:00Z",
|
||||||
|
failing_targets: [
|
||||||
|
{ target_url: "https://mod/api", count: 9, last_error: "HTTP 503" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
sessions.mockResolvedValue({
|
||||||
|
active: 12,
|
||||||
|
ended_by_reason: { reuse_detected: 1 },
|
||||||
|
reuse_detected: 1,
|
||||||
|
awaiting_sweep: 0,
|
||||||
|
});
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("7")).toBeInTheDocument());
|
||||||
|
expect(screen.getAllByText("9").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("https://mod/api")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("HTTP 503")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a notice nobody could be sent", async () => {
|
||||||
|
notices.mockResolvedValue({
|
||||||
|
window_days: 30,
|
||||||
|
by_kind: { expiring_soon: 1 },
|
||||||
|
total: 1,
|
||||||
|
recorded_but_not_sent: 1,
|
||||||
|
workspaces_with_no_billing_contact: 1,
|
||||||
|
recent: [
|
||||||
|
{
|
||||||
|
tenant_name: "Alpha Corp",
|
||||||
|
kind: "expiring_soon",
|
||||||
|
for_end_date: "2026-03-01",
|
||||||
|
sent_to: null,
|
||||||
|
sent_at: "2026-02-22T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Alpha Corp")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("table.nobody")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a retry rather than a blank page when a read fails", async () => {
|
||||||
|
// Four independent reads: one failing should not leave the operator
|
||||||
|
// looking at nothing with no way forward.
|
||||||
|
outbox.mockRejectedValue(new Error("boom"));
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("loadError")).toBeInTheDocument()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the audit retention panel", () => {
|
||||||
|
it("does not take the page down when retention is not configured", async () => {
|
||||||
|
// Retention connects as its own database role, so this endpoint failing
|
||||||
|
// is a real and common state on a fresh deployment. Hiding the three
|
||||||
|
// panels that work in order to report the one that does not is exactly
|
||||||
|
// backwards.
|
||||||
|
healthy();
|
||||||
|
auditRetention.mockRejectedValue(new Error("not configured"));
|
||||||
|
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("panels.retentionUnavailable")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The rest of the page is still there.
|
||||||
|
expect(screen.getByText("panels.sessions")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says plainly when nothing is past its window", async () => {
|
||||||
|
healthy();
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("panels.pastRetention:0")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a backlog rather than burying it", async () => {
|
||||||
|
// A sweep that is not keeping up is the thing worth knowing before the
|
||||||
|
// table is the reason an operations query times out.
|
||||||
|
healthy();
|
||||||
|
auditRetention.mockResolvedValue({
|
||||||
|
total_entries: 9_000_000,
|
||||||
|
oldest_entry: "2019-01-01T00:00:00Z",
|
||||||
|
past_retention: 4200,
|
||||||
|
retention_days: 365,
|
||||||
|
security_retention_days: 730,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OperationsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("panels.pastRetention:4200")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
BellRing,
|
||||||
|
MailWarning,
|
||||||
|
RefreshCcw,
|
||||||
|
ShieldAlert,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { CustomButton, CustomLoader } from "../../components/custom";
|
||||||
|
import { operationsApi } from "./OperationsApi";
|
||||||
|
import type {
|
||||||
|
AuditRetentionStatus,
|
||||||
|
NoticeSummary,
|
||||||
|
OpenAlert,
|
||||||
|
OutboxSummary,
|
||||||
|
SessionSummary,
|
||||||
|
} from "./OperationsTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the background work has been doing.
|
||||||
|
*
|
||||||
|
* Four jobs run unattended — the event outbox, the session sweep, the
|
||||||
|
* subscription notices and the alert checks — and every one of them reported
|
||||||
|
* only into a log file. The numbers that most want watching were answerable
|
||||||
|
* and, in practice, never answered.
|
||||||
|
*
|
||||||
|
* Anything currently firing sits at the top: it is the only part of the page
|
||||||
|
* that says something is wrong *now*. Below it are the three figures that mean
|
||||||
|
* somebody should act — customers who will lapse with no warning, events that
|
||||||
|
* are not getting through, and refresh tokens that were used twice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Health = "ok" | "warn" | "bad";
|
||||||
|
|
||||||
|
const Figure: React.FC<{
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
hint?: string;
|
||||||
|
health?: Health;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}> = ({ label, value, hint, health = "ok", icon }) => {
|
||||||
|
const tone =
|
||||||
|
health === "bad"
|
||||||
|
? "border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/40"
|
||||||
|
: health === "warn"
|
||||||
|
? "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40"
|
||||||
|
: "border-[var(--card-border)] bg-[var(--card-bg)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border p-4 shadow-sm ${tone}`}>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-3xl font-semibold text-[var(--text-primary)]">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{hint && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">{hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Panel: React.FC<{ title: string; children: React.ReactNode }> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6 shadow-sm">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Counts: React.FC<{ counts: Record<string, number>; empty: string }> = ({
|
||||||
|
counts,
|
||||||
|
empty,
|
||||||
|
}) => {
|
||||||
|
const entries = Object.entries(counts).filter(([, n]) => n > 0);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <p className="text-sm text-[var(--text-secondary)]">{empty}</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{entries.map(([key, count]) => (
|
||||||
|
<li key={key} className="flex justify-between py-2 text-sm">
|
||||||
|
<span className="text-[var(--text-secondary)]">{key}</span>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">{count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OperationsPage: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation(["operations", "common"]);
|
||||||
|
|
||||||
|
const [notices, setNotices] = useState<NoticeSummary | null>(null);
|
||||||
|
const [outbox, setOutbox] = useState<OutboxSummary | null>(null);
|
||||||
|
const [sessions, setSessions] = useState<SessionSummary | null>(null);
|
||||||
|
const [alerts, setAlerts] = useState<OpenAlert[]>([]);
|
||||||
|
const [retention, setRetention] = useState<AuditRetentionStatus | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
// Together: four independent reads, and waiting for them in series
|
||||||
|
// makes the page feel broken on a slow connection.
|
||||||
|
const [n, o, s, a] = await Promise.all([
|
||||||
|
operationsApi.notices(),
|
||||||
|
operationsApi.outbox(),
|
||||||
|
operationsApi.sessions(),
|
||||||
|
operationsApi.alerts(),
|
||||||
|
]);
|
||||||
|
setNotices(n);
|
||||||
|
setOutbox(o);
|
||||||
|
setSessions(s);
|
||||||
|
setAlerts(a);
|
||||||
|
|
||||||
|
// Deliberately not in that Promise.all. Retention runs on its own
|
||||||
|
// database role, and the endpoint fails when that role is not
|
||||||
|
// configured — which is a real state, and a common one on a fresh
|
||||||
|
// deployment. Letting it take the whole page down would hide the
|
||||||
|
// three panels that are working to report the one that is not.
|
||||||
|
try {
|
||||||
|
setRetention(await operationsApi.auditRetention());
|
||||||
|
} catch {
|
||||||
|
setRetention(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const when = (value: string | null) =>
|
||||||
|
value ? new Date(value).toLocaleString(i18n.language) : "—";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("loadError")}
|
||||||
|
<div className="mt-4">
|
||||||
|
<CustomButton onClick={load}>{t("common:retry", "Retry")}</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CustomButton variant="secondary" onClick={load}>
|
||||||
|
<RefreshCcw className="me-2 h-4 w-4" />
|
||||||
|
{t("common:refresh", "Refresh")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-red-300 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/40">
|
||||||
|
<div className="flex items-center gap-2 font-semibold text-red-900 dark:text-red-200">
|
||||||
|
<BellRing className="h-4 w-4" />
|
||||||
|
{t("alerts.open", { count: alerts.length })}
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<li key={alert.key} className="text-sm">
|
||||||
|
<span className="font-medium text-red-900 dark:text-red-200">
|
||||||
|
{alert.key}
|
||||||
|
</span>
|
||||||
|
<span className="ms-2 text-red-800 dark:text-red-300">
|
||||||
|
{alert.detail}
|
||||||
|
</span>
|
||||||
|
{alert.notify_count === 0 && (
|
||||||
|
// Open and undelivered. Worth saying, because
|
||||||
|
// otherwise the absence of a message reads as
|
||||||
|
// the absence of a problem.
|
||||||
|
<span className="ms-2 text-xs italic text-red-700 dark:text-red-400">
|
||||||
|
{t("alerts.undelivered")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The three that mean somebody should act. */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<Figure
|
||||||
|
label={t("figures.noBillingContact")}
|
||||||
|
value={notices?.workspaces_with_no_billing_contact ?? 0}
|
||||||
|
hint={t("figures.noBillingContactHint")}
|
||||||
|
health={
|
||||||
|
(notices?.workspaces_with_no_billing_contact ?? 0) > 0
|
||||||
|
? "warn"
|
||||||
|
: "ok"
|
||||||
|
}
|
||||||
|
icon={<MailWarning className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<Figure
|
||||||
|
label={t("figures.stuckEvents")}
|
||||||
|
value={outbox?.stuck ?? 0}
|
||||||
|
hint={t("figures.stuckEventsHint")}
|
||||||
|
health={(outbox?.stuck ?? 0) > 0 ? "bad" : "ok"}
|
||||||
|
icon={<AlertTriangle className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<Figure
|
||||||
|
label={t("figures.tokenReuse")}
|
||||||
|
value={sessions?.reuse_detected ?? 0}
|
||||||
|
hint={t("figures.tokenReuseHint")}
|
||||||
|
health={(sessions?.reuse_detected ?? 0) > 0 ? "bad" : "ok"}
|
||||||
|
icon={<ShieldAlert className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<Panel
|
||||||
|
title={t("panels.notices", {
|
||||||
|
days: notices?.window_days ?? 30,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Counts
|
||||||
|
counts={notices?.by_kind ?? {}}
|
||||||
|
empty={t("panels.noNotices")}
|
||||||
|
/>
|
||||||
|
{(notices?.recorded_but_not_sent ?? 0) > 0 && (
|
||||||
|
<p className="mt-4 text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
{t("panels.recordedNotSent", {
|
||||||
|
count: notices?.recorded_but_not_sent ?? 0,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title={t("panels.outbox")}>
|
||||||
|
<Counts
|
||||||
|
counts={outbox?.by_status ?? {}}
|
||||||
|
empty={t("panels.noEvents")}
|
||||||
|
/>
|
||||||
|
<p className="mt-4 text-xs text-[var(--text-secondary)]">
|
||||||
|
{t("panels.oldestPending", {
|
||||||
|
when: when(outbox?.oldest_pending_at ?? null),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title={t("panels.auditRetention")}>
|
||||||
|
{retention === null ? (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("panels.retentionUnavailable")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 text-3xl font-semibold text-[var(--text-primary)]">
|
||||||
|
{retention.total_entries.toLocaleString()}
|
||||||
|
<span className="ms-2 text-sm font-normal text-[var(--text-secondary)]">
|
||||||
|
{t("panels.auditEntries")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* The number that matters. High once is a sweep
|
||||||
|
that has not run yet; high across runs is a sweep
|
||||||
|
that is not keeping up, and the two look
|
||||||
|
identical in a single reading — so the window is
|
||||||
|
shown beside it rather than left implied. */}
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
retention.past_retention > 0
|
||||||
|
? "font-medium text-amber-700 dark:text-amber-400"
|
||||||
|
: "text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("panels.pastRetention", {
|
||||||
|
count: retention.past_retention,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-[var(--text-secondary)]">
|
||||||
|
{t("panels.retentionWindow", {
|
||||||
|
days: retention.retention_days,
|
||||||
|
securityDays: retention.security_retention_days,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{t("panels.oldestEntry", {
|
||||||
|
when: when(retention.oldest_entry),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title={t("panels.sessions")}>
|
||||||
|
<div className="mb-4 text-3xl font-semibold text-[var(--text-primary)]">
|
||||||
|
{sessions?.active ?? 0}
|
||||||
|
<span className="ms-2 text-sm font-normal text-[var(--text-secondary)]">
|
||||||
|
{t("panels.activeSessions")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Counts
|
||||||
|
counts={sessions?.ended_by_reason ?? {}}
|
||||||
|
empty={t("panels.noEndedSessions")}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title={t("panels.failingTargets")}>
|
||||||
|
{(outbox?.failing_targets.length ?? 0) === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("panels.noFailingTargets")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{outbox?.failing_targets.map((target) => (
|
||||||
|
<li key={target.target_url} className="py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-[var(--text-primary)]">
|
||||||
|
{target.target_url}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-sm font-medium">
|
||||||
|
{target.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{target.last_error && (
|
||||||
|
<p className="mt-1 truncate text-xs text-[var(--text-secondary)]">
|
||||||
|
{target.last_error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel title={t("panels.recent")}>
|
||||||
|
{(notices?.recent.length ?? 0) === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("panels.noNotices")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-start text-xs uppercase text-[var(--text-secondary)]">
|
||||||
|
<th className="py-2 text-start">{t("table.workspace")}</th>
|
||||||
|
<th className="py-2 text-start">{t("table.kind")}</th>
|
||||||
|
<th className="py-2 text-start">{t("table.sentTo")}</th>
|
||||||
|
<th className="py-2 text-start">{t("table.sentAt")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[var(--card-border)]">
|
||||||
|
{notices?.recent.map((row, index) => (
|
||||||
|
<tr key={`${row.tenant_name}-${row.kind}-${index}`}>
|
||||||
|
<td className="py-2">{row.tenant_name}</td>
|
||||||
|
<td className="py-2">{row.kind}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{row.sent_to ?? (
|
||||||
|
<span className="text-amber-700 dark:text-amber-400">
|
||||||
|
{t("table.nobody")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">{when(row.sent_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OperationsPage;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/** Summaries of the background work, from `/api/admin/operations/*`. */
|
||||||
|
|
||||||
|
export type NoticeRow = {
|
||||||
|
tenant_name: string;
|
||||||
|
kind: string;
|
||||||
|
for_end_date: string | null;
|
||||||
|
/** Null means the notice was recorded but there was nobody to send it to. */
|
||||||
|
sent_to: string | null;
|
||||||
|
sent_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoticeSummary = {
|
||||||
|
window_days: number;
|
||||||
|
by_kind: Record<string, number>;
|
||||||
|
total: number;
|
||||||
|
recorded_but_not_sent: number;
|
||||||
|
/**
|
||||||
|
* Workspaces with an end date and no billing address. A forecast, not a
|
||||||
|
* history: these are the ones that will lapse without warning next time.
|
||||||
|
*/
|
||||||
|
workspaces_with_no_billing_contact: number;
|
||||||
|
recent: NoticeRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FailingTarget = {
|
||||||
|
target_url: string;
|
||||||
|
count: number;
|
||||||
|
last_error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutboxSummary = {
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
/** Pending, overdue and already retried — the number worth an alert. */
|
||||||
|
stuck: number;
|
||||||
|
oldest_pending_at: string | null;
|
||||||
|
failing_targets: FailingTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionSummary = {
|
||||||
|
active: number;
|
||||||
|
ended_by_reason: Record<string, number>;
|
||||||
|
/**
|
||||||
|
* A security signal, not a capacity one: somebody presented a refresh token
|
||||||
|
* the legitimate client had already spent.
|
||||||
|
*/
|
||||||
|
reuse_detected: number;
|
||||||
|
awaiting_sweep: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenAlert = {
|
||||||
|
key: string;
|
||||||
|
severity: string;
|
||||||
|
detail: string | null;
|
||||||
|
observed: number | null;
|
||||||
|
opened_at: string;
|
||||||
|
last_notified_at: string | null;
|
||||||
|
/** 0 means open and undelivered: a webhook outage, or nowhere configured. */
|
||||||
|
notify_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far behind the audit sweep is.
|
||||||
|
*
|
||||||
|
* `past_retention` staying high across runs means the sweep is not keeping up —
|
||||||
|
* worth knowing before the table is the reason an operations query times out,
|
||||||
|
* rather than after.
|
||||||
|
*/
|
||||||
|
export type AuditRetentionStatus = {
|
||||||
|
total_entries: number;
|
||||||
|
oldest_entry: string | null;
|
||||||
|
past_retention: number;
|
||||||
|
retention_days: number;
|
||||||
|
/** Security entries are kept longer, so a non-zero `past_retention` against
|
||||||
|
* a small window is not automatically a backlog. */
|
||||||
|
security_retention_days: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type { OrgMember, OrgUnit, SeatSummary } from "./OrgTypes";
|
||||||
|
|
||||||
|
export const orgApi = {
|
||||||
|
/** Ordered so parents come before their children — the server sorts on the
|
||||||
|
* materialised path, which gives depth-first order without a second pass. */
|
||||||
|
list: () => apiClient.get<OrgUnit[]>("/api/org-units"),
|
||||||
|
|
||||||
|
create: (payload: { name: string; code?: string | null; parent_id?: string | null }) =>
|
||||||
|
apiClient.post<OrgUnit>("/api/org-units", payload, {
|
||||||
|
successMessage: "Unit created",
|
||||||
|
errorMessage: "Could not create the unit",
|
||||||
|
}),
|
||||||
|
|
||||||
|
rename: (id: string, payload: { name?: string; code?: string | null }) =>
|
||||||
|
apiClient.put<OrgUnit>(`/api/org-units/${id}`, payload, {
|
||||||
|
successMessage: "Unit updated",
|
||||||
|
errorMessage: "Could not update the unit",
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Rewrites every descendant's path on the server. Expensive and rare, which
|
||||||
|
* is the trade that keeps the permission check to one indexed prefix match. */
|
||||||
|
move: (id: string, parentId: string | null) =>
|
||||||
|
apiClient.post<OrgUnit>(`/api/org-units/${id}/move`, { parent_id: parentId }, {
|
||||||
|
successMessage: "Unit moved",
|
||||||
|
errorMessage: "Could not move the unit",
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Refused while children or members remain — a cascade would dissolve a
|
||||||
|
* sub-tree and quietly widen everybody scoped to one of them. */
|
||||||
|
remove: (id: string) =>
|
||||||
|
apiClient.delete<null>(`/api/org-units/${id}`, {
|
||||||
|
successMessage: "Unit deleted",
|
||||||
|
errorMessage: "Could not delete the unit",
|
||||||
|
}),
|
||||||
|
|
||||||
|
members: (id: string) =>
|
||||||
|
apiClient.get<OrgMember[]>(`/api/org-units/${id}/members`, { toast: false }),
|
||||||
|
|
||||||
|
addMember: (
|
||||||
|
id: string,
|
||||||
|
payload: { user_id: string; primary?: boolean; lead?: boolean }
|
||||||
|
) =>
|
||||||
|
apiClient.post<null>(`/api/org-units/${id}/members`, payload, {
|
||||||
|
successMessage: "Added to the unit",
|
||||||
|
errorMessage: "Could not add them",
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeMember: (id: string, userId: string) =>
|
||||||
|
apiClient.delete<null>(`/api/org-units/${id}/members/${userId}`, {
|
||||||
|
successMessage: "Removed from the unit",
|
||||||
|
errorMessage: "Could not remove them",
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confine somebody's user administration to this unit and everything under it.
|
||||||
|
*
|
||||||
|
* This only ever *narrows*. Somebody with no scopes administers the whole
|
||||||
|
* workspace — which is what every administrator is today — so granting one
|
||||||
|
* takes access away and never adds any.
|
||||||
|
*/
|
||||||
|
grantScope: (id: string, userId: string) =>
|
||||||
|
apiClient.post<null>(`/api/org-units/${id}/administrators`, { user_id: userId }, {
|
||||||
|
successMessage: "Administration scoped to this unit",
|
||||||
|
errorMessage: "Could not set the scope",
|
||||||
|
}),
|
||||||
|
|
||||||
|
revokeScope: (id: string, userId: string) =>
|
||||||
|
apiClient.delete<null>(`/api/org-units/${id}/administrators/${userId}`, {
|
||||||
|
successMessage: "Scope removed",
|
||||||
|
errorMessage: "Could not remove the scope",
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** The workspace's seat position. Each unit's own allocation arrives with the
|
||||||
|
* unit in `list`, so this is one request rather than one per branch. */
|
||||||
|
seatSummary: () =>
|
||||||
|
apiClient.get<SeatSummary>("/api/org-units/seats", { toast: false }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap one unit, or remove its cap by passing null.
|
||||||
|
*
|
||||||
|
* Null is not zero. Zero means nobody may be in this unit; removing the cap
|
||||||
|
* means the unit has none of its own and only the workspace limit applies.
|
||||||
|
*
|
||||||
|
* The server refuses a limit below the unit's current headcount and refuses a
|
||||||
|
* total above what was bought, and says which in the message — so the error
|
||||||
|
* is shown rather than replaced with something vaguer.
|
||||||
|
*/
|
||||||
|
setSeats: (id: string, seatLimit: number | null) =>
|
||||||
|
apiClient.put<SeatSummary>(
|
||||||
|
`/api/org-units/${id}/seats`,
|
||||||
|
{ seat_limit: seatLimit },
|
||||||
|
{ toast: false }
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export type OrgUnit = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
/** Materialised ancestry, `/root/child/self/`. The list arrives ordered by it,
|
||||||
|
* which is depth-first order for free — a child's path is its parent's plus
|
||||||
|
* one segment. */
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
is_active: boolean;
|
||||||
|
/** Absent means unconstrained rather than zero. A unit with no allocation is
|
||||||
|
* bounded only by the workspace, and rendering a `0` there would read as
|
||||||
|
* "nobody may join this branch" — the opposite of what it means. */
|
||||||
|
seat_limit?: number | null;
|
||||||
|
/** Active members, counted the way the server's guard counts them. If the
|
||||||
|
* screen counted differently it would say a branch is full while the server
|
||||||
|
* let somebody in. */
|
||||||
|
seats_used: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What was bought, what is spoken for, and what is left to give.
|
||||||
|
*
|
||||||
|
* `purchased` is null on a plan with no seat limit, and `unallocated` is null
|
||||||
|
* with it — there is no such thing as "left to give" out of an unbounded
|
||||||
|
* supply, and showing a number there would invent one. */
|
||||||
|
export type SeatSummary = {
|
||||||
|
purchased: number | null;
|
||||||
|
allocated: number;
|
||||||
|
unallocated: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgMember = {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
/** Somebody can be in several units; exactly one is primary, and that is the
|
||||||
|
* one shown beside their name. */
|
||||||
|
is_primary: boolean;
|
||||||
|
/** A lead runs the unit. Separate from administering it — the two are
|
||||||
|
* frequently different people. */
|
||||||
|
is_lead: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
CornerDownRight,
|
||||||
|
Network,
|
||||||
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
UserMinus,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomInput,
|
||||||
|
CustomLoader,
|
||||||
|
CustomModal,
|
||||||
|
CustomSearchableDropdown,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { usersApi } from "../users/UserApi";
|
||||||
|
import type { User } from "../users/UserTypes";
|
||||||
|
import { orgApi } from "./OrgApi";
|
||||||
|
import type { OrgMember, OrgUnit, SeatSummary } from "./OrgTypes";
|
||||||
|
import SeatAllocationDialog from "./SeatAllocationDialog";
|
||||||
|
import SeatSummaryStrip from "./SeatSummaryStrip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Departments, branches and teams — and who administers which.
|
||||||
|
*
|
||||||
|
* ## What the screen has to convey that the API cannot
|
||||||
|
*
|
||||||
|
* **Scoping only narrows.** Somebody with no scopes administers the whole
|
||||||
|
* workspace, which is what every administrator is today. Granting a scope takes
|
||||||
|
* access away; removing the last one gives it back. That reads backwards from
|
||||||
|
* "revoke", so the screen says it in as many words rather than leaving somebody
|
||||||
|
* to discover it.
|
||||||
|
*
|
||||||
|
* **A unit will not delete while anything depends on it.** The server refuses,
|
||||||
|
* on purpose — a cascade would dissolve a department's whole sub-tree and
|
||||||
|
* quietly widen every administrator scoped to one of them — and the message
|
||||||
|
* explains what to do instead.
|
||||||
|
*
|
||||||
|
* The tree is rendered from the materialised path rather than by recursion: the
|
||||||
|
* list already arrives parents-first, so indentation is `depth` and nothing has
|
||||||
|
* to be assembled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UnitDetail: React.FC<{
|
||||||
|
unit: OrgUnit;
|
||||||
|
people: User[];
|
||||||
|
onChanged: () => void;
|
||||||
|
}> = ({ unit, people, onChanged }) => {
|
||||||
|
const { t } = useTranslation(["organisation", "common"]);
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<OrgMember[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [chosen, setChosen] = useState("");
|
||||||
|
const [scopeChoice, setScopeChoice] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setMembers(await orgApi.members(unit.id));
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [unit.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!chosen) return;
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
await orgApi.addMember(unit.id, { user_id: chosen, primary: true });
|
||||||
|
setChosen("");
|
||||||
|
await load();
|
||||||
|
onChanged();
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (member: OrgMember) => {
|
||||||
|
await orgApi.removeMember(unit.id, member.user_id);
|
||||||
|
await load();
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scope = async (member: OrgMember) => {
|
||||||
|
await orgApi.grantScope(unit.id, member.user_id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopeChosen = async () => {
|
||||||
|
if (!scopeChoice) return;
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
await orgApi.grantScope(unit.id, scopeChoice);
|
||||||
|
setScopeChoice("");
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unscope = async (member: OrgMember) => {
|
||||||
|
await orgApi.revokeScope(unit.id, member.user_id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const memberIds = new Set(members.map((member) => member.user_id));
|
||||||
|
const candidates = people.filter((person) => !memberIds.has(person.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("members.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{members.map((member) => (
|
||||||
|
<li
|
||||||
|
key={member.user_id}
|
||||||
|
className="flex flex-wrap items-center gap-2 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="break-all text-sm text-[var(--text-primary)]">
|
||||||
|
{member.email}
|
||||||
|
</span>
|
||||||
|
{member.is_lead && (
|
||||||
|
<span className="ms-2 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
|
{t("members.lead")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void scope(member)}
|
||||||
|
title={t("scope.explain")}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="me-2 h-3.5 w-3.5" />
|
||||||
|
{t("scope.grant")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void unscope(member)}
|
||||||
|
>
|
||||||
|
{t("scope.revoke")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void remove(member)}
|
||||||
|
>
|
||||||
|
<UserMinus className="h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md border border-[var(--card-border)] p-3">
|
||||||
|
<p className="mb-2 text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{t("members.add")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-56 flex-1">
|
||||||
|
<CustomSearchableDropdown
|
||||||
|
label={t("members.person")}
|
||||||
|
value={chosen}
|
||||||
|
onChange={setChosen}
|
||||||
|
options={candidates.map((person) => ({
|
||||||
|
label: person.email,
|
||||||
|
value: person.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={add}
|
||||||
|
disabled={isBusy || !chosen}
|
||||||
|
>
|
||||||
|
<UserPlus className="me-2 h-4 w-4" />
|
||||||
|
{t("members.addButton")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separate from membership on purpose. Administering a unit and being in
|
||||||
|
it are different things — a regional HR administrator looks after a
|
||||||
|
branch they have never worked at — and offering this only against the
|
||||||
|
member list would quietly make the narrower case the only one. */}
|
||||||
|
<div className="rounded-md border border-[var(--card-border)] p-3">
|
||||||
|
<p className="mb-1 text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{t("scope.title")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("scope.note")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-56 flex-1">
|
||||||
|
<CustomSearchableDropdown
|
||||||
|
label={t("scope.person")}
|
||||||
|
value={scopeChoice}
|
||||||
|
onChange={setScopeChoice}
|
||||||
|
options={people.map((person) => ({
|
||||||
|
label: person.email,
|
||||||
|
value: person.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={scopeChosen}
|
||||||
|
disabled={isBusy || !scopeChoice}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="me-2 h-4 w-4" />
|
||||||
|
{t("scope.grant")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganisationPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation(["organisation", "common"]);
|
||||||
|
|
||||||
|
const [units, setUnits] = useState<OrgUnit[]>([]);
|
||||||
|
const [people, setPeople] = useState<User[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [parentId, setParentId] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [open, setOpen] = useState<OrgUnit | null>(null);
|
||||||
|
const [pendingRemove, setPendingRemove] = useState<OrgUnit | null>(null);
|
||||||
|
const [removeError, setRemoveError] = useState("");
|
||||||
|
|
||||||
|
const [seats, setSeats] = useState<SeatSummary | null>(null);
|
||||||
|
const [seatUnit, setSeatUnit] = useState<OrgUnit | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setUnits(await orgApi.list());
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separately, and allowed to fail on its own: the seat position is useful
|
||||||
|
// context, but a workspace whose plan lookup hiccups must still be able to
|
||||||
|
// manage its structure. The strip renders nothing rather than zeroes.
|
||||||
|
try {
|
||||||
|
setSeats(await orgApi.seatSummary());
|
||||||
|
} catch {
|
||||||
|
setSeats(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// People are needed to add somebody to a unit. A failure here must not stop
|
||||||
|
// the structure being managed, so the dropdown simply comes up empty.
|
||||||
|
void usersApi
|
||||||
|
.getAll()
|
||||||
|
.then(setPeople)
|
||||||
|
.catch(() => setPeople([]));
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
await orgApi.create({
|
||||||
|
name: name.trim(),
|
||||||
|
code: code.trim() || null,
|
||||||
|
parent_id: parentId || null,
|
||||||
|
});
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setName("");
|
||||||
|
setCode("");
|
||||||
|
setParentId("");
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!pendingRemove) return;
|
||||||
|
const target = pendingRemove;
|
||||||
|
setPendingRemove(null);
|
||||||
|
setRemoveError("");
|
||||||
|
try {
|
||||||
|
await orgApi.remove(target.id);
|
||||||
|
} catch (caught) {
|
||||||
|
// The server refuses while children or members remain, and that refusal
|
||||||
|
// is the useful part — it says which.
|
||||||
|
setRemoveError(
|
||||||
|
caught instanceof Error ? caught.message : t("errors.generic")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton variant="primary" onClick={() => setIsCreateOpen(true)}>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
{t("add")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Above the tree: dividing seats between branches without knowing what
|
||||||
|
is left to give is arithmetic done on paper beside the screen. */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<SeatSummaryStrip summary={seats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{removeError && (
|
||||||
|
<div className="mb-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
|
||||||
|
{removeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="px-6 py-12 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : units.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<Network className="mx-auto mb-3 h-8 w-8 text-[var(--text-secondary)]" />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{units.map((unit) => (
|
||||||
|
<li
|
||||||
|
key={unit.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 px-6 py-3"
|
||||||
|
// Indentation comes straight from the path's depth; the list
|
||||||
|
// already arrives parents-first, so nothing is assembled here.
|
||||||
|
style={{ paddingInlineStart: `${1.5 + unit.depth * 1.25}rem` }}
|
||||||
|
>
|
||||||
|
{unit.depth > 0 && (
|
||||||
|
<CornerDownRight className="h-4 w-4 shrink-0 text-[var(--text-secondary)]" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{unit.name}
|
||||||
|
</span>
|
||||||
|
{unit.code && (
|
||||||
|
<span className="ms-2 font-mono text-xs text-[var(--text-secondary)]">
|
||||||
|
{unit.code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* `used / limit` when capped, and the bare headcount when not.
|
||||||
|
A capped branch at its limit is the thing somebody is looking
|
||||||
|
for on this screen, so it is coloured rather than counted. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSeatUnit(unit)}
|
||||||
|
title={t("seats.edit")}
|
||||||
|
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||||
|
unit.seat_limit != null && unit.seats_used >= unit.seat_limit
|
||||||
|
? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
|
: "bg-[var(--card-border)] text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="me-1 inline h-3 w-3" />
|
||||||
|
{unit.seat_limit == null
|
||||||
|
? t("seats.usedUncapped", { count: unit.seats_used })
|
||||||
|
: t("seats.usedOfLimit", {
|
||||||
|
used: unit.seats_used,
|
||||||
|
limit: unit.seat_limit,
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(unit)}
|
||||||
|
>
|
||||||
|
{t("members.open")}
|
||||||
|
<ChevronRight className="ms-1 h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPendingRemove(unit)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isCreateOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
title={t("add")}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.name")}
|
||||||
|
type="text"
|
||||||
|
placeholder={t("form.namePlaceholder")}
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.code")}
|
||||||
|
type="text"
|
||||||
|
placeholder="LHR-01"
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("form.codeNote")}</p>
|
||||||
|
|
||||||
|
<CustomSearchableDropdown
|
||||||
|
label={t("form.parent")}
|
||||||
|
value={parentId}
|
||||||
|
onChange={setParentId}
|
||||||
|
options={[
|
||||||
|
{ label: t("form.topLevel"), value: "" },
|
||||||
|
...units.map((unit) => ({
|
||||||
|
label: `${"— ".repeat(unit.depth)}${unit.name}`,
|
||||||
|
value: unit.id,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={create}
|
||||||
|
disabled={isBusy || name.trim().length === 0}
|
||||||
|
>
|
||||||
|
{t("form.submit")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={open !== null}
|
||||||
|
onClose={() => setOpen(null)}
|
||||||
|
title={open?.name ?? ""}
|
||||||
|
>
|
||||||
|
{open && (
|
||||||
|
<UnitDetail unit={open} people={people} onChanged={load} />
|
||||||
|
)}
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={seatUnit}
|
||||||
|
// What is left, plus whatever this unit already holds — raising a unit's
|
||||||
|
// own allocation must not be counted against itself, or every increase
|
||||||
|
// reads as impossible.
|
||||||
|
availableToThisUnit={
|
||||||
|
seats?.unallocated == null
|
||||||
|
? null
|
||||||
|
: seats.unallocated + (seatUnit?.seat_limit ?? 0)
|
||||||
|
}
|
||||||
|
onClose={() => setSeatUnit(null)}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={pendingRemove !== null}
|
||||||
|
onClose={() => setPendingRemove(null)}
|
||||||
|
onConfirm={remove}
|
||||||
|
title={t("confirmRemove.title")}
|
||||||
|
description={t("confirmRemove.message", { name: pendingRemove?.name ?? "" })}
|
||||||
|
confirmText={t("confirmRemove.confirm")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrganisationPage;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import SeatAllocationDialog from "./SeatAllocationDialog";
|
||||||
|
import SeatSummaryStrip from "./SeatSummaryStrip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinction this dialog exists to protect is empty-versus-zero. Removing a
|
||||||
|
* branch's cap and forbidding anybody from being in it are opposite intentions,
|
||||||
|
* and one text field has to carry both without conflating them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const setSeats = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./OrgApi", () => ({
|
||||||
|
orgApi: {
|
||||||
|
setSeats: (id: string, limit: number | null) => setSeats(id, limit),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) =>
|
||||||
|
options && Object.keys(options).length
|
||||||
|
? `${key}:${JSON.stringify(options)}`
|
||||||
|
: key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const unit = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: "u1",
|
||||||
|
name: "Lahore",
|
||||||
|
code: null,
|
||||||
|
parent_id: null,
|
||||||
|
path: "/u1/",
|
||||||
|
depth: 0,
|
||||||
|
is_active: true,
|
||||||
|
seat_limit: null as number | null,
|
||||||
|
seats_used: 2,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setSeats.mockReset().mockResolvedValue({
|
||||||
|
purchased: 50,
|
||||||
|
allocated: 10,
|
||||||
|
unallocated: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SeatAllocationDialog", () => {
|
||||||
|
it("sends null when the box is cleared, not zero", async () => {
|
||||||
|
// Zero means nobody may be in this branch. Null means it has no cap of
|
||||||
|
// its own. Sending the wrong one empties a branch nobody asked to empty.
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={unit({ seat_limit: 8 })}
|
||||||
|
availableToThisUnit={40}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.clear(screen.getByRole("spinbutton"));
|
||||||
|
await user.click(screen.getByRole("button", { name: /actions\.save/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(setSeats).toHaveBeenCalledWith("u1", null));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends zero when zero is typed", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={unit({ seat_limit: 8 })}
|
||||||
|
availableToThisUnit={40}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const field = screen.getByRole("spinbutton");
|
||||||
|
await user.clear(field);
|
||||||
|
await user.type(field, "0");
|
||||||
|
await user.click(screen.getByRole("button", { name: /actions\.save/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(setSeats).toHaveBeenCalledWith("u1", 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens showing the branch's own limit rather than the last one edited", async () => {
|
||||||
|
// One component is reused for every row. A stale value here would
|
||||||
|
// silently re-cap the wrong branch at the previous one's number.
|
||||||
|
const { rerender } = render(
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={unit({ id: "u1", seat_limit: 8 })}
|
||||||
|
availableToThisUnit={40}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("spinbutton")).toHaveValue(8);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={unit({ id: "u2", name: "Karachi", seat_limit: 3 })}
|
||||||
|
availableToThisUnit={40}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByRole("spinbutton")).toHaveValue(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens empty for a branch that has no cap", async () => {
|
||||||
|
render(
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={unit({ seat_limit: null })}
|
||||||
|
availableToThisUnit={40}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("spinbutton")).toHaveValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the server's refusal rather than a generic message", async () => {
|
||||||
|
// The server's message names the number — "at most 2 can go to Lahore".
|
||||||
|
// That is the only part that says what to type instead.
|
||||||
|
setSeats.mockRejectedValue(new Error("At most 2 can go to Lahore."));
|
||||||
|
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
render(
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={unit({ seat_limit: 1 })}
|
||||||
|
availableToThisUnit={2}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={onSaved}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const field = screen.getByRole("spinbutton");
|
||||||
|
await user.clear(field);
|
||||||
|
await user.type(field, "9");
|
||||||
|
await user.click(screen.getByRole("button", { name: /actions\.save/ }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("At most 2 can go to Lahore.")).toBeInTheDocument();
|
||||||
|
expect(onSaved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays open when the save fails", async () => {
|
||||||
|
// Closing on failure would look exactly like success.
|
||||||
|
setSeats.mockRejectedValue(new Error("nope"));
|
||||||
|
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={unit({ seat_limit: 1 })}
|
||||||
|
availableToThisUnit={2}
|
||||||
|
onClose={onClose}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /actions\.save/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("nope")).toBeInTheDocument());
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when no unit is open", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SeatAllocationDialog
|
||||||
|
unit={null}
|
||||||
|
availableToThisUnit={40}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SeatSummaryStrip", () => {
|
||||||
|
it("shows what is left to give", async () => {
|
||||||
|
render(<SeatSummaryStrip summary={{ purchased: 50, allocated: 12, unallocated: 38 }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("50")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("12")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("38")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invent a remainder out of an unlimited plan", () => {
|
||||||
|
// "Left to give" out of an unbounded supply is not a quantity.
|
||||||
|
render(<SeatSummaryStrip summary={{ purchased: null, allocated: 12, unallocated: null }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("seats.unlimited")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("seats.unallocated")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing rather than zeroes when the position is unknown", () => {
|
||||||
|
// A strip reading "0 of 0" is a claim about the customer's plan that a
|
||||||
|
// failed request puts us in no position to make.
|
||||||
|
const { container } = render(<SeatSummaryStrip summary={null} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { CustomButton, CustomInput, CustomModal } from "../../components/custom";
|
||||||
|
import { orgApi } from "./OrgApi";
|
||||||
|
import type { OrgUnit } from "./OrgTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of the workspace's seats this branch may use.
|
||||||
|
*
|
||||||
|
* The workspace limit was always enforced; it was simply the wrong grain for an
|
||||||
|
* organisation whose branches have their own budgets. One branch could use
|
||||||
|
* forty-eight of fifty seats and nobody found out until another branch could not
|
||||||
|
* add anybody.
|
||||||
|
*
|
||||||
|
* Two things this screen has to get right, because both are refusals the server
|
||||||
|
* will make and the person needs to understand *before* they type:
|
||||||
|
*
|
||||||
|
* - **Empty is not zero.** Clearing the box removes the cap, so only the
|
||||||
|
* workspace limit applies. Typing `0` means nobody may be in this branch.
|
||||||
|
* They are different intentions and the field cannot conflate them.
|
||||||
|
* - **The floor is the current headcount.** A branch instantly over its limit,
|
||||||
|
* with no action that caused it, is a number somebody has to fix by removing
|
||||||
|
* a person.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SeatAllocationDialog: React.FC<{
|
||||||
|
unit: OrgUnit | null;
|
||||||
|
/** What is left to give, excluding this unit's own current allocation. Null
|
||||||
|
* when the plan has no seat limit at all, in which case there is no ceiling
|
||||||
|
* to warn about. */
|
||||||
|
availableToThisUnit: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
}> = ({ unit, availableToThisUnit, onClose, onSaved }) => {
|
||||||
|
const { t } = useTranslation(["organisation", "common"]);
|
||||||
|
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Re-seeded per unit rather than once: the dialog is one component reused
|
||||||
|
// for every row, and a stale value here would silently re-cap the wrong
|
||||||
|
// branch at the previous one's number.
|
||||||
|
setValue(unit?.seat_limit == null ? "" : String(unit.seat_limit));
|
||||||
|
setError("");
|
||||||
|
}, [unit]);
|
||||||
|
|
||||||
|
if (!unit) return null;
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const parsed = trimmed === "" ? null : Number(trimmed);
|
||||||
|
const isValid =
|
||||||
|
parsed === null || (Number.isInteger(parsed) && parsed >= 0);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!isValid) {
|
||||||
|
setError(t("seats.notAWholeNumber"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
await orgApi.setSeats(unit.id, parsed);
|
||||||
|
await onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (failure) {
|
||||||
|
// The server's message names the actual number — "already has 3
|
||||||
|
// active members", "at most 2 can go to Lahore". Replacing it with
|
||||||
|
// something generic would take away the only part that tells the
|
||||||
|
// person what to type instead.
|
||||||
|
setError(
|
||||||
|
failure instanceof Error && failure.message
|
||||||
|
? failure.message
|
||||||
|
: t("seats.saveFailed")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomModal isOpen onClose={onClose} title={t("seats.title", { name: unit.name })}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("seats.explanation")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("seats.limit")}
|
||||||
|
type="number"
|
||||||
|
min={unit.seats_used}
|
||||||
|
placeholder={t("seats.uncapped")}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("seats.currentUse", { count: unit.seats_used })}
|
||||||
|
{availableToThisUnit !== null && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
{t("seats.availableToThisUnit", { count: availableToThisUnit })}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Said before they press it, not after the server refuses. */}
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("seats.emptyMeansUncapped")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<CustomButton variant="secondary" onClick={onClose} disabled={isBusy}>
|
||||||
|
{t("common:actions.cancel")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton onClick={save} disabled={isBusy || !isValid}>
|
||||||
|
{t("common:actions.save")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SeatAllocationDialog;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { SeatSummary } from "./OrgTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What was bought, what is spoken for, and what is left to give.
|
||||||
|
*
|
||||||
|
* Without the third number, dividing seats between branches is arithmetic done
|
||||||
|
* on paper beside the screen — and the person only finds out they overcommitted
|
||||||
|
* when the server refuses the last one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SeatSummaryStrip: React.FC<{ summary: SeatSummary | null }> = ({ summary }) => {
|
||||||
|
const { t } = useTranslation(["organisation", "common"]);
|
||||||
|
|
||||||
|
// Nothing rather than zeroes. A strip reading "0 of 0" while the request is
|
||||||
|
// in flight, or after it failed, is a claim about the customer's plan that
|
||||||
|
// we are in no position to make.
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
// An unlimited plan has no ceiling to divide, so the only honest number is
|
||||||
|
// what has been handed out. "Unallocated" out of an unbounded supply is not
|
||||||
|
// a quantity.
|
||||||
|
const isUnlimited = summary.purchased === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] px-6 py-3 text-sm">
|
||||||
|
{!isUnlimited && (
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{t("seats.purchased")}{" "}
|
||||||
|
<span className="font-semibold text-[var(--text-primary)]">
|
||||||
|
{summary.purchased}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{t("seats.allocated")}{" "}
|
||||||
|
<span className="font-semibold text-[var(--text-primary)]">
|
||||||
|
{summary.allocated}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isUnlimited ? (
|
||||||
|
<span className="text-[var(--text-secondary)]">{t("seats.unlimited")}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{t("seats.unallocated")}{" "}
|
||||||
|
<span className="font-semibold text-[var(--text-primary)]">
|
||||||
|
{summary.unallocated}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SeatSummaryStrip;
|
||||||
@@ -7,6 +7,9 @@ import { Key, Pencil } from "lucide-react";
|
|||||||
import type { PasswordForm, FormatDateFunction, FormatNameFunction, RenderStatusBadgeFunction } from "./ProfileTypes";
|
import type { PasswordForm, FormatDateFunction, FormatNameFunction, RenderStatusBadgeFunction } from "./ProfileTypes";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { paletteApi } from "../theme/PaletteApi";
|
import { paletteApi } from "../theme/PaletteApi";
|
||||||
|
import SessionsPanel from "./SessionsPanel";
|
||||||
|
import SecurityPanel from "../security/SecurityPanel";
|
||||||
|
import NotificationPreferencesPanel from "../notifications/NotificationPreferencesPanel";
|
||||||
import type { ColorPalette } from "../theme/ThemeTypes";
|
import type { ColorPalette } from "../theme/ThemeTypes";
|
||||||
|
|
||||||
type ProfileForm = {
|
type ProfileForm = {
|
||||||
@@ -371,6 +374,19 @@ const ProfilePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Security before sessions, and both above appearance: the order is
|
||||||
|
"what protects the account", then "where it is signed in", then
|
||||||
|
preferences. A second factor is the one that changes the others'
|
||||||
|
worth. */}
|
||||||
|
<SecurityPanel />
|
||||||
|
|
||||||
|
<SessionsPanel />
|
||||||
|
|
||||||
|
{/* After sessions and before appearance: it is a preference rather
|
||||||
|
than a protection, but it is the one preference where the wrong
|
||||||
|
answer is discovered as silence. */}
|
||||||
|
<NotificationPreferencesPanel />
|
||||||
|
|
||||||
{/* Appearance / Theme Section */}
|
{/* Appearance / Theme Section */}
|
||||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6">
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6">
|
||||||
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
|
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
|||||||
@@ -11,3 +11,16 @@ export type FormatDateFunction = (value: string) => string;
|
|||||||
export type FormatNameFunction = (firstName: string, lastName?: string | null) => string;
|
export type FormatNameFunction = (firstName: string, lastName?: string | null) => string;
|
||||||
|
|
||||||
export type RenderStatusBadgeFunction = (status?: string) => JSX.Element;
|
export type RenderStatusBadgeFunction = (status?: string) => JSX.Element;
|
||||||
|
|
||||||
|
/** One place this account is signed in. */
|
||||||
|
export interface UserSession {
|
||||||
|
id: string;
|
||||||
|
/** Raw, as sent. Interpreted for display only — the client chooses it. */
|
||||||
|
user_agent: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
/** The session this browser is holding. Flagged so the list is safe to act on. */
|
||||||
|
is_current: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import SessionsPanel from "./SessionsPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of sessions is only useful if you can tell which one you are holding.
|
||||||
|
* Without that flag nobody dares press anything — and the button that matters,
|
||||||
|
* "sign out everywhere else", is precisely the one you press when you have just
|
||||||
|
* lost a laptop and cannot afford to also lose the session you are using.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const listSessions = vi.fn();
|
||||||
|
const endSession = vi.fn();
|
||||||
|
const endOtherSessions = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../authentication/AuthApi", () => ({
|
||||||
|
authApi: {
|
||||||
|
listSessions: () => listSessions(),
|
||||||
|
endSession: (id: string) => endSession(id),
|
||||||
|
endOtherSessions: () => endOtherSessions(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: "s1",
|
||||||
|
user_agent: "Mozilla/5.0 (Windows NT 10.0) Chrome/120.0",
|
||||||
|
ip_address: "203.0.113.7",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
last_used_at: new Date().toISOString(),
|
||||||
|
expires_at: "2027-01-01T00:00:00Z",
|
||||||
|
is_current: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
listSessions.mockReset();
|
||||||
|
endSession.mockReset().mockResolvedValue({ message: "ok" });
|
||||||
|
endOtherSessions.mockReset().mockResolvedValue({ message: "ok", ended: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SessionsPanel", () => {
|
||||||
|
it("names the device rather than showing the raw user agent", async () => {
|
||||||
|
// A user agent string is not for reading. Two things a person recognises
|
||||||
|
// — roughly what browser, roughly what platform — and nothing more.
|
||||||
|
listSessions.mockResolvedValue([session()]);
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Chrome/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Windows/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Mozilla\/5\.0/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the device cannot be identified", async () => {
|
||||||
|
listSessions.mockResolvedValue([session({ user_agent: null })]);
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("fields.unknownDevice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the session the browser is holding", async () => {
|
||||||
|
listSessions.mockResolvedValue([
|
||||||
|
session({ id: "mine", is_current: true }),
|
||||||
|
session({ id: "other" }),
|
||||||
|
]);
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("fields.thisDevice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no end button for the current session", async () => {
|
||||||
|
// A button that logs you out of the page you are standing on reads as a
|
||||||
|
// mistake. Signing out is what ends the current session.
|
||||||
|
listSessions.mockResolvedValue([session({ id: "mine", is_current: true })]);
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
await screen.findByText("fields.thisDevice");
|
||||||
|
expect(screen.queryByText("buttons.endSession")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends one session and drops it from the list", async () => {
|
||||||
|
listSessions.mockResolvedValue([
|
||||||
|
session({ id: "mine", is_current: true }),
|
||||||
|
session({ id: "other" }),
|
||||||
|
]);
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByText("buttons.endSession"));
|
||||||
|
|
||||||
|
expect(endSession).toHaveBeenCalledWith("other");
|
||||||
|
// Removed locally rather than by refetching: the row should go the
|
||||||
|
// moment it is ended, not after a round trip.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("buttons.endSession")).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides sign-out-everywhere when there is nowhere else", async () => {
|
||||||
|
listSessions.mockResolvedValue([session({ id: "mine", is_current: true })]);
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
await screen.findByText("fields.thisDevice");
|
||||||
|
expect(screen.queryByText("buttons.signOutOthers")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the current session when signing out everywhere else", async () => {
|
||||||
|
listSessions.mockResolvedValue([
|
||||||
|
session({ id: "mine", is_current: true }),
|
||||||
|
session({ id: "other" }),
|
||||||
|
session({ id: "another" }),
|
||||||
|
]);
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByText("buttons.signOutOthers"));
|
||||||
|
|
||||||
|
expect(endOtherSessions).toHaveBeenCalled();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("fields.thisDevice")).toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("buttons.endSession")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits it could not load rather than claiming no sessions", async () => {
|
||||||
|
// "No other sessions" is a stronger and possibly false claim than "we do
|
||||||
|
// not know". On a security control the difference matters: one invites
|
||||||
|
// you to relax, the other to look again.
|
||||||
|
listSessions.mockRejectedValue(new Error("network"));
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("messages.loadFailed")).toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("messages.noSessions")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says there are none when there really are none", async () => {
|
||||||
|
listSessions.mockResolvedValue([]);
|
||||||
|
render(<SessionsPanel />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("messages.noSessions")).toBeInTheDocument()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Laptop, LogOut, Smartphone, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { CustomButton, CustomLoader } from "../../components/custom";
|
||||||
|
import { authApi } from "../authentication/AuthApi";
|
||||||
|
import type { UserSession } from "./ProfileTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this account is signed in, and how to end it.
|
||||||
|
*
|
||||||
|
* The platform rotated and revoked refresh tokens already; what it never did was
|
||||||
|
* show anyone the result. Someone who suspected a session they did not recognise
|
||||||
|
* had no option but to change their password and hope.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user agent string is not for reading. This turns it into the two things a
|
||||||
|
* person actually recognises about a session — roughly what kind of device, and
|
||||||
|
* roughly what browser — and stops there. Guessing harder from a string the
|
||||||
|
* client controls produces confident nonsense.
|
||||||
|
*/
|
||||||
|
const describeDevice = (userAgent?: string | null) => {
|
||||||
|
if (!userAgent) return { label: null as string | null, isMobile: false };
|
||||||
|
|
||||||
|
const isMobile = /Mobile|Android|iPhone|iPad/i.test(userAgent);
|
||||||
|
const browser =
|
||||||
|
/Edg\//.test(userAgent) ? "Edge"
|
||||||
|
: /OPR\/|Opera/.test(userAgent) ? "Opera"
|
||||||
|
: /Chrome\//.test(userAgent) ? "Chrome"
|
||||||
|
: /Safari\//.test(userAgent) ? "Safari"
|
||||||
|
: /Firefox\//.test(userAgent) ? "Firefox"
|
||||||
|
: null;
|
||||||
|
const platform =
|
||||||
|
/Windows/.test(userAgent) ? "Windows"
|
||||||
|
: /Android/.test(userAgent) ? "Android"
|
||||||
|
: /iPhone|iPad|iOS/.test(userAgent) ? "iOS"
|
||||||
|
: /Mac OS X|Macintosh/.test(userAgent) ? "macOS"
|
||||||
|
: /Linux/.test(userAgent) ? "Linux"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const label = [browser, platform].filter(Boolean).join(" · ") || null;
|
||||||
|
return { label, isMobile };
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativeTime = (value: string, locale: string) => {
|
||||||
|
const then = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(then)) return value;
|
||||||
|
|
||||||
|
const seconds = Math.round((then - Date.now()) / 1000);
|
||||||
|
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||||
|
const steps: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
["second", 60],
|
||||||
|
["minute", 60],
|
||||||
|
["hour", 24],
|
||||||
|
["day", 7],
|
||||||
|
["week", 4.35],
|
||||||
|
["month", 12],
|
||||||
|
];
|
||||||
|
|
||||||
|
let amount = seconds;
|
||||||
|
for (const [unit, size] of steps) {
|
||||||
|
if (Math.abs(amount) < size) return formatter.format(Math.round(amount), unit);
|
||||||
|
amount /= size;
|
||||||
|
}
|
||||||
|
return formatter.format(Math.round(amount), "year");
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionsPanel: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation(["profile", "common"]);
|
||||||
|
|
||||||
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [isRevokingOthers, setIsRevokingOthers] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setSessions(await authApi.listSessions());
|
||||||
|
} catch {
|
||||||
|
// An empty list would assert "you are signed in nowhere else" — a
|
||||||
|
// stronger and possibly false claim than admitting we do not know.
|
||||||
|
// On a security control the difference matters.
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const endSession = async (id: string) => {
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
await authApi.endSession(id);
|
||||||
|
setSessions((current) => current.filter((session) => session.id !== id));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endOthers = async () => {
|
||||||
|
setIsRevokingOthers(true);
|
||||||
|
try {
|
||||||
|
await authApi.endOtherSessions();
|
||||||
|
setSessions((current) => current.filter((session) => session.is_current));
|
||||||
|
} finally {
|
||||||
|
setIsRevokingOthers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const others = sessions.filter((session) => !session.is_current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6">
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{t("sections.sessions")}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("sections.sessionsDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{others.length > 0 && (
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={endOthers}
|
||||||
|
disabled={isRevokingOthers}
|
||||||
|
>
|
||||||
|
<LogOut className="me-2 h-4 w-4" />
|
||||||
|
{t("buttons.signOutOthers")}
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("messages.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("messages.noSessions")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const { label, isMobile } = describeDevice(session.user_agent);
|
||||||
|
const DeviceIcon = isMobile ? Smartphone : Laptop;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={session.id}
|
||||||
|
className="flex flex-wrap items-center gap-3 py-3"
|
||||||
|
>
|
||||||
|
<DeviceIcon className="h-5 w-5 shrink-0 text-[var(--text-secondary)]" />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{label ?? t("fields.unknownDevice")}
|
||||||
|
</span>
|
||||||
|
{session.is_current && (
|
||||||
|
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
{t("fields.thisDevice")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-[var(--text-secondary)]">
|
||||||
|
{[
|
||||||
|
session.ip_address,
|
||||||
|
relativeTime(session.last_used_at, i18n.language),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The current session is ended by signing out, not from
|
||||||
|
here — a button that logs you out of the page you are
|
||||||
|
standing on reads as a mistake. */}
|
||||||
|
{!session.is_current && (
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => endSession(session.id)}
|
||||||
|
disabled={busyId === session.id}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-2 h-4 w-4" />
|
||||||
|
{t("buttons.endSession")}
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SessionsPanel;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
|
||||||
|
export type ReferenceList = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
allows_custom_items: boolean;
|
||||||
|
/** The platform owns it: visible to you, not yours to change — though you may
|
||||||
|
* usually add your own items to it. */
|
||||||
|
is_platform: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReferenceItem = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
metadata_json?: Record<string, unknown> | null;
|
||||||
|
is_platform: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference lists — the things dropdowns are made of.
|
||||||
|
*
|
||||||
|
* Reading needs only a session, deliberately: a picker is needed by every
|
||||||
|
* screen, and a permission on it would mean a form that renders empty rather
|
||||||
|
* than one that refuses.
|
||||||
|
*
|
||||||
|
* There is no "rename a code" call. A code is what integrations name and stored
|
||||||
|
* records point at, so changing one is a silent data migration disguised as an
|
||||||
|
* edit — the API has no field for it and neither does this.
|
||||||
|
*/
|
||||||
|
export const referenceApi = {
|
||||||
|
lists: () => apiClient.get<ReferenceList[]>("/api/reference", { toast: false }),
|
||||||
|
|
||||||
|
items: (code: string, includeInactive = false) =>
|
||||||
|
apiClient.get<ReferenceItem[]>(
|
||||||
|
`/api/reference/${encodeURIComponent(code)}/items` +
|
||||||
|
(includeInactive ? "?include_inactive=true" : ""),
|
||||||
|
{ toast: false }
|
||||||
|
),
|
||||||
|
|
||||||
|
createList: (payload: { code: string; name: string; description?: string | null }) =>
|
||||||
|
apiClient.post<ReferenceList>("/api/reference", payload, {
|
||||||
|
successMessage: "List created",
|
||||||
|
errorMessage: "Could not create the list",
|
||||||
|
}),
|
||||||
|
|
||||||
|
renameList: (id: string, payload: { name?: string; description?: string | null }) =>
|
||||||
|
apiClient.put<ReferenceList>(`/api/reference/${id}`, payload, {
|
||||||
|
successMessage: "List updated",
|
||||||
|
errorMessage: "Could not update the list",
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteList: (id: string) =>
|
||||||
|
apiClient.delete<null>(`/api/reference/${id}`, {
|
||||||
|
successMessage: "List deleted",
|
||||||
|
errorMessage: "Could not delete the list",
|
||||||
|
}),
|
||||||
|
|
||||||
|
addItem: (
|
||||||
|
code: string,
|
||||||
|
payload: { code: string; label: string; sort_order?: number }
|
||||||
|
) =>
|
||||||
|
apiClient.post<ReferenceItem>(
|
||||||
|
`/api/reference/${encodeURIComponent(code)}/items`,
|
||||||
|
payload,
|
||||||
|
{ successMessage: "Item added", errorMessage: "Could not add the item" }
|
||||||
|
),
|
||||||
|
|
||||||
|
updateItem: (
|
||||||
|
id: string,
|
||||||
|
payload: { label?: string; sort_order?: number; is_active?: boolean }
|
||||||
|
) =>
|
||||||
|
apiClient.put<ReferenceItem>(`/api/reference/items/${id}`, payload, {
|
||||||
|
successMessage: "Item updated",
|
||||||
|
errorMessage: "Could not update the item",
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Retires rather than deletes, and returns the item to say so — a record
|
||||||
|
* from last year still points at it. */
|
||||||
|
retireItem: (id: string) =>
|
||||||
|
apiClient.delete<ReferenceItem>(`/api/reference/items/${id}`, {
|
||||||
|
successMessage: "Item retired",
|
||||||
|
errorMessage: "Could not retire the item",
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChevronRight, List, Lock, Plus, Trash2, Undo2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomInput,
|
||||||
|
CustomLoader,
|
||||||
|
CustomModal,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { referenceApi } from "./ReferenceApi";
|
||||||
|
import type { ReferenceItem, ReferenceList } from "./ReferenceApi";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference lists.
|
||||||
|
*
|
||||||
|
* The screen exists to make one asymmetry obvious, because it is surprising
|
||||||
|
* until it is explained: **a list the platform maintains is visible and not
|
||||||
|
* editable — but you can usually add your own items to it.** Both halves matter.
|
||||||
|
* Somebody who thinks they cannot extend a standard list will copy it, and the
|
||||||
|
* copy drifts the moment the standard one changes.
|
||||||
|
*
|
||||||
|
* So platform rows are marked, platform items are marked, and the edit controls
|
||||||
|
* are simply absent on them rather than present and failing.
|
||||||
|
*
|
||||||
|
* Retiring is offered instead of deleting, and says which: a record from last
|
||||||
|
* year still points at the item, and removing it would break the report that
|
||||||
|
* shows it months after the change that caused it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ItemsPanel: React.FC<{ list: ReferenceList; onChanged: () => void }> = ({
|
||||||
|
list,
|
||||||
|
onChanged,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation(["reference", "common"]);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ReferenceItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [showRetired, setShowRetired] = useState(false);
|
||||||
|
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setItems(await referenceApi.items(list.code, showRetired));
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [list.code, showRetired]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
await referenceApi.addItem(list.code, {
|
||||||
|
code: code.trim(),
|
||||||
|
label: label.trim(),
|
||||||
|
sort_order: items.length,
|
||||||
|
});
|
||||||
|
setCode("");
|
||||||
|
setLabel("");
|
||||||
|
await load();
|
||||||
|
onChanged();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retire = async (item: ReferenceItem) => {
|
||||||
|
await referenceApi.retireItem(item.id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = async (item: ReferenceItem) => {
|
||||||
|
await referenceApi.updateItem(item.id, { is_active: true });
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canAdd = !list.is_platform || list.allows_custom_items;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{list.is_platform && (
|
||||||
|
<p className="rounded-md border border-[var(--card-border)] p-3 text-sm text-[var(--text-secondary)]">
|
||||||
|
{canAdd ? t("platform.extendable") : t("platform.closed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("items.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`flex flex-wrap items-center gap-2 py-3 ${item.is_active ? "" : "opacity-60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="ms-2 font-mono text-xs text-[var(--text-secondary)]">
|
||||||
|
{item.code}
|
||||||
|
</span>
|
||||||
|
{item.is_platform && (
|
||||||
|
<span className="ms-2 inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
{t("platform.badge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!item.is_active && (
|
||||||
|
<span className="ms-2 text-xs text-[var(--text-secondary)]">
|
||||||
|
{t("items.retired")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Absent rather than present-and-failing on platform items. A
|
||||||
|
button that always refuses teaches people to ignore buttons. */}
|
||||||
|
{!item.is_platform &&
|
||||||
|
(item.is_active ? (
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void retire(item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
) : (
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void restore(item)}
|
||||||
|
>
|
||||||
|
<Undo2 className="me-2 h-3.5 w-3.5" />
|
||||||
|
{t("items.restore")}
|
||||||
|
</CustomButton>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showRetired}
|
||||||
|
onChange={(event) => setShowRetired(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t("items.showRetired")}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{canAdd && (
|
||||||
|
<div className="space-y-3 rounded-md border border-[var(--card-border)] p-3">
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{t("items.add")}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<CustomInput
|
||||||
|
label={t("items.label")}
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label={t("items.code")}
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("items.codeNote")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={add}
|
||||||
|
disabled={isBusy || !code.trim() || !label.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
{t("items.addButton")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReferencePage: React.FC = () => {
|
||||||
|
const { t } = useTranslation(["reference", "common"]);
|
||||||
|
|
||||||
|
const [lists, setLists] = useState<ReferenceList[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [open, setOpen] = useState<ReferenceList | null>(null);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<ReferenceList | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setLists(await referenceApi.lists());
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
await referenceApi.createList({ code: code.trim(), name: name.trim() });
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setCode("");
|
||||||
|
setName("");
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!pendingDelete) return;
|
||||||
|
const target = pendingDelete;
|
||||||
|
setPendingDelete(null);
|
||||||
|
setDeleteError("");
|
||||||
|
try {
|
||||||
|
await referenceApi.deleteList(target.id);
|
||||||
|
} catch (caught) {
|
||||||
|
// The server refuses while it still holds items, and that refusal is the
|
||||||
|
// useful part — it says how many.
|
||||||
|
setDeleteError(
|
||||||
|
caught instanceof Error ? caught.message : t("errors.generic")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton variant="primary" onClick={() => setIsCreateOpen(true)}>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
{t("add")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteError && (
|
||||||
|
<div className="mb-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
|
||||||
|
{deleteError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="px-6 py-12 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : lists.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<List className="mx-auto mb-3 h-8 w-8 text-[var(--text-secondary)]" />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{lists.map((list) => (
|
||||||
|
<li
|
||||||
|
key={list.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 px-6 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{list.name}
|
||||||
|
</span>
|
||||||
|
<span className="ms-2 font-mono text-xs text-[var(--text-secondary)]">
|
||||||
|
{list.code}
|
||||||
|
</span>
|
||||||
|
{list.is_platform && (
|
||||||
|
<span className="ms-2 inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
{t("platform.badge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(list)}
|
||||||
|
>
|
||||||
|
{t("items.open")}
|
||||||
|
<ChevronRight className="ms-1 h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
{!list.is_platform && (
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPendingDelete(list)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isCreateOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
title={t("add")}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.name")}
|
||||||
|
type="text"
|
||||||
|
placeholder={t("form.namePlaceholder")}
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.code")}
|
||||||
|
type="text"
|
||||||
|
placeholder="cost-centre"
|
||||||
|
required
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.codeNote")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={create}
|
||||||
|
disabled={isBusy || !code.trim() || !name.trim()}
|
||||||
|
>
|
||||||
|
{t("form.submit")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={open !== null}
|
||||||
|
onClose={() => setOpen(null)}
|
||||||
|
title={open?.name ?? ""}
|
||||||
|
>
|
||||||
|
{open && <ItemsPanel list={open} onChanged={load} />}
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
onClose={() => setPendingDelete(null)}
|
||||||
|
onConfirm={remove}
|
||||||
|
title={t("confirmDelete.title")}
|
||||||
|
description={t("confirmDelete.message", { name: pendingDelete?.name ?? "" })}
|
||||||
|
confirmText={t("confirmDelete.confirm")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReferencePage;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type {
|
||||||
|
MfaEnrolmentStarted,
|
||||||
|
MfaRecoveryCodes,
|
||||||
|
MfaStatus,
|
||||||
|
} from "./SecurityTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managing the second factor on your own account.
|
||||||
|
*
|
||||||
|
* Every call here acts on the caller — there is no user id in any path. An
|
||||||
|
* administrator cannot enrol a factor on somebody else's behalf, because a
|
||||||
|
* factor somebody else set up is not a second factor, it is a second person who
|
||||||
|
* can sign in as them.
|
||||||
|
*
|
||||||
|
* `disable` and `regenerateRecoveryCodes` send the password as well as a code.
|
||||||
|
* A session token is enough to *use* the account — that is what a session is —
|
||||||
|
* but it must not be enough to disarm it, or a laptop left open removes the
|
||||||
|
* protection and keeps the access.
|
||||||
|
*/
|
||||||
|
export const securityApi = {
|
||||||
|
status: () => apiClient.get<MfaStatus>("/api/auth/mfa"),
|
||||||
|
|
||||||
|
beginEnrolment: () =>
|
||||||
|
apiClient.post<MfaEnrolmentStarted>("/api/auth/mfa/enrol", null, {
|
||||||
|
// No toast: nothing has happened yet. The factor is inactive until a code
|
||||||
|
// from it has been presented, and saying "created" here would tell
|
||||||
|
// somebody they are protected when they are not.
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
confirmEnrolment: (code: string) =>
|
||||||
|
apiClient.post<MfaRecoveryCodes>(
|
||||||
|
"/api/auth/mfa/confirm",
|
||||||
|
{ code },
|
||||||
|
{
|
||||||
|
successMessage: "Two-factor authentication is on",
|
||||||
|
errorMessage: "That code is not correct",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
regenerateRecoveryCodes: (password: string, code?: string) =>
|
||||||
|
apiClient.post<MfaRecoveryCodes>(
|
||||||
|
"/api/auth/mfa/recovery-codes",
|
||||||
|
{ password, code },
|
||||||
|
{
|
||||||
|
successMessage: "New recovery codes issued",
|
||||||
|
errorMessage: "Could not issue new codes",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
disable: (password: string, code?: string) =>
|
||||||
|
apiClient.post<null>(
|
||||||
|
"/api/auth/mfa/disable",
|
||||||
|
{ password, code },
|
||||||
|
{
|
||||||
|
successMessage: "Two-factor authentication is off",
|
||||||
|
errorMessage: "Could not turn it off",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import SecurityPanel from "./SecurityPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second-factor panel.
|
||||||
|
*
|
||||||
|
* The state worth testing hardest is the middle one: a secret generated and
|
||||||
|
* never proved. The factor does *nothing* then, and somebody who scanned a code
|
||||||
|
* and walked away believing they were protected is worse off than somebody who
|
||||||
|
* never started — so the screen has to say so rather than showing a neutral
|
||||||
|
* "pending".
|
||||||
|
*
|
||||||
|
* The rest is about not letting a session token disarm the account, and about
|
||||||
|
* the recovery codes being hard to dismiss by reflex, since that is the only
|
||||||
|
* moment they exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const status = vi.fn();
|
||||||
|
const beginEnrolment = vi.fn();
|
||||||
|
const confirmEnrolment = vi.fn();
|
||||||
|
const disable = vi.fn();
|
||||||
|
const regenerate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./SecurityApi", () => ({
|
||||||
|
securityApi: {
|
||||||
|
status: () => status(),
|
||||||
|
beginEnrolment: () => beginEnrolment(),
|
||||||
|
confirmEnrolment: (code: string) => confirmEnrolment(code),
|
||||||
|
disable: (password: string, code?: string) => disable(password, code),
|
||||||
|
regenerateRecoveryCodes: (password: string, code?: string) =>
|
||||||
|
regenerate(password, code),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The QR library sits behind a module of ours, so the test mocks that rather
|
||||||
|
// than reaching through to the package's own interop.
|
||||||
|
// A plain async function rather than `vi.fn().mockResolvedValue(...)`: the
|
||||||
|
// factory is hoisted above the imports, and a spy built there resolves to
|
||||||
|
// `undefined` — which looks exactly like a QR code that failed to render.
|
||||||
|
vi.mock("./qr", () => ({
|
||||||
|
toQrDataUrl: async () => "data:image/png;base64,x",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
// The key itself, so a test asserting on text is asserting on the key
|
||||||
|
// rather than on prose somebody may reword.
|
||||||
|
t: (key: string, options?: Record<string, unknown>) =>
|
||||||
|
options && typeof options.count === "number"
|
||||||
|
? `${key}:${options.count}`
|
||||||
|
: key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
status.mockReset();
|
||||||
|
beginEnrolment.mockReset();
|
||||||
|
confirmEnrolment.mockReset();
|
||||||
|
disable.mockReset();
|
||||||
|
regenerate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const off = { enabled: false, enrolment_pending: false, recovery_codes_remaining: 0 };
|
||||||
|
const pending = { enabled: false, enrolment_pending: true, recovery_codes_remaining: 0 };
|
||||||
|
const on = { enabled: true, enrolment_pending: false, recovery_codes_remaining: 10 };
|
||||||
|
|
||||||
|
describe("the three states", () => {
|
||||||
|
it("offers to set it up when it is off", async () => {
|
||||||
|
status.mockResolvedValue(off);
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("off.start")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("state.pendingWarning")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says plainly that a half-finished enrolment protects nothing", async () => {
|
||||||
|
status.mockResolvedValue(pending);
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("state.pendingWarning")).toBeTruthy();
|
||||||
|
// And still shows "off", because that is what it is.
|
||||||
|
expect(screen.getByText("state.off")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows how many recovery codes are left when it is on", async () => {
|
||||||
|
status.mockResolvedValue(on);
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("on.recoveryRemaining:10")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("on.runningLow")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the recovery codes are nearly gone", async () => {
|
||||||
|
status.mockResolvedValue({ ...on, recovery_codes_remaining: 1 });
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("on.runningLow")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim it is off when it could not ask", async () => {
|
||||||
|
// Showing "two-factor is off" when the request failed is a false claim
|
||||||
|
// about a security control, and the one that would make somebody act.
|
||||||
|
status.mockRejectedValue(new Error("network"));
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("errors.loadFailed")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("off.start")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enrolling", () => {
|
||||||
|
it("shows the secret as text as well as a QR code", async () => {
|
||||||
|
// Every authenticator app accepts a typed key, and somebody reading the
|
||||||
|
// screen on the same device they are enrolling cannot scan it.
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
status.mockResolvedValue(off);
|
||||||
|
beginEnrolment.mockResolvedValue({
|
||||||
|
secret: "JBSWY3DPEHPK3PXP",
|
||||||
|
otpauth_uri: "otpauth://totp/x",
|
||||||
|
});
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("off.start"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("JBSWY3DPEHPK3PXP")).toBeTruthy();
|
||||||
|
expect(screen.getByAltText("enrol.qrAlt")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands over the recovery codes once the code is confirmed", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
status.mockResolvedValue(off);
|
||||||
|
beginEnrolment.mockResolvedValue({
|
||||||
|
secret: "S",
|
||||||
|
otpauth_uri: "otpauth://totp/x",
|
||||||
|
});
|
||||||
|
confirmEnrolment.mockResolvedValue({ codes: ["aaaa-bbbb-cccc"] });
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("off.start"));
|
||||||
|
await user.type(await screen.findByLabelText("enrol.codeLabel"), "123456");
|
||||||
|
await user.click(screen.getByText("enrol.confirm"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("aaaa-bbbb-cccc")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a rejected code so the next attempt is not confusing", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
status.mockResolvedValue(off);
|
||||||
|
beginEnrolment.mockResolvedValue({ secret: "S", otpauth_uri: "otpauth://x" });
|
||||||
|
confirmEnrolment.mockRejectedValue(new Error("That code is not correct"));
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("off.start"));
|
||||||
|
const field = await screen.findByLabelText("enrol.codeLabel");
|
||||||
|
await user.type(field, "000000");
|
||||||
|
await user.click(screen.getByText("enrol.confirm"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(field).toHaveValue(""));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not let the recovery codes be dismissed unread", async () => {
|
||||||
|
// This is the only time they exist in readable form. A modal somebody
|
||||||
|
// can close by reflex is how they get lost.
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
status.mockResolvedValue(off);
|
||||||
|
beginEnrolment.mockResolvedValue({ secret: "S", otpauth_uri: "otpauth://x" });
|
||||||
|
confirmEnrolment.mockResolvedValue({ codes: ["aaaa-bbbb-cccc"] });
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("off.start"));
|
||||||
|
await user.type(await screen.findByLabelText("enrol.codeLabel"), "123456");
|
||||||
|
await user.click(screen.getByText("enrol.confirm"));
|
||||||
|
|
||||||
|
const done = await screen.findByText("recovery.done");
|
||||||
|
expect(done.closest("button")).toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText("recovery.acknowledge"));
|
||||||
|
expect(done.closest("button")).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("turning it off", () => {
|
||||||
|
it("asks for the password, not just the session", async () => {
|
||||||
|
// A stolen session must not be enough to disarm the account it stole.
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
status.mockResolvedValue(on);
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("on.disable"));
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText("confirm.password")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("confirm.code")).toBeTruthy();
|
||||||
|
expect(disable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends both the password and the code", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
status.mockResolvedValue(on);
|
||||||
|
disable.mockResolvedValue(null);
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("on.disable"));
|
||||||
|
await user.type(await screen.findByLabelText("confirm.password"), "hunter2");
|
||||||
|
await user.type(screen.getByLabelText("confirm.code"), "123456");
|
||||||
|
await user.click(screen.getByText("confirm.submit"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(disable).toHaveBeenCalledWith("hunter2", "123456"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be submitted without a password", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
status.mockResolvedValue(on);
|
||||||
|
render(<SecurityPanel />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("on.disable"));
|
||||||
|
const submit = await screen.findByText("confirm.submit");
|
||||||
|
|
||||||
|
expect(submit.closest("button")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Copy, KeyRound, ShieldCheck, ShieldOff } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomInput,
|
||||||
|
CustomLoader,
|
||||||
|
CustomModal,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { securityApi } from "./SecurityApi";
|
||||||
|
import { toQrDataUrl } from "./qr";
|
||||||
|
import type { MfaStatus } from "./SecurityTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second factor, on your own account.
|
||||||
|
*
|
||||||
|
* Three states, and the screen has to make the middle one unmistakable:
|
||||||
|
*
|
||||||
|
* - **off** — nothing set up.
|
||||||
|
* - **half-enrolled** — a secret was generated and never proved. The factor
|
||||||
|
* does *nothing* in this state. Somebody who scans a code, walks away, and
|
||||||
|
* believes they are protected is worse off than somebody who never started,
|
||||||
|
* so this says so in as many words rather than showing a neutral "pending".
|
||||||
|
* - **on** — a code from the secret has been presented once.
|
||||||
|
*
|
||||||
|
* Turning it off costs the password *and* a code. A session token is enough to
|
||||||
|
* use the account; it must not be enough to disarm it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RecoveryCodes: React.FC<{ codes: string[]; onDone: () => void }> = ({
|
||||||
|
codes,
|
||||||
|
onDone,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation(["security", "common"]);
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
|
||||||
|
const copyAll = () => {
|
||||||
|
void navigator.clipboard?.writeText(codes.join("\n"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = () => {
|
||||||
|
// A blob rather than a link to a server: these exist only in this response,
|
||||||
|
// and a URL that could fetch them again would defeat the point of hashing
|
||||||
|
// them on the way in.
|
||||||
|
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = "recovery-codes.txt";
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("recovery.explain")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="grid grid-cols-2 gap-2 rounded-md border border-[var(--card-border)] p-3 font-mono text-sm">
|
||||||
|
{codes.map((code) => (
|
||||||
|
<li key={code} className="text-[var(--text-primary)]">
|
||||||
|
{code}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CustomButton variant="secondary" onClick={copyAll}>
|
||||||
|
<Copy className="me-2 h-4 w-4" />
|
||||||
|
{t("recovery.copy")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton variant="secondary" onClick={download}>
|
||||||
|
{t("recovery.download")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm text-[var(--text-primary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acknowledged}
|
||||||
|
onChange={(event) => setAcknowledged(event.target.checked)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>{t("recovery.acknowledge")}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Deliberately gated. This is the only time these exist in readable form,
|
||||||
|
and a modal somebody can dismiss by reflex is how they get lost. */}
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!acknowledged}
|
||||||
|
onClick={onDone}
|
||||||
|
>
|
||||||
|
{t("recovery.done")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SecurityPanel: React.FC = () => {
|
||||||
|
const { t } = useTranslation(["security", "common"]);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<MfaStatus | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const [enrolment, setEnrolment] = useState<{
|
||||||
|
secret: string;
|
||||||
|
uri: string;
|
||||||
|
qr: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [codes, setCodes] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
const [confirmAction, setConfirmAction] = useState<"disable" | "regenerate" | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmCode, setConfirmCode] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setStatus(await securityApi.status());
|
||||||
|
} catch {
|
||||||
|
// Not an empty state. Showing "two-factor is off" when we could not ask is
|
||||||
|
// a false claim about a security control, and the one that would make
|
||||||
|
// somebody act.
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const startEnrolment = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const started = await securityApi.beginEnrolment();
|
||||||
|
// Null when it could not be drawn — see `toQrDataUrl`. The secret below is
|
||||||
|
// shown either way, and every authenticator app accepts it typed.
|
||||||
|
const qr = await toQrDataUrl(started.otpauth_uri);
|
||||||
|
setEnrolment({ secret: started.secret, uri: started.otpauth_uri, qr });
|
||||||
|
setCode("");
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmEnrolment = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const issued = await securityApi.confirmEnrolment(code.trim());
|
||||||
|
setEnrolment(null);
|
||||||
|
setCode("");
|
||||||
|
setCodes(issued.codes);
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
// The code is cleared: leaving a spent one in the field means the next
|
||||||
|
// attempt fails for a reason the person cannot see.
|
||||||
|
setCode("");
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.badCode"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runConfirmedAction = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
if (confirmAction === "disable") {
|
||||||
|
await securityApi.disable(password, confirmCode.trim() || undefined);
|
||||||
|
setConfirmAction(null);
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
const issued = await securityApi.regenerateRecoveryCodes(
|
||||||
|
password,
|
||||||
|
confirmCode.trim() || undefined
|
||||||
|
);
|
||||||
|
setConfirmAction(null);
|
||||||
|
setCodes(issued.codes);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
setPassword("");
|
||||||
|
setConfirmCode("");
|
||||||
|
} catch (caught) {
|
||||||
|
setConfirmCode("");
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = "rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={card}>
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={card}>
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.enabled ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
{t("state.on")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||||
|
<ShieldOff className="h-3.5 w-3.5" />
|
||||||
|
{t("state.off")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<p className="py-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
{/* Half-enrolled. Said plainly, because believing you are protected when
|
||||||
|
you are not is worse than knowing you are not. */}
|
||||||
|
{!failed && status?.enrolment_pending && !enrolment && (
|
||||||
|
<div className="mb-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
|
||||||
|
{t("state.pendingWarning")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!failed && !status?.enabled && !enrolment && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("off.explain")}</p>
|
||||||
|
<CustomButton variant="primary" onClick={startEnrolment} disabled={isBusy}>
|
||||||
|
<KeyRound className="me-2 h-4 w-4" />
|
||||||
|
{t("off.start")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enrolment && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("enrol.step1")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
|
{enrolment.qr && (
|
||||||
|
<img
|
||||||
|
src={enrolment.qr}
|
||||||
|
alt={t("enrol.qrAlt")}
|
||||||
|
className="rounded-md border border-[var(--card-border)] bg-white p-2"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("enrol.manual")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<code className="break-all rounded bg-[var(--card-border)]/30 px-2 py-1 font-mono text-sm text-[var(--text-primary)]">
|
||||||
|
{enrolment.secret}
|
||||||
|
</code>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
void navigator.clipboard?.writeText(enrolment.secret)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("enrol.step2")}</p>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("enrol.codeLabel")}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="123456"
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={confirmEnrolment}
|
||||||
|
disabled={isBusy || code.trim().length === 0}
|
||||||
|
>
|
||||||
|
{t("enrol.confirm")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEnrolment(null);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{t("common:buttons.cancel", "Cancel")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!failed && status?.enabled && !enrolment && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("on.recoveryRemaining", {
|
||||||
|
count: status.recovery_codes_remaining,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* A running-low count is the useful part: somebody down to their last
|
||||||
|
code is one lost phone away from a support ticket. */}
|
||||||
|
{status.recovery_codes_remaining <= 2 && (
|
||||||
|
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
|
||||||
|
{t("on.runningLow")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmAction("regenerate");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("on.regenerate")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmAction("disable");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldOff className="me-2 h-4 w-4" />
|
||||||
|
{t("on.disable")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={codes !== null}
|
||||||
|
onClose={() => setCodes(null)}
|
||||||
|
title={t("recovery.title")}
|
||||||
|
>
|
||||||
|
{codes && <RecoveryCodes codes={codes} onDone={() => setCodes(null)} />}
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={confirmAction !== null}
|
||||||
|
onClose={() => {
|
||||||
|
setConfirmAction(null);
|
||||||
|
setPassword("");
|
||||||
|
setConfirmCode("");
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
confirmAction === "disable"
|
||||||
|
? t("on.disable")
|
||||||
|
: t("on.regenerate")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{confirmAction === "disable"
|
||||||
|
? t("confirm.disableExplain")
|
||||||
|
: t("confirm.regenerateExplain")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("confirm.password")}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("confirm.code")}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder={t("confirm.codePlaceholder")}
|
||||||
|
value={confirmCode}
|
||||||
|
onChange={(event) => setConfirmCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={runConfirmedAction}
|
||||||
|
disabled={isBusy || password.length === 0}
|
||||||
|
>
|
||||||
|
{t("confirm.submit")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SecurityPanel;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type MfaStatus = {
|
||||||
|
enabled: boolean;
|
||||||
|
/** A secret was generated and never proved. The factor does nothing in this
|
||||||
|
* state — which is deliberate: somebody who scans a QR code and whose phone
|
||||||
|
* then dies must not be locked out by a secret nobody holds. */
|
||||||
|
enrolment_pending: boolean;
|
||||||
|
recovery_codes_remaining: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MfaEnrolmentStarted = {
|
||||||
|
secret: string;
|
||||||
|
otpauth_uri: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MfaRecoveryCodes = {
|
||||||
|
/** Returned once. They are hashed on the way in, so there is no endpoint that
|
||||||
|
* can show them again — which is the property that makes them safe to store
|
||||||
|
* at all, and the reason the screen insists you take them now. */
|
||||||
|
codes: string[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Render an `otpauth://` URI as a data URL, or nothing.
|
||||||
|
*
|
||||||
|
* A named boundary rather than a call inline, for three reasons:
|
||||||
|
*
|
||||||
|
* - **Failing is a real case, not an exception.** The secret is shown as text
|
||||||
|
* beside it and every authenticator app accepts a typed key, so a failed
|
||||||
|
* render is a smaller problem than a dead enrolment screen. Returning `null`
|
||||||
|
* makes that the ordinary path rather than something a `catch` hides.
|
||||||
|
* - **The library loads on demand.** Imported at the top of the file it lands in
|
||||||
|
* the profile chunk, which every signed-in person downloads to look at their
|
||||||
|
* own details — for a drawing almost none of them will ever need. Enrolling in
|
||||||
|
* a second factor happens once, if ever, and that is when this is fetched.
|
||||||
|
* - It is the one place the QR library is named, so nothing else has to know how
|
||||||
|
* that package resolves.
|
||||||
|
*/
|
||||||
|
export const toQrDataUrl = async (text: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const { default: QRCode } = await import("qrcode");
|
||||||
|
return await QRCode.toDataURL(text, { margin: 1, width: 200 });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { CustomDropdown, CustomConfirmationModal } from '../../components/custom';
|
import { CustomDropdown, CustomConfirmationModal } from '../../components/custom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { languages } from '../../i18n/config';
|
import { languages, loadLanguage } from '../../i18n/config';
|
||||||
import type { SupportedLanguage } from '../../i18n/config';
|
import type { SupportedLanguage } from '../../i18n/config';
|
||||||
import { Globe } from 'lucide-react';
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
@@ -31,12 +31,17 @@ const SettingsPage: React.FC = () => {
|
|||||||
setShowConfirmation(false);
|
setShowConfirmation(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fetched before the reload rather than after it. The page comes back in
|
||||||
|
// the new language either way, but pre-warming means the bundle is in the
|
||||||
|
// browser's cache when it does, instead of a flash of English.
|
||||||
|
await loadLanguage(pendingLanguage);
|
||||||
|
|
||||||
localStorage.setItem('preferred_language', pendingLanguage);
|
localStorage.setItem('preferred_language', pendingLanguage);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await updateLanguage(pendingLanguage);
|
await updateLanguage(pendingLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update language:', error);
|
console.error('Failed to update language:', error);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type {
|
||||||
|
IdentityProvider,
|
||||||
|
IdentityProviderCreate,
|
||||||
|
IdentityProviderUpdate,
|
||||||
|
} from "./SsoTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace's own identity provider — its Azure AD, Okta or Google.
|
||||||
|
*
|
||||||
|
* The inbound direction. "SSO" elsewhere in this codebase means the platform
|
||||||
|
* signing users *into* modules, which is a different thing entirely and lives
|
||||||
|
* under a different path for exactly that reason.
|
||||||
|
*/
|
||||||
|
export const ssoApi = {
|
||||||
|
list: () => apiClient.get<IdentityProvider[]>("/api/admin/sso/"),
|
||||||
|
|
||||||
|
create: (payload: IdentityProviderCreate) =>
|
||||||
|
apiClient.post<IdentityProvider>("/api/admin/sso/", payload, {
|
||||||
|
successMessage: "Connection added",
|
||||||
|
errorMessage: "Could not add the connection",
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: (id: string, payload: IdentityProviderUpdate) =>
|
||||||
|
apiClient.patch<IdentityProvider>(`/api/admin/sso/${id}`, payload, {
|
||||||
|
successMessage: "Connection updated",
|
||||||
|
errorMessage: "Could not update the connection",
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the provider's discovery document.
|
||||||
|
*
|
||||||
|
* Endpoints are filled in from it rather than typed, which is both less
|
||||||
|
* error-prone and how a provider signals that one has moved — so this is the
|
||||||
|
* button to press when a working connection suddenly is not.
|
||||||
|
*/
|
||||||
|
discover: (id: string) =>
|
||||||
|
apiClient.post<IdentityProvider>(`/api/admin/sso/${id}/discover`, null, {
|
||||||
|
successMessage: "Configuration refreshed from the provider",
|
||||||
|
errorMessage: "Could not reach the provider",
|
||||||
|
}),
|
||||||
|
|
||||||
|
remove: (id: string) =>
|
||||||
|
apiClient.delete<{ message?: string }>(`/api/admin/sso/${id}`, {
|
||||||
|
successMessage: "Connection removed",
|
||||||
|
errorMessage: "Could not remove the connection",
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Copy, LogIn, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomCheckBox,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomInput,
|
||||||
|
CustomLoader,
|
||||||
|
CustomModal,
|
||||||
|
CustomSwitch,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { ssoApi } from "./SsoApi";
|
||||||
|
import type { IdentityProvider } from "./SsoTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace connecting its own identity provider, and pointing its directory
|
||||||
|
* at us.
|
||||||
|
*
|
||||||
|
* ## Two things this screen is careful about
|
||||||
|
*
|
||||||
|
* **The secret is write-only.** There is no field to read it back, because a
|
||||||
|
* secret that can be read is a secret in every response log, browser cache and
|
||||||
|
* screen-share. The form shows whether one is set and offers to replace it;
|
||||||
|
* leaving the field empty on an edit keeps the stored one.
|
||||||
|
*
|
||||||
|
* **A connection starts switched off.** Turning it on is a separate,
|
||||||
|
* deliberate act after discovery has succeeded — otherwise a half-configured
|
||||||
|
* provider becomes the sign-in route for a whole workspace the moment somebody
|
||||||
|
* saves the form.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CopyRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium text-[var(--text-secondary)]">{label}</p>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-[var(--card-border)] p-2">
|
||||||
|
<code className="min-w-0 flex-1 break-all font-mono text-xs text-[var(--text-primary)]">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void navigator.clipboard?.writeText(value)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SsoPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation(["sso", "common"]);
|
||||||
|
|
||||||
|
const [providers, setProviders] = useState<IdentityProvider[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<IdentityProvider | null>(null);
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [issuer, setIssuer] = useState("");
|
||||||
|
const [clientId, setClientId] = useState("");
|
||||||
|
const [clientSecret, setClientSecret] = useState("");
|
||||||
|
const [domains, setDomains] = useState("");
|
||||||
|
const [jit, setJit] = useState(true);
|
||||||
|
const [linkByEmail, setLinkByEmail] = useState(false);
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [pendingRemove, setPendingRemove] = useState<IdentityProvider | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setProviders(await ssoApi.list());
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const openNew = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setName("");
|
||||||
|
setSlug("");
|
||||||
|
setIssuer("");
|
||||||
|
setClientId("");
|
||||||
|
setClientSecret("");
|
||||||
|
setDomains("");
|
||||||
|
setJit(true);
|
||||||
|
setLinkByEmail(false);
|
||||||
|
setError("");
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (provider: IdentityProvider) => {
|
||||||
|
setEditing(provider);
|
||||||
|
setName(provider.name);
|
||||||
|
setSlug(provider.slug);
|
||||||
|
setIssuer(provider.issuer ?? "");
|
||||||
|
setClientId(provider.client_id ?? "");
|
||||||
|
// Always blank. The stored secret cannot be read back, so pre-filling
|
||||||
|
// anything here would be a lie that overwrites it on save.
|
||||||
|
setClientSecret("");
|
||||||
|
setDomains(provider.allowed_domains ?? "");
|
||||||
|
setJit(provider.jit_provisioning);
|
||||||
|
setLinkByEmail(provider.link_existing_by_email);
|
||||||
|
setError("");
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await ssoApi.update(editing.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
issuer: issuer.trim() || null,
|
||||||
|
client_id: clientId.trim() || null,
|
||||||
|
// Omitted when blank, which leaves the stored secret alone.
|
||||||
|
...(clientSecret ? { client_secret: clientSecret } : {}),
|
||||||
|
allowed_domains: domains.trim() || null,
|
||||||
|
jit_provisioning: jit,
|
||||||
|
link_existing_by_email: linkByEmail,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ssoApi.create({
|
||||||
|
name: name.trim(),
|
||||||
|
slug: slug.trim(),
|
||||||
|
issuer: issuer.trim() || null,
|
||||||
|
client_id: clientId.trim() || null,
|
||||||
|
client_secret: clientSecret || null,
|
||||||
|
allowed_domains: domains.trim() || null,
|
||||||
|
jit_provisioning: jit,
|
||||||
|
link_existing_by_email: linkByEmail,
|
||||||
|
// Off. Turning it on is a separate act, after discovery has worked.
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsFormOpen(false);
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (provider: IdentityProvider) => {
|
||||||
|
await ssoApi.update(provider.id, { enabled: !provider.enabled });
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rediscover = async (provider: IdentityProvider) => {
|
||||||
|
await ssoApi.discover(provider.id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!pendingRemove) return;
|
||||||
|
const target = pendingRemove;
|
||||||
|
setPendingRemove(null);
|
||||||
|
try {
|
||||||
|
await ssoApi.remove(target.id);
|
||||||
|
} finally {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scimBase = `${window.location.origin.replace(/\/$/, "")}/scim/v2`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton variant="primary" onClick={openNew}>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
{t("add")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="px-6 py-12 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<LogIn className="mx-auto mb-3 h-8 w-8 text-[var(--text-secondary)]" />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<li key={provider.id} className="px-6 py-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{provider.name}
|
||||||
|
</span>
|
||||||
|
{!provider.client_secret_set && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{t("noSecret")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!provider.discovered_at && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{t("notDiscovered")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 break-all text-sm text-[var(--text-secondary)]">
|
||||||
|
{provider.issuer ?? t("noIssuer")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{provider.allowed_domains
|
||||||
|
? t("domains.limited", { domains: provider.allowed_domains })
|
||||||
|
: t("domains.any")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CustomSwitch
|
||||||
|
checked={provider.enabled}
|
||||||
|
onChange={() => void toggle(provider)}
|
||||||
|
label={provider.enabled ? t("on") : t("off")}
|
||||||
|
/>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void rediscover(provider)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="me-2 h-4 w-4" />
|
||||||
|
{t("rediscover")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => openEdit(provider)}
|
||||||
|
>
|
||||||
|
{t("common:actions.edit", "Edit")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPendingRemove(provider)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provisioning. Kept on the same screen as the sign-in connection
|
||||||
|
because they are two halves of the same setup: one decides who you
|
||||||
|
are, the other whether you still exist. */}
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{t("scim.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("scim.subtitle")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<CopyRow label={t("scim.baseUrl")} value={scimBase} />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("scim.token")}</p>
|
||||||
|
<ul className="list-disc space-y-1 text-sm text-[var(--text-secondary)] ltr:pl-5 rtl:pr-5">
|
||||||
|
<li>{t("scim.step1")}</li>
|
||||||
|
<li>{t("scim.step2")}</li>
|
||||||
|
<li>{t("scim.step3")}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isFormOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
title={editing ? t("form.editTitle") : t("add")}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.name")}
|
||||||
|
type="text"
|
||||||
|
placeholder="Contoso Azure AD"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<>
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.slug")}
|
||||||
|
type="text"
|
||||||
|
placeholder="contoso"
|
||||||
|
required
|
||||||
|
value={slug}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSlug(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.slugNote")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.issuer")}
|
||||||
|
type="url"
|
||||||
|
placeholder="https://login.microsoftonline.com/<tenant>/v2.0"
|
||||||
|
value={issuer}
|
||||||
|
onChange={(event) => setIssuer(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.issuerNote")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.clientId")}
|
||||||
|
type="text"
|
||||||
|
value={clientId}
|
||||||
|
onChange={(event) => setClientId(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={
|
||||||
|
editing && editing.client_secret_set
|
||||||
|
? t("form.secretReplace")
|
||||||
|
: t("form.secret")
|
||||||
|
}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={clientSecret}
|
||||||
|
onChange={(event) => setClientSecret(event.target.value)}
|
||||||
|
/>
|
||||||
|
{editing && editing.client_secret_set && (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.secretNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.domains")}
|
||||||
|
type="text"
|
||||||
|
placeholder="contoso.com, contoso.co.uk"
|
||||||
|
value={domains}
|
||||||
|
onChange={(event) => setDomains(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.domainsNote")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CustomCheckBox
|
||||||
|
label={t("form.jit")}
|
||||||
|
checked={jit}
|
||||||
|
onChange={(event) => setJit(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<CustomCheckBox
|
||||||
|
label={t("form.linkByEmail")}
|
||||||
|
checked={linkByEmail}
|
||||||
|
onChange={(event) => setLinkByEmail(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.linkByEmailNote")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={save}
|
||||||
|
disabled={isBusy || name.trim().length === 0}
|
||||||
|
>
|
||||||
|
{editing ? t("form.save") : t("form.create")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={pendingRemove !== null}
|
||||||
|
onClose={() => setPendingRemove(null)}
|
||||||
|
onConfirm={remove}
|
||||||
|
title={t("confirmRemove.title")}
|
||||||
|
description={t("confirmRemove.message", { name: pendingRemove?.name ?? "" })}
|
||||||
|
confirmText={t("confirmRemove.confirm")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SsoPage;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type IdentityProvider = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
/** Part of the login URL, so it is fixed once created — changing it would
|
||||||
|
* break any bookmark or portal link a customer has already handed out. */
|
||||||
|
slug: string;
|
||||||
|
enabled: boolean;
|
||||||
|
issuer?: string | null;
|
||||||
|
client_id?: string | null;
|
||||||
|
scopes: string;
|
||||||
|
/** Comma-separated. Empty means any domain the provider vouches for, which is
|
||||||
|
* the difference between "our staff" and "anyone with a Google account". */
|
||||||
|
allowed_domains?: string | null;
|
||||||
|
jit_provisioning: boolean;
|
||||||
|
default_role_id?: string | null;
|
||||||
|
link_existing_by_email: boolean;
|
||||||
|
authorization_endpoint?: string | null;
|
||||||
|
token_endpoint?: string | null;
|
||||||
|
jwks_uri?: string | null;
|
||||||
|
discovered_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
/** There is no field carrying the secret itself: one that can be read back is
|
||||||
|
* a secret in every response log, browser cache and screen-share. */
|
||||||
|
client_secret_set: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityProviderCreate = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
issuer?: string | null;
|
||||||
|
client_id?: string | null;
|
||||||
|
client_secret?: string | null;
|
||||||
|
scopes?: string;
|
||||||
|
allowed_domains?: string | null;
|
||||||
|
jit_provisioning?: boolean;
|
||||||
|
link_existing_by_email?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityProviderUpdate = Partial<
|
||||||
|
Omit<IdentityProviderCreate, "slug">
|
||||||
|
> & {
|
||||||
|
/** Turning a connection on is a separate act from configuring it: a
|
||||||
|
* half-configured provider must not become the sign-in route for a whole
|
||||||
|
* workspace the moment somebody saves the form. */
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ export type SubscriptionPlan = {
|
|||||||
price?: number | null;
|
price?: number | null;
|
||||||
duration_days?: number | null;
|
duration_days?: number | null;
|
||||||
max_users_allowed?: number | null;
|
max_users_allowed?: number | null;
|
||||||
|
/** Days a lapsed workspace stays read-only before it is locked out. 0 = none. */
|
||||||
|
grace_period_days?: number | null;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -22,6 +24,7 @@ export type SubscriptionPlanCreateRequest = {
|
|||||||
price?: number;
|
price?: number;
|
||||||
duration_days?: number;
|
duration_days?: number;
|
||||||
max_users_allowed?: number;
|
max_users_allowed?: number;
|
||||||
|
grace_period_days?: number;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
access_ids?: string[];
|
access_ids?: string[];
|
||||||
@@ -34,6 +37,7 @@ export type SubscriptionPlanUpdateRequest = {
|
|||||||
price?: number;
|
price?: number;
|
||||||
duration_days?: number;
|
duration_days?: number;
|
||||||
max_users_allowed?: number;
|
max_users_allowed?: number;
|
||||||
|
grace_period_days?: number;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
access_ids?: string[];
|
access_ids?: string[];
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { buildPlanCreatePayload, buildPlanUpdatePayload } from "./buildPlanPayload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plan decides the permission bound, the seat limit and the grace window.
|
||||||
|
* Every one of those is optional, has a meaningful zero, and has a "not set"
|
||||||
|
* that means something different from zero — which is exactly the shape that
|
||||||
|
* goes wrong quietly.
|
||||||
|
*
|
||||||
|
* The specific trap: number fields come from `<input type="number">` as strings,
|
||||||
|
* and `Number("")` is `0`. An empty seat-limit box becomes a real zero unless the
|
||||||
|
* empty case is handled first — and a plan with `max_users_allowed: 0` cannot
|
||||||
|
* have a single user added to it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
name: " Enterprise ",
|
||||||
|
is_public: true,
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildPlanCreatePayload", () => {
|
||||||
|
it("trims the name", () => {
|
||||||
|
expect(buildPlanCreatePayload(base, [], []).name).toBe("Enterprise");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unset seat limit unset", () => {
|
||||||
|
// Unset means unlimited. This is the one that locks a customer out of
|
||||||
|
// their own workspace if it becomes 0.
|
||||||
|
const payload = buildPlanCreatePayload(
|
||||||
|
{ ...base, max_users_allowed: "" },
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.max_users_allowed).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a seat limit of zero, which is not the same thing", () => {
|
||||||
|
const payload = buildPlanCreatePayload(
|
||||||
|
{ ...base, max_users_allowed: 0 },
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.max_users_allowed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a number typed into a text input", () => {
|
||||||
|
const payload = buildPlanCreatePayload(
|
||||||
|
{ ...base, max_users_allowed: "25", price: "99.5", duration_days: "30" },
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.max_users_allowed).toBe(25);
|
||||||
|
expect(payload.price).toBe(99.5);
|
||||||
|
expect(payload.duration_days).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a price of zero, which is a free plan", () => {
|
||||||
|
expect(buildPlanCreatePayload({ ...base, price: 0 }, [], []).price).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults grace to zero rather than leaving it out", () => {
|
||||||
|
// Unset and zero genuinely are the same here — no grace — so it is sent
|
||||||
|
// concretely. On an edit, `undefined` would leave a previous window in
|
||||||
|
// place rather than clearing it.
|
||||||
|
expect(
|
||||||
|
buildPlanCreatePayload({ ...base, grace_period_days: "" }, [], [])
|
||||||
|
.grace_period_days
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a configured grace window", () => {
|
||||||
|
expect(
|
||||||
|
buildPlanCreatePayload({ ...base, grace_period_days: "14" }, [], [])
|
||||||
|
.grace_period_days
|
||||||
|
).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a description somebody cleared", () => {
|
||||||
|
expect(
|
||||||
|
buildPlanCreatePayload({ ...base, description: " " }, [], []).description
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a number field that is not a number", () => {
|
||||||
|
// Browsers usually prevent it; a pasted value or an autofill does not.
|
||||||
|
// `Number("abc")` is NaN, and NaN serialises to `null`, which the API
|
||||||
|
// reads as an explicit "no limit".
|
||||||
|
expect(
|
||||||
|
buildPlanCreatePayload({ ...base, max_users_allowed: "abc" }, [], [])
|
||||||
|
.max_users_allowed
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits the permission selection into the two the API expects", () => {
|
||||||
|
const payload = buildPlanCreatePayload(
|
||||||
|
base,
|
||||||
|
["p", "m"],
|
||||||
|
[{ id: "p" }, { id: "m", module_id: "mod" }]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.access_ids).toEqual(["p"]);
|
||||||
|
expect(payload.module_access_ids).toEqual(["m"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives JSON serialisation without inventing values", () => {
|
||||||
|
// What actually goes on the wire. `undefined` keys are dropped, which is
|
||||||
|
// what "not set" has to mean; a stray `null` would be read as an
|
||||||
|
// explicit clear.
|
||||||
|
const payload = buildPlanCreatePayload(
|
||||||
|
{ ...base, max_users_allowed: "", price: "" },
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const wire = JSON.parse(JSON.stringify(payload));
|
||||||
|
|
||||||
|
expect("max_users_allowed" in wire).toBe(false);
|
||||||
|
expect("price" in wire).toBe(false);
|
||||||
|
expect(wire.grace_period_days).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPlanUpdatePayload", () => {
|
||||||
|
it("sends every field the create payload does", () => {
|
||||||
|
// The two forms drifting is how a field ends up settable on create and
|
||||||
|
// not on edit — which reads as "the save didn't work".
|
||||||
|
const values = {
|
||||||
|
...base,
|
||||||
|
price: "10",
|
||||||
|
duration_days: "30",
|
||||||
|
max_users_allowed: "5",
|
||||||
|
grace_period_days: "7",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Object.keys(buildPlanUpdatePayload(values, [], [])).sort()).toEqual(
|
||||||
|
Object.keys(buildPlanCreatePayload(values, [], [])).sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the grace window through an edit", () => {
|
||||||
|
expect(
|
||||||
|
buildPlanUpdatePayload({ ...base, grace_period_days: "30" }, [], [])
|
||||||
|
.grace_period_days
|
||||||
|
).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { splitAccessIds, type AccessLike } from "./splitAccessIds";
|
||||||
|
import type {
|
||||||
|
SubscriptionPlanCreateRequest,
|
||||||
|
SubscriptionPlanUpdateRequest,
|
||||||
|
} from "./SubscriptionTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns what the plan form holds into what the API expects.
|
||||||
|
*
|
||||||
|
* A plan decides three things that matter and are easy to send wrong: the
|
||||||
|
* permission bound (an upper limit on what roles may grant, since finding S-2),
|
||||||
|
* the seat limit, and the grace window. Each is optional, each has a meaningful
|
||||||
|
* zero, and each has a "not set" that is different from zero:
|
||||||
|
*
|
||||||
|
* - `max_users_allowed` **unset** means unlimited seats. `0` means nobody can be
|
||||||
|
* added. Collapsing them locks a customer out of their own workspace.
|
||||||
|
* - `grace_period_days` **unset** and `0` genuinely are the same thing — no
|
||||||
|
* grace — which is why it defaults to `0` rather than to `undefined`. Sending
|
||||||
|
* `undefined` would leave an existing value in place on an edit.
|
||||||
|
* - `price` of `0` is a free plan, not a missing price.
|
||||||
|
*
|
||||||
|
* The number fields arrive from `<input type="number">`, which yields a string,
|
||||||
|
* and `Number("")` is `0` — so an empty box becomes a real zero unless the empty
|
||||||
|
* case is handled first. That is the specific way this goes wrong.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const optionalNumber = (value: unknown): number | undefined => {
|
||||||
|
if (value === "" || value === null || value === undefined) return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionalText = (value: string | null | undefined): string | undefined => {
|
||||||
|
const text = (value ?? "").trim();
|
||||||
|
return text === "" ? undefined : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanFormValues = {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
price?: number | string | null;
|
||||||
|
duration_days?: number | string | null;
|
||||||
|
max_users_allowed?: number | string | null;
|
||||||
|
grace_period_days?: number | string | null;
|
||||||
|
// Optional in the request type but never genuinely absent in a form: a
|
||||||
|
// checkbox is checked or it is not. Defaulted rather than required so the
|
||||||
|
// form state can be passed straight in.
|
||||||
|
is_public?: boolean;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const common = (values: PlanFormValues, selected: string[], all: AccessLike[]) => {
|
||||||
|
const { accessIds, moduleAccessIds } = splitAccessIds(selected, all);
|
||||||
|
return {
|
||||||
|
name: values.name.trim(),
|
||||||
|
description: optionalText(values.description),
|
||||||
|
price: optionalNumber(values.price),
|
||||||
|
duration_days: optionalNumber(values.duration_days),
|
||||||
|
max_users_allowed: optionalNumber(values.max_users_allowed),
|
||||||
|
// Unset and zero mean the same thing here, so the default is concrete —
|
||||||
|
// and on an edit, `undefined` would leave a previous grace window in
|
||||||
|
// place rather than clearing it.
|
||||||
|
grace_period_days: optionalNumber(values.grace_period_days) ?? 0,
|
||||||
|
is_public: values.is_public ?? true,
|
||||||
|
status: values.status ?? "active",
|
||||||
|
access_ids: accessIds,
|
||||||
|
module_access_ids: moduleAccessIds,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPlanCreatePayload = (
|
||||||
|
values: PlanFormValues,
|
||||||
|
selectedAccessIds: string[],
|
||||||
|
allAccesses: AccessLike[]
|
||||||
|
): SubscriptionPlanCreateRequest => common(values, selectedAccessIds, allAccesses);
|
||||||
|
|
||||||
|
export const buildPlanUpdatePayload = (
|
||||||
|
values: PlanFormValues,
|
||||||
|
selectedAccessIds: string[],
|
||||||
|
allAccesses: AccessLike[]
|
||||||
|
): SubscriptionPlanUpdateRequest => common(values, selectedAccessIds, allAccesses);
|
||||||
@@ -11,6 +11,7 @@ import { subscriptionsApi } from "../SubscriptionsApi";
|
|||||||
import type { SubscriptionPlanCreateRequest } from "../SubscriptionTypes";
|
import type { SubscriptionPlanCreateRequest } from "../SubscriptionTypes";
|
||||||
import type { RoleAccess } from "../../roles/RolesTypes";
|
import type { RoleAccess } from "../../roles/RolesTypes";
|
||||||
import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector";
|
import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector";
|
||||||
|
import { buildPlanCreatePayload } from "../buildPlanPayload";
|
||||||
|
|
||||||
const AddSubscriptions = () => {
|
const AddSubscriptions = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -21,6 +22,7 @@ const AddSubscriptions = () => {
|
|||||||
price: undefined,
|
price: undefined,
|
||||||
duration_days: undefined,
|
duration_days: undefined,
|
||||||
max_users_allowed: undefined,
|
max_users_allowed: undefined,
|
||||||
|
grace_period_days: 0,
|
||||||
is_public: true,
|
is_public: true,
|
||||||
status: "active",
|
status: "active",
|
||||||
access_ids: [],
|
access_ids: [],
|
||||||
@@ -69,42 +71,17 @@ const AddSubscriptions = () => {
|
|||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const splitAccessIds = (ids: string[]) => {
|
|
||||||
const accessIds: string[] = [];
|
|
||||||
const moduleAccessIds: string[] = [];
|
|
||||||
const accessMap = new Map(allAccesses.map((a) => [a.id, a]));
|
|
||||||
|
|
||||||
ids.forEach((id) => {
|
|
||||||
const access = accessMap.get(id);
|
|
||||||
if (access?.module_id) {
|
|
||||||
moduleAccessIds.push(id);
|
|
||||||
} else {
|
|
||||||
accessIds.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { accessIds, moduleAccessIds };
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { accessIds, moduleAccessIds } = splitAccessIds(selectedAccessIds);
|
const payload = buildPlanCreatePayload(
|
||||||
|
formData,
|
||||||
const payload: SubscriptionPlanCreateRequest = {
|
selectedAccessIds,
|
||||||
name: formData.name.trim(),
|
allAccesses
|
||||||
description: formData.description?.trim() || undefined,
|
);
|
||||||
price: formData.price ? Number(formData.price) : undefined,
|
|
||||||
duration_days: formData.duration_days ? Number(formData.duration_days) : undefined,
|
|
||||||
max_users_allowed: formData.max_users_allowed ?? undefined,
|
|
||||||
is_public: formData.is_public,
|
|
||||||
status: formData.status,
|
|
||||||
access_ids: accessIds,
|
|
||||||
module_access_ids: moduleAccessIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
await subscriptionsApi.create(payload);
|
await subscriptionsApi.create(payload);
|
||||||
navigate("/subscriptions");
|
navigate("/subscriptions");
|
||||||
@@ -189,6 +166,22 @@ const AddSubscriptions = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* How long a lapsed workspace stays read-only before it is locked
|
||||||
|
out. Zero keeps the old behaviour, so an existing plan does not
|
||||||
|
change because the field appeared. */}
|
||||||
|
<CustomInput
|
||||||
|
label="Grace Period (Days)"
|
||||||
|
name="grace_period_days"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={formData.grace_period_days ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
grace_period_days: e.target.value ? Number(e.target.value) : 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CustomInput
|
<CustomInput
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from "../../../components/custom/CustomColumnFilter.utils";
|
} from "../../../components/custom/CustomColumnFilter.utils";
|
||||||
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
||||||
import { formatDate } from "../../../lib/dateFormat";
|
import { formatDate } from "../../../lib/dateFormat";
|
||||||
import type { SubscriptionPlan, SubscriptionPlanUpdateRequest } from "../SubscriptionTypes";
|
import type { SubscriptionPlan } from "../SubscriptionTypes";
|
||||||
import type { RoleAccess } from "../../roles/RolesTypes";
|
import type { RoleAccess } from "../../roles/RolesTypes";
|
||||||
import { subscriptionsApi } from "../SubscriptionsApi";
|
import { subscriptionsApi } from "../SubscriptionsApi";
|
||||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||||
resolveStoredTablePageSize,
|
resolveStoredTablePageSize,
|
||||||
} from "../../../lib/tablePageSize";
|
} from "../../../lib/tablePageSize";
|
||||||
|
import { buildPlanUpdatePayload } from "../buildPlanPayload";
|
||||||
|
|
||||||
const formatPrice = (price?: number | null) => {
|
const formatPrice = (price?: number | null) => {
|
||||||
if (price == null) return "-";
|
if (price == null) return "-";
|
||||||
@@ -50,6 +51,7 @@ interface EditFormState {
|
|||||||
price: number | undefined;
|
price: number | undefined;
|
||||||
duration_days: number | undefined;
|
duration_days: number | undefined;
|
||||||
max_users_allowed: number | undefined;
|
max_users_allowed: number | undefined;
|
||||||
|
grace_period_days: number | undefined;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,7 @@ const AllSubscriptions = () => {
|
|||||||
price: undefined,
|
price: undefined,
|
||||||
duration_days: undefined,
|
duration_days: undefined,
|
||||||
max_users_allowed: undefined,
|
max_users_allowed: undefined,
|
||||||
|
grace_period_days: 0,
|
||||||
is_public: true,
|
is_public: true,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
@@ -223,26 +226,6 @@ const AllSubscriptions = () => {
|
|||||||
return counts;
|
return counts;
|
||||||
}, [allPlansForCounts]);
|
}, [allPlansForCounts]);
|
||||||
|
|
||||||
const splitAccessIds = useCallback(
|
|
||||||
(ids: string[]) => {
|
|
||||||
const accessIds: string[] = [];
|
|
||||||
const moduleAccessIds: string[] = [];
|
|
||||||
const accessMap = new Map(allAccesses.map((a) => [a.id, a]));
|
|
||||||
|
|
||||||
ids.forEach((id) => {
|
|
||||||
const access = accessMap.get(id);
|
|
||||||
if (access?.module_id) {
|
|
||||||
moduleAccessIds.push(id);
|
|
||||||
} else {
|
|
||||||
accessIds.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { accessIds, moduleAccessIds };
|
|
||||||
},
|
|
||||||
[allAccesses]
|
|
||||||
);
|
|
||||||
|
|
||||||
const openView = useCallback(async (plan: SubscriptionPlan) => {
|
const openView = useCallback(async (plan: SubscriptionPlan) => {
|
||||||
setSelectedPlan(plan);
|
setSelectedPlan(plan);
|
||||||
setViewAccessIds([]);
|
setViewAccessIds([]);
|
||||||
@@ -266,6 +249,7 @@ const AllSubscriptions = () => {
|
|||||||
price: plan.price ?? undefined,
|
price: plan.price ?? undefined,
|
||||||
duration_days: plan.duration_days ?? undefined,
|
duration_days: plan.duration_days ?? undefined,
|
||||||
max_users_allowed: plan.max_users_allowed ?? undefined,
|
max_users_allowed: plan.max_users_allowed ?? undefined,
|
||||||
|
grace_period_days: plan.grace_period_days ?? 0,
|
||||||
is_public: plan.is_public,
|
is_public: plan.is_public,
|
||||||
status: plan.status,
|
status: plan.status,
|
||||||
});
|
});
|
||||||
@@ -322,19 +306,7 @@ const AllSubscriptions = () => {
|
|||||||
setEditError("");
|
setEditError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { accessIds, moduleAccessIds } = splitAccessIds(editAccessIds);
|
const payload = buildPlanUpdatePayload(editForm, editAccessIds, allAccesses);
|
||||||
|
|
||||||
const payload: SubscriptionPlanUpdateRequest = {
|
|
||||||
name: editForm.name.trim(),
|
|
||||||
description: editForm.description.trim() || undefined,
|
|
||||||
price: editForm.price ? Number(editForm.price) : undefined,
|
|
||||||
duration_days: editForm.duration_days ? Number(editForm.duration_days) : undefined,
|
|
||||||
max_users_allowed: editForm.max_users_allowed ?? undefined,
|
|
||||||
is_public: editForm.is_public,
|
|
||||||
status: editForm.status,
|
|
||||||
access_ids: accessIds,
|
|
||||||
module_access_ids: moduleAccessIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
const updated = await subscriptionsApi.update(selectedPlan.id, payload);
|
const updated = await subscriptionsApi.update(selectedPlan.id, payload);
|
||||||
setPlans((prev) =>
|
setPlans((prev) =>
|
||||||
@@ -773,6 +745,19 @@ const AllSubscriptions = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label="Grace Period (Days)"
|
||||||
|
name="grace_period_days"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={editForm.grace_period_days ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
grace_period_days: e.target.value ? Number(e.target.value) : 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CustomInput
|
<CustomInput
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { splitAccessIds } from "./splitAccessIds";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picker shows platform and module permissions as one list, because to the
|
||||||
|
* person granting them they are one list. The server keeps them in two tables,
|
||||||
|
* so something has to sort them — and getting it wrong is silent.
|
||||||
|
*
|
||||||
|
* An id sent in the wrong array does not error. The server looks it up in the
|
||||||
|
* table it was told to use, does not find it, and drops it. The permission never
|
||||||
|
* takes effect and the console goes on showing it as granted, which is a support
|
||||||
|
* ticket that starts "I gave them access and it doesn't work".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const platform = (id: string) => ({ id });
|
||||||
|
const ofModule = (id: string) => ({ id, module_id: "mod-1" });
|
||||||
|
|
||||||
|
describe("splitAccessIds", () => {
|
||||||
|
it("sends a platform permission to the platform list", () => {
|
||||||
|
const result = splitAccessIds(["a"], [platform("a")]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ accessIds: ["a"], moduleAccessIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a module permission to the module list", () => {
|
||||||
|
const result = splitAccessIds(["m"], [ofModule("m")]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ accessIds: [], moduleAccessIds: ["m"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts a mixed selection", () => {
|
||||||
|
const result = splitAccessIds(
|
||||||
|
["a", "m", "b", "n"],
|
||||||
|
[platform("a"), ofModule("m"), platform("b"), ofModule("n")]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.accessIds).toEqual(["a", "b"]);
|
||||||
|
expect(result.moduleAccessIds).toEqual(["m", "n"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the order the picker gave", () => {
|
||||||
|
// Not important to the server, but a diff between two saves that differ
|
||||||
|
// only in ordering is a diff somebody has to read and dismiss.
|
||||||
|
const result = splitAccessIds(
|
||||||
|
["c", "a", "b"],
|
||||||
|
[platform("a"), platform("b"), platform("c")]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.accessIds).toEqual(["c", "a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a null module_id as a platform permission", () => {
|
||||||
|
// The API returns `module_id: null` rather than omitting it, and `null`
|
||||||
|
// is falsy — but only by luck. Stated so it stays true.
|
||||||
|
const result = splitAccessIds(["a"], [{ id: "a", module_id: null }]);
|
||||||
|
|
||||||
|
expect(result.accessIds).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts an id it does not recognise with the platform permissions", () => {
|
||||||
|
// The conservative half: the server ignores an id that is not in
|
||||||
|
// `plan_accesses`, whereas one wrongly placed among module permissions
|
||||||
|
// could match a real module permission and grant something unintended.
|
||||||
|
const result = splitAccessIds(["ghost"], []);
|
||||||
|
|
||||||
|
expect(result).toEqual({ accessIds: ["ghost"], moduleAccessIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects nothing from nothing", () => {
|
||||||
|
expect(splitAccessIds([], [platform("a")])).toEqual({
|
||||||
|
accessIds: [],
|
||||||
|
moduleAccessIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invent entries for permissions that were not selected", () => {
|
||||||
|
// The picker's full list is passed as the second argument; only the
|
||||||
|
// first is a selection. Confusing the two would grant everything.
|
||||||
|
const result = splitAccessIds(
|
||||||
|
["a"],
|
||||||
|
[platform("a"), platform("b"), ofModule("m")]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.accessIds).toEqual(["a"]);
|
||||||
|
expect(result.moduleAccessIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* One flat list of permission ids, split into the two the API expects.
|
||||||
|
*
|
||||||
|
* The picker shows platform permissions and module permissions together,
|
||||||
|
* because to an administrator granting them they are one list. The server keeps
|
||||||
|
* them in two tables — `plan_accesses` and `plan_module_accesses` — so something
|
||||||
|
* has to sort them, and the only thing distinguishing them is whether the
|
||||||
|
* permission belongs to a module.
|
||||||
|
*
|
||||||
|
* Getting it wrong is quiet. An id sent in the wrong array does not error: the
|
||||||
|
* server looks it up in the table it was told to use, does not find it, and
|
||||||
|
* drops it. The permission simply never takes effect, and the console goes on
|
||||||
|
* showing it as granted.
|
||||||
|
*
|
||||||
|
* Extracted because it existed twice — once in the create form and once in the
|
||||||
|
* edit form — with no test on either. Two copies of a rule is how the two
|
||||||
|
* copies drift, which is the same defect this codebase has now had in four
|
||||||
|
* other places.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AccessLike = {
|
||||||
|
id: string;
|
||||||
|
/** Present only on permissions belonging to a module. */
|
||||||
|
module_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SplitAccessIds = {
|
||||||
|
accessIds: string[];
|
||||||
|
moduleAccessIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const splitAccessIds = (
|
||||||
|
ids: string[],
|
||||||
|
accesses: AccessLike[]
|
||||||
|
): SplitAccessIds => {
|
||||||
|
const accessIds: string[] = [];
|
||||||
|
const moduleAccessIds: string[] = [];
|
||||||
|
const byId = new Map(accesses.map((access) => [access.id, access]));
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const access = byId.get(id);
|
||||||
|
// An id the picker does not know about goes with the platform
|
||||||
|
// permissions, which is where an unknown id was always sent. It is the
|
||||||
|
// conservative half of the split: the server ignores an id that is not
|
||||||
|
// in `accesses`, whereas one wrongly placed among module permissions
|
||||||
|
// could match a real module permission id.
|
||||||
|
if (access?.module_id) {
|
||||||
|
moduleAccessIds.push(id);
|
||||||
|
} else {
|
||||||
|
accessIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accessIds, moduleAccessIds };
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ export type Tenant = {
|
|||||||
tenant_name: string;
|
tenant_name: string;
|
||||||
tenant_domain: string;
|
tenant_domain: string;
|
||||||
tenant_logo_url?: string | null;
|
tenant_logo_url?: string | null;
|
||||||
|
/** Who to tell before the subscription lapses. */
|
||||||
|
billing_email?: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
plan_id?: string | null;
|
plan_id?: string | null;
|
||||||
start_date?: string | null;
|
start_date?: string | null;
|
||||||
@@ -24,6 +26,7 @@ export type TenantCreateRequest = {
|
|||||||
tenant_name: string;
|
tenant_name: string;
|
||||||
tenant_domain: string;
|
tenant_domain: string;
|
||||||
tenant_logo_url?: string;
|
tenant_logo_url?: string;
|
||||||
|
billing_email?: string;
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
@@ -35,6 +38,8 @@ export type TenantUpdateRequest = {
|
|||||||
tenant_name?: string;
|
tenant_name?: string;
|
||||||
tenant_domain?: string;
|
tenant_domain?: string;
|
||||||
tenant_logo_url?: string | null;
|
tenant_logo_url?: string | null;
|
||||||
|
/** Who to tell before the subscription lapses. */
|
||||||
|
billing_email?: string | null;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
plan_id?: string;
|
plan_id?: string;
|
||||||
start_date?: string | null;
|
start_date?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildTenantCreatePayload,
|
||||||
|
buildTenantUpdatePayload,
|
||||||
|
} from "./buildTenantPayload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspace form. `billing_email` is the field worth guarding: it is the
|
||||||
|
* only address the platform will ever write to about a subscription, and a
|
||||||
|
* workspace with an empty string there looks configured and gets no warning
|
||||||
|
* before it lapses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
tenantName: " Alpha Corp ",
|
||||||
|
tenantDomain: " alpha.example.com ",
|
||||||
|
planId: "plan-1",
|
||||||
|
status: "ACTIVE" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildTenantCreatePayload", () => {
|
||||||
|
it("trims the name and domain", () => {
|
||||||
|
const payload = buildTenantCreatePayload(base);
|
||||||
|
|
||||||
|
expect(payload.tenant_name).toBe("Alpha Corp");
|
||||||
|
expect(payload.tenant_domain).toBe("alpha.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a billing address", () => {
|
||||||
|
expect(
|
||||||
|
buildTenantCreatePayload({ ...base, billingEmail: " finance@x.com " })
|
||||||
|
.billing_email
|
||||||
|
).toBe("finance@x.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a cleared billing address as absent, not as an empty string", () => {
|
||||||
|
// `""` is a value the API will store. A workspace whose billing_email is
|
||||||
|
// `""` looks configured and is not: the notice worker records "nobody to
|
||||||
|
// tell" and sends nothing.
|
||||||
|
const payload = buildTenantCreatePayload({ ...base, billingEmail: " " });
|
||||||
|
|
||||||
|
expect(payload.billing_email).toBeUndefined();
|
||||||
|
expect("billing_email" in JSON.parse(JSON.stringify(payload))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves optional dates out rather than sending empty strings", () => {
|
||||||
|
const payload = buildTenantCreatePayload({
|
||||||
|
...base,
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.start_date).toBeUndefined();
|
||||||
|
expect(payload.end_date).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends dates that were filled in", () => {
|
||||||
|
const payload = buildTenantCreatePayload({
|
||||||
|
...base,
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-12-31",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.start_date).toBe("2026-01-01");
|
||||||
|
expect(payload.end_date).toBe("2026-12-31");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a module assignment with no environment chosen", () => {
|
||||||
|
// An assignment with an empty slug is not an assignment. The server now
|
||||||
|
// refuses it — and used to accept it, silently falling back to the
|
||||||
|
// module's default, which is production.
|
||||||
|
const payload = buildTenantCreatePayload({
|
||||||
|
...base,
|
||||||
|
moduleEnvironments: [
|
||||||
|
{ module_id: "a", environment_slug: "prod" },
|
||||||
|
{ module_id: "b", environment_slug: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.module_environments).toEqual([
|
||||||
|
{ module_id: "a", environment_slug: "prod" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends an empty list when nothing was assigned", () => {
|
||||||
|
expect(buildTenantCreatePayload(base).module_environments).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTenantUpdatePayload", () => {
|
||||||
|
const editBase = {
|
||||||
|
tenantName: "Alpha Corp",
|
||||||
|
tenantDomain: "alpha.example.com",
|
||||||
|
tenantLogoUrl: "",
|
||||||
|
billingEmail: "",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("clears a field with null rather than leaving it alone", () => {
|
||||||
|
// The difference between the two forms. On create, "not set" is
|
||||||
|
// `undefined` and the key is dropped. On edit, clearing a box means
|
||||||
|
// *remove it* — `undefined` would leave the old value in place and look
|
||||||
|
// like the save had not worked.
|
||||||
|
const payload = buildTenantUpdatePayload(editBase);
|
||||||
|
|
||||||
|
expect(payload.tenant_logo_url).toBeNull();
|
||||||
|
expect(payload.billing_email).toBeNull();
|
||||||
|
|
||||||
|
const wire = JSON.parse(JSON.stringify(payload));
|
||||||
|
expect(wire.billing_email).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can clear a billing address that was set", () => {
|
||||||
|
// The edit form used `|| undefined` here while using `|| null` for the
|
||||||
|
// logo two lines above, so a billing address could be set and never
|
||||||
|
// removed: clearing the box dropped the key and the server left the old
|
||||||
|
// value in place. Surfaced by extracting the two forms' rules into one
|
||||||
|
// place and reading them side by side.
|
||||||
|
expect(buildTenantUpdatePayload(editBase).billing_email).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the dates the same way", () => {
|
||||||
|
expect(buildTenantUpdatePayload(editBase).start_date).toBeNull();
|
||||||
|
expect(buildTenantUpdatePayload(editBase).end_date).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a billing address that was filled in", () => {
|
||||||
|
expect(
|
||||||
|
buildTenantUpdatePayload({ ...editBase, billingEmail: " ops@x.com " })
|
||||||
|
.billing_email
|
||||||
|
).toBe("ops@x.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the active flag, including when false", () => {
|
||||||
|
// `false` is a real value and the one that matters — a workspace being
|
||||||
|
// deactivated. Dropping it as falsy would make deactivation impossible.
|
||||||
|
expect(
|
||||||
|
buildTenantUpdatePayload({ ...editBase, isActive: false }).is_active
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the plan alone when none was chosen", () => {
|
||||||
|
// Unlike the logo, an empty plan on an edit form means "unchanged", not
|
||||||
|
// "remove the plan" — there is no way to have no plan through this form.
|
||||||
|
expect(buildTenantUpdatePayload(editBase).plan_id).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a plan that was chosen", () => {
|
||||||
|
expect(
|
||||||
|
buildTenantUpdatePayload({ ...editBase, planId: "plan-2" }).plan_id
|
||||||
|
).toBe("plan-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type {
|
||||||
|
ModuleEnvironmentAssignment,
|
||||||
|
TenantCreateRequest,
|
||||||
|
TenantStatus,
|
||||||
|
TenantUpdateRequest,
|
||||||
|
} from "./TenantsTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns what the workspace form holds into what the API expects.
|
||||||
|
*
|
||||||
|
* Extracted so it can be tested. A form that quietly stops sending a field is
|
||||||
|
* the defect this codebase has already had twice on the server side —
|
||||||
|
* `provisioning_endpoint` and `grace_period_days` were both accepted by a schema
|
||||||
|
* and dropped before they reached the model — and the console half is worse,
|
||||||
|
* because it compiles, renders, saves, and reports success.
|
||||||
|
*
|
||||||
|
* The rules, once, rather than in each form:
|
||||||
|
*
|
||||||
|
* - **Trim, then treat empty as absent.** A field somebody typed into and
|
||||||
|
* cleared should read as "not set", not as the empty string. `""` is a value
|
||||||
|
* the API will happily store, and a workspace whose `billing_email` is `""`
|
||||||
|
* gets no warning before it lapses while looking configured.
|
||||||
|
* - **`undefined`, never `null`, on create.** The request is serialised with
|
||||||
|
* `JSON.stringify`, which drops `undefined` keys and sends `null` ones. On
|
||||||
|
* create those mean the same thing; on update they do not.
|
||||||
|
* - **On update, `null` is how you clear something.** `undefined` leaves it
|
||||||
|
* alone. Collapsing the two would make a field impossible to unset once set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const trimmed = (value: string | null | undefined): string | undefined => {
|
||||||
|
const text = (value ?? "").trim();
|
||||||
|
return text === "" ? undefined : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantFormValues = {
|
||||||
|
tenantName: string;
|
||||||
|
tenantDomain: string;
|
||||||
|
tenantLogoUrl?: string;
|
||||||
|
billingEmail?: string;
|
||||||
|
planId: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
moduleEnvironments?: ModuleEnvironmentAssignment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildTenantCreatePayload = (
|
||||||
|
values: TenantFormValues
|
||||||
|
): TenantCreateRequest => ({
|
||||||
|
tenant_name: values.tenantName.trim(),
|
||||||
|
tenant_domain: values.tenantDomain.trim(),
|
||||||
|
tenant_logo_url: trimmed(values.tenantLogoUrl),
|
||||||
|
billing_email: trimmed(values.billingEmail),
|
||||||
|
plan_id: values.planId,
|
||||||
|
start_date: trimmed(values.startDate),
|
||||||
|
end_date: trimmed(values.endDate),
|
||||||
|
status: values.status,
|
||||||
|
// An assignment with no environment chosen is not an assignment. Sending it
|
||||||
|
// would pin the workspace to an empty slug, which the server now refuses —
|
||||||
|
// and used to accept, silently falling back to production.
|
||||||
|
module_environments: (values.moduleEnvironments ?? []).filter(
|
||||||
|
(assignment) => assignment.environment_slug
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TenantEditValues = {
|
||||||
|
tenantName: string;
|
||||||
|
tenantDomain: string;
|
||||||
|
tenantLogoUrl: string;
|
||||||
|
billingEmail: string;
|
||||||
|
isActive: boolean;
|
||||||
|
planId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
status?: TenantStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildTenantUpdatePayload = (
|
||||||
|
values: TenantEditValues
|
||||||
|
): TenantUpdateRequest => ({
|
||||||
|
tenant_name: values.tenantName.trim(),
|
||||||
|
tenant_domain: values.tenantDomain.trim(),
|
||||||
|
// `null` rather than `undefined`: on an edit form, clearing the logo means
|
||||||
|
// remove it. `undefined` would leave the old one in place and look like the
|
||||||
|
// save had not worked.
|
||||||
|
tenant_logo_url: trimmed(values.tenantLogoUrl) ?? null,
|
||||||
|
billing_email: trimmed(values.billingEmail) ?? null,
|
||||||
|
is_active: values.isActive,
|
||||||
|
// A plan cannot be removed through this form — an empty selection means
|
||||||
|
// "unchanged", not "no plan" — so this one stays undefined.
|
||||||
|
plan_id: values.planId || undefined,
|
||||||
|
start_date: trimmed(values.startDate) ?? null,
|
||||||
|
end_date: trimmed(values.endDate) ?? null,
|
||||||
|
status: values.status || undefined,
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { CustomButton, CustomDatePicker, CustomInput, CustomDropdown } from "../
|
|||||||
import CustomBackButton from "../../../components/custom/CustomBackButton";
|
import CustomBackButton from "../../../components/custom/CustomBackButton";
|
||||||
import { CustomLoader } from "../../../components/custom";
|
import { CustomLoader } from "../../../components/custom";
|
||||||
import { tenantsApi } from "../TenantsApi";
|
import { tenantsApi } from "../TenantsApi";
|
||||||
import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes";
|
import type { Tenant, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes";
|
||||||
import { useAuth } from "../../../context/AuthContext";
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
||||||
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
||||||
@@ -12,6 +12,7 @@ import type { RoleAccess } from "../../roles/RolesTypes";
|
|||||||
import { adminModuleApi } from "../../modules/admin/AdminModuleApi";
|
import { adminModuleApi } from "../../modules/admin/AdminModuleApi";
|
||||||
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
|
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { buildTenantCreatePayload } from "../buildTenantPayload";
|
||||||
|
|
||||||
const toIsoDate = (value: Date) => value.toISOString().slice(0, 10);
|
const toIsoDate = (value: Date) => value.toISOString().slice(0, 10);
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const AddTenants = () => {
|
|||||||
const [tenantName, setTenantName] = useState("");
|
const [tenantName, setTenantName] = useState("");
|
||||||
const [tenantDomain, setTenantDomain] = useState("");
|
const [tenantDomain, setTenantDomain] = useState("");
|
||||||
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
|
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
|
||||||
|
const [billingEmail, setBillingEmail] = useState("");
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState("");
|
const [selectedPlanId, setSelectedPlanId] = useState("");
|
||||||
const [startDate, setStartDate] = useState(today);
|
const [startDate, setStartDate] = useState(today);
|
||||||
const [endDate, setEndDate] = useState("");
|
const [endDate, setEndDate] = useState("");
|
||||||
@@ -212,16 +214,17 @@ const AddTenants = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: TenantCreateRequest = {
|
const payload = buildTenantCreatePayload({
|
||||||
tenant_name: tenantName.trim(),
|
tenantName,
|
||||||
tenant_domain: tenantDomain.trim(),
|
tenantDomain,
|
||||||
tenant_logo_url: tenantLogoUrl.trim() || undefined,
|
tenantLogoUrl,
|
||||||
plan_id: selectedPlanId,
|
billingEmail,
|
||||||
start_date: startDate || undefined,
|
planId: selectedPlanId,
|
||||||
end_date: endDate || undefined,
|
startDate,
|
||||||
|
endDate,
|
||||||
status: tenantStatus,
|
status: tenantStatus,
|
||||||
module_environments: moduleEnvAssignments.filter((a) => a.environment_slug),
|
moduleEnvironments: moduleEnvAssignments,
|
||||||
};
|
});
|
||||||
|
|
||||||
await tenantsApi.create(payload);
|
await tenantsApi.create(payload);
|
||||||
navigate("/tenants");
|
navigate("/tenants");
|
||||||
@@ -321,6 +324,19 @@ const AddTenants = () => {
|
|||||||
onChange={(e) => setTenantLogoUrl(e.target.value)}
|
onChange={(e) => setTenantLogoUrl(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* The only address the platform will ever write to about this
|
||||||
|
workspace's subscription. A workspace without one gets no warning
|
||||||
|
before it lapses — the notice worker logs that gap, but nobody
|
||||||
|
reads a log on a customer's behalf. */}
|
||||||
|
<CustomInput
|
||||||
|
label="Billing Email"
|
||||||
|
name="billing_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="finance@example.com"
|
||||||
|
value={billingEmail}
|
||||||
|
onChange={(e) => setBillingEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 pt-6">
|
<div className="border-t border-gray-200 pt-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
|
||||||
<CustomDropdown
|
<CustomDropdown
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ import {
|
|||||||
} from "../../../components/custom/CustomColumnFilter.utils";
|
} from "../../../components/custom/CustomColumnFilter.utils";
|
||||||
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
||||||
import { formatDate } from "../../../lib/dateFormat";
|
import { formatDate } from "../../../lib/dateFormat";
|
||||||
import type { Tenant, TenantStatus, TenantUpdateRequest } from "../TenantsTypes";
|
import type { Tenant, TenantStatus } from "../TenantsTypes";
|
||||||
import { tenantsApi } from "../TenantsApi";
|
import { tenantsApi } from "../TenantsApi";
|
||||||
import { useAuth } from "../../../context/AuthContext";
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
||||||
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
||||||
|
import { buildTenantUpdatePayload } from "../buildTenantPayload";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||||
@@ -70,6 +71,7 @@ interface EditFormState {
|
|||||||
tenant_name: string;
|
tenant_name: string;
|
||||||
tenant_domain: string;
|
tenant_domain: string;
|
||||||
tenant_logo_url: string;
|
tenant_logo_url: string;
|
||||||
|
billing_email: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
@@ -92,6 +94,7 @@ const AllTenants = () => {
|
|||||||
tenant_name: "",
|
tenant_name: "",
|
||||||
tenant_domain: "",
|
tenant_domain: "",
|
||||||
tenant_logo_url: "",
|
tenant_logo_url: "",
|
||||||
|
billing_email: "",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
plan_id: "",
|
plan_id: "",
|
||||||
start_date: "",
|
start_date: "",
|
||||||
@@ -318,6 +321,7 @@ const AllTenants = () => {
|
|||||||
tenant_name: tenant.tenant_name,
|
tenant_name: tenant.tenant_name,
|
||||||
tenant_domain: tenant.tenant_domain,
|
tenant_domain: tenant.tenant_domain,
|
||||||
tenant_logo_url: tenant.tenant_logo_url ?? "",
|
tenant_logo_url: tenant.tenant_logo_url ?? "",
|
||||||
|
billing_email: tenant.billing_email ?? "",
|
||||||
is_active: tenant.is_active,
|
is_active: tenant.is_active,
|
||||||
plan_id: tenant.plan_id ?? "",
|
plan_id: tenant.plan_id ?? "",
|
||||||
start_date: tenant.start_date ?? "",
|
start_date: tenant.start_date ?? "",
|
||||||
@@ -374,16 +378,17 @@ const AllTenants = () => {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: TenantUpdateRequest = {
|
const payload = buildTenantUpdatePayload({
|
||||||
tenant_name: editForm.tenant_name.trim(),
|
tenantName: editForm.tenant_name,
|
||||||
tenant_domain: editForm.tenant_domain.trim(),
|
tenantDomain: editForm.tenant_domain,
|
||||||
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
|
tenantLogoUrl: editForm.tenant_logo_url,
|
||||||
is_active: editForm.is_active,
|
billingEmail: editForm.billing_email,
|
||||||
plan_id: editForm.plan_id || undefined,
|
isActive: editForm.is_active,
|
||||||
start_date: editForm.start_date || null,
|
planId: editForm.plan_id,
|
||||||
end_date: editForm.end_date || null,
|
startDate: editForm.start_date,
|
||||||
|
endDate: editForm.end_date,
|
||||||
status: editForm.status,
|
status: editForm.status,
|
||||||
};
|
});
|
||||||
|
|
||||||
const updatedTenant = await tenantsApi.update(tenantId, payload);
|
const updatedTenant = await tenantsApi.update(tenantId, payload);
|
||||||
|
|
||||||
@@ -841,6 +846,19 @@ const AllTenants = () => {
|
|||||||
onChange={handleEditChange}
|
onChange={handleEditChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* The only address the platform will ever write to about this
|
||||||
|
workspace's subscription. A workspace without one gets no warning
|
||||||
|
before it lapses — the notice worker logs that gap, but nobody
|
||||||
|
reads a log on a customer's behalf. */}
|
||||||
|
<CustomInput
|
||||||
|
label="Billing Email"
|
||||||
|
name="billing_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="finance@example.com"
|
||||||
|
value={editForm.billing_email}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex mt-6 items-center">
|
<div className="flex mt-6 items-center">
|
||||||
<CustomCheckBox
|
<CustomCheckBox
|
||||||
label="Active"
|
label="Active"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { tenantsApi } from "../../tenants/TenantsApi";
|
|||||||
import type { Tenant } from "../../tenants/TenantsTypes";
|
import type { Tenant } from "../../tenants/TenantsTypes";
|
||||||
import { useDebounce } from "../../../components/hooks/useDebounce";
|
import { useDebounce } from "../../../components/hooks/useDebounce";
|
||||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
||||||
|
import DeletedUsersPanel from "./DeletedUsersPanel";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||||
@@ -90,6 +91,11 @@ const AllUsers = () => {
|
|||||||
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
|
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
|
||||||
const [roleFilter, setRoleFilter] = useState<string[]>([]);
|
const [roleFilter, setRoleFilter] = useState<string[]>([]);
|
||||||
const [totalRows, setTotalRows] = useState(0);
|
const [totalRows, setTotalRows] = useState(0);
|
||||||
|
// Bumped when a restored account should reappear in the list. A counter
|
||||||
|
// rather than calling `loadUsers` from outside the effect: the effect already
|
||||||
|
// owns every parameter the query depends on, and a second caller would drift
|
||||||
|
// from it the first time a filter is added.
|
||||||
|
const [refreshToken, setRefreshToken] = useState(0);
|
||||||
const [activeSort, setActiveSort] = useState<{
|
const [activeSort, setActiveSort] = useState<{
|
||||||
column: "first_name" | "email" | "status" | "tenant_id" | "role_id" | null;
|
column: "first_name" | "email" | "status" | "tenant_id" | "role_id" | null;
|
||||||
direction: ColumnSortDirection;
|
direction: ColumnSortDirection;
|
||||||
@@ -211,6 +217,7 @@ const AllUsers = () => {
|
|||||||
tenantFilter,
|
tenantFilter,
|
||||||
roleFilter,
|
roleFilter,
|
||||||
activeSort,
|
activeSort,
|
||||||
|
refreshToken,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Reset page on search/filter change
|
// Reset page on search/filter change
|
||||||
@@ -592,9 +599,17 @@ const AllUsers = () => {
|
|||||||
<p className="text-sm text-[var(--text-secondary)]">Manage system users, their roles, and account status.</p>
|
<p className="text-sm text-[var(--text-secondary)]">Manage system users, their roles, and account status.</p>
|
||||||
</div>
|
</div>
|
||||||
<ProtectedComponent requiredAccess="admin.user.create">
|
<ProtectedComponent requiredAccess="admin.user.create">
|
||||||
<Link to="/users/add">
|
<div className="flex flex-wrap gap-2">
|
||||||
<CustomButton variant="primary">+ {t('add')}</CustomButton>
|
{/* Inviting is the primary action now: it is the one where the
|
||||||
</Link>
|
person sets their own password, so nobody else ever knows it.
|
||||||
|
Adding directly stays available and stays secondary. */}
|
||||||
|
<Link to="/users/invitations">
|
||||||
|
<CustomButton variant="primary">{t('invite', 'Invite')}</CustomButton>
|
||||||
|
</Link>
|
||||||
|
<Link to="/users/add">
|
||||||
|
<CustomButton variant="outlined">+ {t('add')}</CustomButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</ProtectedComponent>
|
</ProtectedComponent>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -627,6 +642,13 @@ const AllUsers = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Below the live list and collapsed by default: this answers "we removed
|
||||||
|
the wrong person this morning", and putting it above would make the
|
||||||
|
ordinary case read as an afterthought. */}
|
||||||
|
<ProtectedComponent requiredAccess="admin.user.create">
|
||||||
|
<DeletedUsersPanel onRestored={() => setRefreshToken((n) => n + 1)} />
|
||||||
|
</ProtectedComponent>
|
||||||
|
|
||||||
{/* View Modal */}
|
{/* View Modal */}
|
||||||
<CustomModal
|
<CustomModal
|
||||||
isOpen={isViewOpen}
|
isOpen={isViewOpen}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChevronDown, ChevronRight, Undo2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { CustomButton, CustomLoader } from "../../../components/custom";
|
||||||
|
import { apiClient } from "../../../lib/apiClient";
|
||||||
|
import { formatDate } from "../../../lib/dateFormat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recently deleted accounts, and the way back.
|
||||||
|
*
|
||||||
|
* Deleting is soft now: the row stays so the record of who the account was
|
||||||
|
* survives, and the address is released so the same person can be added
|
||||||
|
* elsewhere. None of that is any use if a deleted account cannot be found —
|
||||||
|
* restoring by id alone would mean already knowing an identifier no screen
|
||||||
|
* shows.
|
||||||
|
*
|
||||||
|
* **Collapsed by default.** This is the answer to "we removed the wrong person
|
||||||
|
* this morning", not a list anybody needs to look at daily, and putting it above
|
||||||
|
* the live users would make the ordinary case read as an afterthought.
|
||||||
|
*
|
||||||
|
* Restoring can legitimately fail — somebody may have taken the address since,
|
||||||
|
* or the last seat may have gone — so the failure is shown rather than swallowed
|
||||||
|
* into a silent refresh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type DeletedUser = {
|
||||||
|
id: string;
|
||||||
|
/** `deleted_email`: the live column holds a tombstone, because deletion
|
||||||
|
* releases the address. */
|
||||||
|
email: string;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
deleted_at: string;
|
||||||
|
deleted_by_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeletedUsersPanel: React.FC<{ onRestored?: () => void }> = ({
|
||||||
|
onRestored,
|
||||||
|
}) => {
|
||||||
|
const { t, i18n } = useTranslation(["users", "common"]);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [items, setItems] = useState<DeletedUser[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
setItems(
|
||||||
|
await apiClient.get<DeletedUser[]>("/api/user/deleted", { toast: false })
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) void load();
|
||||||
|
}, [isOpen, load]);
|
||||||
|
|
||||||
|
const restore = async (user: DeletedUser) => {
|
||||||
|
setBusyId(user.id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/api/user/${user.id}/restore`, null, {
|
||||||
|
successMessage: t("deleted.restored"),
|
||||||
|
errorMessage: t("deleted.restoreFailed"),
|
||||||
|
});
|
||||||
|
setItems((current) => current.filter((item) => item.id !== user.id));
|
||||||
|
onRestored?.();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("deleted.restoreFailed"));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
className="flex w-full items-center gap-2 px-6 py-4 text-start"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<Chevron className="h-4 w-4 text-[var(--text-secondary)]" />
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{t("deleted.title")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-[var(--card-border)] px-6 py-4">
|
||||||
|
{error && <p className="mb-3 text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("deleted.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("deleted.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{items.map((user) => (
|
||||||
|
<li
|
||||||
|
key={user.id}
|
||||||
|
className="flex flex-wrap items-center gap-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="break-all text-sm text-[var(--text-primary)]">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
{t("deleted.on", {
|
||||||
|
when: formatDate(user.deleted_at, i18n.language),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void restore(user)}
|
||||||
|
disabled={busyId === user.id}
|
||||||
|
>
|
||||||
|
<Undo2 className="me-2 h-3.5 w-3.5" />
|
||||||
|
{t("deleted.restore")}
|
||||||
|
</CustomButton>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeletedUsersPanel;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type {
|
||||||
|
WebhookDeliveryList,
|
||||||
|
WebhookEndpoint,
|
||||||
|
WebhookEndpointCreated,
|
||||||
|
WebhookEndpointList,
|
||||||
|
WebhookTestResult,
|
||||||
|
} from "./WebhookTypes";
|
||||||
|
|
||||||
|
export const webhookApi = {
|
||||||
|
list: () => apiClient.get<WebhookEndpointList>("/api/webhooks"),
|
||||||
|
|
||||||
|
register: (payload: {
|
||||||
|
url: string;
|
||||||
|
description?: string | null;
|
||||||
|
event_types: string[];
|
||||||
|
}) =>
|
||||||
|
apiClient.post<WebhookEndpointCreated>("/api/webhooks", payload, {
|
||||||
|
// The response carries the signing secret, which the person has to copy
|
||||||
|
// into their own receiver. A success toast beside it invites dismissing
|
||||||
|
// the dialog that matters.
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: (
|
||||||
|
id: string,
|
||||||
|
payload: {
|
||||||
|
description?: string | null;
|
||||||
|
event_types?: string[];
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
apiClient.put<WebhookEndpoint>(`/api/webhooks/${id}`, payload, {
|
||||||
|
successMessage: "Endpoint updated",
|
||||||
|
errorMessage: "Could not update the endpoint",
|
||||||
|
}),
|
||||||
|
|
||||||
|
rotateSecret: (id: string) =>
|
||||||
|
apiClient.post<{ secret: string }>(
|
||||||
|
`/api/webhooks/${id}/rotate-secret`,
|
||||||
|
null,
|
||||||
|
{ toast: false }
|
||||||
|
),
|
||||||
|
|
||||||
|
remove: (id: string) =>
|
||||||
|
apiClient.delete<null>(`/api/webhooks/${id}`, {
|
||||||
|
successMessage: "Endpoint removed",
|
||||||
|
errorMessage: "Could not remove the endpoint",
|
||||||
|
}),
|
||||||
|
|
||||||
|
deliveries: (id: string, status?: string) =>
|
||||||
|
apiClient.get<WebhookDeliveryList>(
|
||||||
|
`/api/webhooks/${id}/deliveries${status ? `?status=${status}` : ""}`,
|
||||||
|
{ toast: false }
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent inline rather than queued: somebody is sitting in front of the form
|
||||||
|
* waiting to find out whether their URL works, and "queued" answers a
|
||||||
|
* different question than the one they asked.
|
||||||
|
*/
|
||||||
|
sendTest: (id: string) =>
|
||||||
|
apiClient.post<WebhookTestResult>(`/api/webhooks/${id}/test`, null, {
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export type WebhookEndpoint = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
description?: string | null;
|
||||||
|
/** Empty means every event. The first endpoint a workspace registers is
|
||||||
|
* usually "send me what you have". */
|
||||||
|
event_types: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
/** Set when the platform switched it off itself, so the reason is on the
|
||||||
|
* record rather than only in a log somewhere the customer cannot read. */
|
||||||
|
disabled_reason?: string | null;
|
||||||
|
consecutive_failures: number;
|
||||||
|
last_success_at?: string | null;
|
||||||
|
last_failure_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookEndpointCreated = {
|
||||||
|
endpoint: WebhookEndpoint;
|
||||||
|
/** Shown at registration and again only on rotation. Encrypted rather than
|
||||||
|
* hashed, because the customer has to put this exact value into their
|
||||||
|
* receiver to verify signatures. */
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookDelivery = {
|
||||||
|
id: string;
|
||||||
|
event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
status: "pending" | "delivered" | "failed";
|
||||||
|
attempts: number;
|
||||||
|
response_status?: number | null;
|
||||||
|
error?: string | null;
|
||||||
|
next_attempt_at: string;
|
||||||
|
delivered_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookEndpointList = {
|
||||||
|
items: WebhookEndpoint[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookDeliveryList = {
|
||||||
|
items: WebhookDelivery[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookTestResult = {
|
||||||
|
delivered: boolean;
|
||||||
|
response_status?: number | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The events the platform emits. Kept in step with the server's catalogue —
|
||||||
|
* a name that is not on this list is refused at registration, deliberately,
|
||||||
|
* because a typo would otherwise leave somebody waiting for an event that can
|
||||||
|
* never arrive. */
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
"user.created",
|
||||||
|
"user.updated",
|
||||||
|
"user.deleted",
|
||||||
|
"user.invited",
|
||||||
|
"invitation.accepted",
|
||||||
|
"subscription.changed",
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Copy,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Send,
|
||||||
|
Trash2,
|
||||||
|
Webhook,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomCheckBox,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomInput,
|
||||||
|
CustomLoader,
|
||||||
|
CustomModal,
|
||||||
|
} from "../../components/custom";
|
||||||
|
import { formatDate } from "../../lib/dateFormat";
|
||||||
|
import { webhookApi } from "./WebhookApi";
|
||||||
|
import { EVENT_TYPES } from "./WebhookTypes";
|
||||||
|
import type {
|
||||||
|
WebhookDelivery,
|
||||||
|
WebhookEndpoint,
|
||||||
|
WebhookTestResult,
|
||||||
|
} from "./WebhookTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace's own webhook endpoints.
|
||||||
|
*
|
||||||
|
* The screen exists mostly to answer one question — "we never got it" — so the
|
||||||
|
* delivery log is not a secondary feature. Without it the only available answer
|
||||||
|
* is asking the customer to trust that we tried.
|
||||||
|
*
|
||||||
|
* The other thing it has to do is make a **switched-off endpoint impossible to
|
||||||
|
* miss**. The platform disables one after twenty consecutive failures, and from
|
||||||
|
* the customer's side an endpoint failing silently looks exactly like one that
|
||||||
|
* was never called. The reason is on the record; this puts it on the screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SecretDialog: React.FC<{ value: string; onDone: () => void }> = ({
|
||||||
|
value,
|
||||||
|
onDone,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation(["webhooks", "common"]);
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("secret.explain")}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-[var(--card-border)] p-3">
|
||||||
|
<code className="min-w-0 flex-1 break-all font-mono text-sm text-[var(--text-primary)]">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void navigator.clipboard?.writeText(value)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm text-[var(--text-primary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acknowledged}
|
||||||
|
onChange={(event) => setAcknowledged(event.target.checked)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>{t("secret.acknowledge")}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!acknowledged}
|
||||||
|
onClick={onDone}
|
||||||
|
>
|
||||||
|
{t("secret.done")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeliveryList: React.FC<{ endpointId: string }> = ({ endpointId }) => {
|
||||||
|
const { t, i18n } = useTranslation(["webhooks", "common"]);
|
||||||
|
const [items, setItems] = useState<WebhookDelivery[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const list = await webhookApi.deliveries(endpointId);
|
||||||
|
if (!cancelled) setItems(list.items);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [endpointId]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<p className="py-6 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="py-6 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("deliveries.empty")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = (status: WebhookDelivery["status"]) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
delivered:
|
||||||
|
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
pending:
|
||||||
|
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||||
|
failed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>
|
||||||
|
{t(`deliveries.status.${status}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="max-h-96 divide-y divide-[var(--card-border)] overflow-y-auto">
|
||||||
|
{items.map((delivery) => (
|
||||||
|
<li key={delivery.id} className="py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-mono text-sm text-[var(--text-primary)]">
|
||||||
|
{delivery.event_type}
|
||||||
|
</span>
|
||||||
|
{badge(delivery.status)}
|
||||||
|
{delivery.response_status != null && (
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
HTTP {delivery.response_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{[
|
||||||
|
formatDate(delivery.created_at, i18n.language),
|
||||||
|
t("deliveries.attempts", { count: delivery.attempts }),
|
||||||
|
].join(" · ")}
|
||||||
|
</p>
|
||||||
|
{delivery.error && (
|
||||||
|
<p className="mt-1 break-words text-xs text-red-500">{delivery.error}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WebhooksPage: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation(["webhooks", "common"]);
|
||||||
|
|
||||||
|
const [endpoints, setEndpoints] = useState<WebhookEndpoint[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
|
const [pendingRemove, setPendingRemove] = useState<WebhookEndpoint | null>(null);
|
||||||
|
const [openDeliveries, setOpenDeliveries] = useState<WebhookEndpoint | null>(null);
|
||||||
|
const [testResult, setTestResult] = useState<
|
||||||
|
(WebhookTestResult & { url: string }) | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const list = await webhookApi.list();
|
||||||
|
setEndpoints(list.items);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const created = await webhookApi.register({
|
||||||
|
url: url.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
event_types: selected,
|
||||||
|
});
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setUrl("");
|
||||||
|
setDescription("");
|
||||||
|
setSelected([]);
|
||||||
|
setSecret(created.secret);
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : t("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reEnable = async (endpoint: WebhookEndpoint) => {
|
||||||
|
// Re-enabling clears the failure count on the server too, so a fix gets a
|
||||||
|
// chance to prove itself rather than being one blip from being switched off
|
||||||
|
// again.
|
||||||
|
await webhookApi.update(endpoint.id, { is_active: true });
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotate = async (endpoint: WebhookEndpoint) => {
|
||||||
|
const rotated = await webhookApi.rotateSecret(endpoint.id);
|
||||||
|
setSecret(rotated.secret);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTest = async (endpoint: WebhookEndpoint) => {
|
||||||
|
const result = await webhookApi.sendTest(endpoint.id);
|
||||||
|
setTestResult({ ...result, url: endpoint.url });
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!pendingRemove) return;
|
||||||
|
const target = pendingRemove;
|
||||||
|
setPendingRemove(null);
|
||||||
|
try {
|
||||||
|
await webhookApi.remove(target.id);
|
||||||
|
} finally {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEvent = (name: string) => {
|
||||||
|
setSelected((current) =>
|
||||||
|
current.includes(name)
|
||||||
|
? current.filter((item) => item !== name)
|
||||||
|
: [...current, name]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton variant="primary" onClick={() => setIsCreateOpen(true)}>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
{t("add")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
) : failed ? (
|
||||||
|
<p className="px-6 py-12 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("errors.loadFailed")}
|
||||||
|
</p>
|
||||||
|
) : endpoints.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<Webhook className="mx-auto mb-3 h-8 w-8 text-[var(--text-secondary)]" />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t("empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--card-border)]">
|
||||||
|
{endpoints.map((endpoint) => (
|
||||||
|
<li key={endpoint.id} className="px-6 py-4">
|
||||||
|
{/* A switched-off endpoint first and loudest. From the
|
||||||
|
customer's side, one failing silently is indistinguishable
|
||||||
|
from one that was never called. */}
|
||||||
|
{!endpoint.is_active && (
|
||||||
|
<div className="mb-3 flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p>{endpoint.disabled_reason ?? t("disabled.generic")}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void reEnable(endpoint)}
|
||||||
|
className="mt-1 text-xs font-semibold underline"
|
||||||
|
>
|
||||||
|
{t("disabled.reEnable")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="break-all font-medium text-[var(--text-primary)]">
|
||||||
|
{endpoint.url}
|
||||||
|
</p>
|
||||||
|
{endpoint.description && (
|
||||||
|
<p className="mt-0.5 text-sm text-[var(--text-secondary)]">
|
||||||
|
{endpoint.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{[
|
||||||
|
endpoint.event_types.length === 0
|
||||||
|
? t("events.all")
|
||||||
|
: t("events.some", {
|
||||||
|
count: endpoint.event_types.length,
|
||||||
|
}),
|
||||||
|
endpoint.last_success_at
|
||||||
|
? t("lastSuccess", {
|
||||||
|
when: formatDate(
|
||||||
|
endpoint.last_success_at,
|
||||||
|
i18n.language
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: t("neverDelivered"),
|
||||||
|
].join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void sendTest(endpoint)}
|
||||||
|
>
|
||||||
|
<Send className="me-2 h-4 w-4" />
|
||||||
|
{t("test")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setOpenDeliveries(endpoint)}
|
||||||
|
>
|
||||||
|
{t("deliveries.open")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void rotate(endpoint)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="me-2 h-4 w-4" />
|
||||||
|
{t("rotate")}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPendingRemove(endpoint)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isCreateOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
title={t("add")}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.url")}
|
||||||
|
type="url"
|
||||||
|
placeholder="https://your-host/hooks/saas"
|
||||||
|
required
|
||||||
|
value={url}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.urlNote")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={t("form.description")}
|
||||||
|
type="text"
|
||||||
|
placeholder={t("form.descriptionPlaceholder")}
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{t("form.events")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
{t("form.eventsNote")}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{EVENT_TYPES.map((name) => (
|
||||||
|
<CustomCheckBox
|
||||||
|
key={name}
|
||||||
|
label={name}
|
||||||
|
checked={selected.includes(name)}
|
||||||
|
onChange={() => toggleEvent(name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={register}
|
||||||
|
disabled={isBusy || url.trim().length === 0}
|
||||||
|
>
|
||||||
|
{t("form.submit")}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={secret !== null}
|
||||||
|
onClose={() => setSecret(null)}
|
||||||
|
title={t("secret.title")}
|
||||||
|
>
|
||||||
|
{secret && <SecretDialog value={secret} onDone={() => setSecret(null)} />}
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={openDeliveries !== null}
|
||||||
|
onClose={() => setOpenDeliveries(null)}
|
||||||
|
title={t("deliveries.title")}
|
||||||
|
>
|
||||||
|
{openDeliveries && <DeliveryList endpointId={openDeliveries.id} />}
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomModal
|
||||||
|
isOpen={testResult !== null}
|
||||||
|
onClose={() => setTestResult(null)}
|
||||||
|
title={t("testResult.title")}
|
||||||
|
>
|
||||||
|
{testResult && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-[var(--text-primary)]">
|
||||||
|
{testResult.delivered
|
||||||
|
? t("testResult.ok", { status: testResult.response_status ?? 200 })
|
||||||
|
: t("testResult.failed")}
|
||||||
|
</p>
|
||||||
|
{testResult.error && (
|
||||||
|
<p className="break-words rounded-md border border-[var(--card-border)] p-3 font-mono text-xs text-red-500">
|
||||||
|
{testResult.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CustomModal>
|
||||||
|
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={pendingRemove !== null}
|
||||||
|
onClose={() => setPendingRemove(null)}
|
||||||
|
onConfirm={remove}
|
||||||
|
title={t("confirmRemove.title")}
|
||||||
|
description={t("confirmRemove.message", { url: pendingRemove?.url ?? "" })}
|
||||||
|
confirmText={t("confirmRemove.confirm")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebhooksPage;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildColumnFilterOptions,
|
||||||
|
resolveColumnSortState,
|
||||||
|
} from "./CustomColumnFilter.utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared by every sortable, filterable table in the console.
|
||||||
|
*
|
||||||
|
* `resolveColumnSortState` is the one worth reading twice. It is two nested
|
||||||
|
* conditionals deciding what happens when you clear a sort, and the interesting
|
||||||
|
* case is clearing a sort on a column that is *not* the one currently sorted —
|
||||||
|
* which must leave the existing sort alone rather than dropping it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("buildColumnFilterOptions", () => {
|
||||||
|
it("puts the count in the label and the bare value in the value", () => {
|
||||||
|
// The label is for reading; the value is what goes in the request. Send
|
||||||
|
// "ACTIVE (12)" as a filter and it matches nothing.
|
||||||
|
expect(buildColumnFilterOptions([["ACTIVE", 12]])).toEqual([
|
||||||
|
{ label: "ACTIVE (12)", value: "ACTIVE" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the order it was given", () => {
|
||||||
|
// Usually a Map built in insertion order. Re-sorting here would make the
|
||||||
|
// dropdown disagree with whatever the caller arranged.
|
||||||
|
expect(
|
||||||
|
buildColumnFilterOptions([
|
||||||
|
["b", 1],
|
||||||
|
["a", 2],
|
||||||
|
]).map((option) => option.value)
|
||||||
|
).toEqual(["b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a Map directly", () => {
|
||||||
|
const counts = new Map([["EXPIRED", 3]]);
|
||||||
|
|
||||||
|
expect(buildColumnFilterOptions(counts)).toEqual([
|
||||||
|
{ label: "EXPIRED (3)", value: "EXPIRED" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles nothing to show", () => {
|
||||||
|
expect(buildColumnFilterOptions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a zero count rather than hiding the option", () => {
|
||||||
|
// "0" is informative: it says the value exists and nothing currently
|
||||||
|
// matches. Dropping it makes the filter list change shape as data does.
|
||||||
|
expect(buildColumnFilterOptions([["NONE", 0]])).toEqual([
|
||||||
|
{ label: "NONE (0)", value: "NONE" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveColumnSortState", () => {
|
||||||
|
const none = { column: null, direction: null } as const;
|
||||||
|
|
||||||
|
it("sorts an unsorted table", () => {
|
||||||
|
expect(resolveColumnSortState(none, "name", "asc")).toEqual({
|
||||||
|
column: "name",
|
||||||
|
direction: "asc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverses the column already sorted", () => {
|
||||||
|
expect(
|
||||||
|
resolveColumnSortState({ column: "name", direction: "asc" }, "name", "desc")
|
||||||
|
).toEqual({ column: "name", direction: "desc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the sort to a different column", () => {
|
||||||
|
expect(
|
||||||
|
resolveColumnSortState({ column: "name", direction: "asc" }, "created", "desc")
|
||||||
|
).toEqual({ column: "created", direction: "desc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the sort when the sorted column is cleared", () => {
|
||||||
|
expect(
|
||||||
|
resolveColumnSortState({ column: "name", direction: "asc" }, "name", null)
|
||||||
|
).toEqual({ column: null, direction: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the sort alone when a different column is cleared", () => {
|
||||||
|
// The case worth having a test for. Clearing a filter on column B while
|
||||||
|
// column A is sorted must not unsort A — the user touched something
|
||||||
|
// else, and the table jumping back to its default order reads as a bug.
|
||||||
|
expect(
|
||||||
|
resolveColumnSortState({ column: "name", direction: "asc" }, "created", null)
|
||||||
|
).toEqual({ column: "name", direction: "asc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays cleared when clearing an already-unsorted table", () => {
|
||||||
|
expect(resolveColumnSortState(none, "name", null)).toEqual(none);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, forwardRef } from "react";
|
import React, { useId, useState, forwardRef } from "react";
|
||||||
import { Phone, Eye, EyeOff } from "lucide-react";
|
import { Phone, Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "url";
|
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "url";
|
||||||
@@ -32,6 +32,13 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
|
// `htmlFor` pointed at `props.id`, and no caller passed one — so every
|
||||||
|
// label in the product was decoration: clicking it did nothing, and a screen
|
||||||
|
// reader could not say which field it belonged to. Generated when absent, so
|
||||||
|
// this is fixed everywhere at once rather than form by form.
|
||||||
|
const generatedId = useId();
|
||||||
|
const inputId = props.id ?? generatedId;
|
||||||
|
|
||||||
const isPassword = type === "password";
|
const isPassword = type === "password";
|
||||||
const isPhone = phonePrefix !== undefined;
|
const isPhone = phonePrefix !== undefined;
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -51,7 +58,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
|||||||
return (
|
return (
|
||||||
<div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}>
|
<div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}>
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={props.id} className="block text-sm font-medium text-[var(--text-primary)]">
|
<label htmlFor={inputId} className="block text-sm font-medium text-[var(--text-primary)]">
|
||||||
{label}
|
{label}
|
||||||
{props.required && <span className="ml-1 text-red-500">*</span>}
|
{props.required && <span className="ml-1 text-red-500">*</span>}
|
||||||
</label>
|
</label>
|
||||||
@@ -78,6 +85,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
|||||||
|
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
maxLength={
|
maxLength={
|
||||||
type === "number" || type === "tel" ? undefined : maxLength
|
type === "number" || type === "tel" ? undefined : maxLength
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuth } from "../../context/AuthContext";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { authApi } from "../../application/authentication/AuthApi";
|
import { authApi } from "../../application/authentication/AuthApi";
|
||||||
import { clearAuthCookies } from "../../lib/authCookies";
|
import { clearAuthCookies } from "../../lib/authCookies";
|
||||||
|
import NotificationBell from "../../application/notifications/NotificationBell";
|
||||||
|
|
||||||
|
|
||||||
const AppHeader: React.FC = () => {
|
const AppHeader: React.FC = () => {
|
||||||
@@ -72,6 +73,10 @@ const AppHeader: React.FC = () => {
|
|||||||
{/* RIGHT: Actions */}
|
{/* RIGHT: Actions */}
|
||||||
<div className="flex items-center gap-2 sm:gap-4 ltr:ml-auto rtl:mr-auto">
|
<div className="flex items-center gap-2 sm:gap-4 ltr:ml-auto rtl:mr-auto">
|
||||||
|
|
||||||
|
{/* Before the user menu: this is the thing that changes, and the one
|
||||||
|
somebody is looking for when they arrive. */}
|
||||||
|
<NotificationBell />
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<div className="relative" ref={userMenuRef}>
|
<div className="relative" ref={userMenuRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Outlet } from "react-router-dom";
|
|||||||
import AppHeader from "./AppHeader";
|
import AppHeader from "./AppHeader";
|
||||||
import AppSidebar from "./AppSidebar";
|
import AppSidebar from "./AppSidebar";
|
||||||
import Backdrop from "./Backdrop";
|
import Backdrop from "./Backdrop";
|
||||||
|
import SubscriptionBanner from "./SubscriptionBanner";
|
||||||
|
|
||||||
const LayoutContent: React.FC = () => {
|
const LayoutContent: React.FC = () => {
|
||||||
const { isExpanded } = useSidebar();
|
const { isExpanded } = useSidebar();
|
||||||
@@ -20,6 +21,10 @@ const LayoutContent: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
|
{/* Above the content, below the header: the reason a save is about to
|
||||||
|
fail should be visible before the save is attempted. */}
|
||||||
|
<SubscriptionBanner />
|
||||||
|
|
||||||
<main className="flex-1 p-4 sm:p-6 lg:p-8 animate-in fade-in duration-500">
|
<main className="flex-1 p-4 sm:p-6 lg:p-8 animate-in fade-in duration-500">
|
||||||
<div className="max-w-[1600px] mx-auto w-full">
|
<div className="max-w-[1600px] mx-auto w-full">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -11,11 +11,19 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
Palette,
|
Palette,
|
||||||
|
KeyRound,
|
||||||
|
List,
|
||||||
|
Paperclip,
|
||||||
|
LogIn,
|
||||||
|
Mail,
|
||||||
|
Network,
|
||||||
Settings,
|
Settings,
|
||||||
|
Webhook,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
FileText,
|
||||||
|
Activity,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSidebar } from "../../context/SidebarContext";
|
import { useSidebar } from "../../context/SidebarContext";
|
||||||
import { usePermission } from "../../lib/usePermission";
|
import { usePermission } from "../../lib/usePermission";
|
||||||
@@ -36,6 +44,9 @@ interface NavItem {
|
|||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
access?: string;
|
access?: string;
|
||||||
|
/** Shown only to platform superadmins. Not every restriction is a permission
|
||||||
|
* code: a page whose figures span all customers has no code that fits. */
|
||||||
|
superadminOnly?: boolean;
|
||||||
submenu?: SubMenuItem[];
|
submenu?: SubMenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +99,57 @@ const navItems: NavItem[] = [
|
|||||||
path: "/logs",
|
path: "/logs",
|
||||||
access: "admin.logs.read",
|
access: "admin.logs.read",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <Activity size={22} />,
|
||||||
|
name: "Operations",
|
||||||
|
path: "/operations",
|
||||||
|
// Not a permission code: the endpoints behind this page are superadmin-only
|
||||||
|
// because every figure on it counts across all customers, and there is no
|
||||||
|
// access code that means "may see other workspaces".
|
||||||
|
superadminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Paperclip size={22} />,
|
||||||
|
name: "Documents",
|
||||||
|
path: "/documents",
|
||||||
|
access: "admin.document.read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <List size={22} />,
|
||||||
|
name: "Reference data",
|
||||||
|
path: "/settings/reference",
|
||||||
|
access: "admin.lookup.manage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Mail size={22} />,
|
||||||
|
name: "Email",
|
||||||
|
path: "/settings/email",
|
||||||
|
access: "admin.email.manage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Network size={22} />,
|
||||||
|
name: "Organisation",
|
||||||
|
path: "/settings/organisation",
|
||||||
|
access: "admin.user.read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <LogIn size={22} />,
|
||||||
|
name: "Sign-in",
|
||||||
|
path: "/settings/sign-in",
|
||||||
|
access: "admin.sso.read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <KeyRound size={22} />,
|
||||||
|
name: "API keys",
|
||||||
|
path: "/settings/api-keys",
|
||||||
|
access: "admin.api_key.manage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Webhook size={22} />,
|
||||||
|
name: "Webhooks",
|
||||||
|
path: "/settings/webhooks",
|
||||||
|
access: "admin.webhook.manage",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <Settings size={22} />,
|
icon: <Settings size={22} />,
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
@@ -125,6 +187,14 @@ const AppSidebar: React.FC = () => {
|
|||||||
'Settings': 'settings',
|
'Settings': 'settings',
|
||||||
'Modules': 'modules',
|
'Modules': 'modules',
|
||||||
'Logs': 'logs',
|
'Logs': 'logs',
|
||||||
|
'Operations': 'operations',
|
||||||
|
'Documents': 'documents',
|
||||||
|
'Reference data': 'reference',
|
||||||
|
'Email': 'email',
|
||||||
|
'Organisation': 'organisation',
|
||||||
|
'Sign-in': 'signIn',
|
||||||
|
'API keys': 'apiKeys',
|
||||||
|
'Webhooks': 'webhooks',
|
||||||
'Subscriptions': 'subscriptions',
|
'Subscriptions': 'subscriptions',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,6 +213,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
|
|
||||||
const visibleNavItems = useMemo(
|
const visibleNavItems = useMemo(
|
||||||
() => navItems.filter((item) => {
|
() => navItems.filter((item) => {
|
||||||
|
if (item.superadminOnly) return Boolean(user?.is_superadmin);
|
||||||
if (!item.access) return true;
|
if (!item.access) return true;
|
||||||
|
|
||||||
if (item.submenu && item.submenu.length > 0) {
|
if (item.submenu && item.submenu.length > 0) {
|
||||||
@@ -151,7 +222,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
|
|
||||||
return can(item.access);
|
return can(item.access);
|
||||||
}),
|
}),
|
||||||
[can]
|
[can, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isActive = useCallback(
|
const isActive = useCallback(
|
||||||
@@ -178,13 +249,16 @@ const AppSidebar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Management
|
// 2. Management
|
||||||
const managementItems = getItems(["Tenants", "Subscriptions", "Roles", "Users"]);
|
const managementItems = getItems(["Tenants", "Subscriptions", "Roles", "Users",
|
||||||
|
"Documents"]);
|
||||||
if (managementItems.length > 0) {
|
if (managementItems.length > 0) {
|
||||||
groups.push({ label: t('nav.groups.management', 'Management'), items: managementItems });
|
groups.push({ label: t('nav.groups.management', 'Management'), items: managementItems });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. System
|
// 3. System
|
||||||
const systemItems = getItems(["Themes", "Modules", "Logs", "Settings"]);
|
const systemItems = getItems(["Themes", "Modules", "Logs", "Operations",
|
||||||
|
"Organisation", "Sign-in", "Email", "Reference data", "API keys",
|
||||||
|
"Webhooks", "Settings"]);
|
||||||
if (systemItems.length > 0) {
|
if (systemItems.length > 0) {
|
||||||
groups.push({ label: t('nav.groups.system', 'System'), items: systemItems });
|
groups.push({ label: t('nav.groups.system', 'System'), items: systemItems });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import SubscriptionBanner from "./SubscriptionBanner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The banner is the only thing that tells a customer why the product has stopped
|
||||||
|
* letting them save. Before it existed, a workspace in grace failed a save with a
|
||||||
|
* message about permissions — which reads as a bug rather than an unpaid invoice.
|
||||||
|
*
|
||||||
|
* So the cases that matter are: it appears when it should, it says *until when*,
|
||||||
|
* and it stays out of the way when nothing is wrong. A banner that shows on a
|
||||||
|
* healthy workspace is a banner people learn to ignore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockUser = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../context/AuthContext", () => ({
|
||||||
|
useAuth: () => ({ user: mockUser() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
// Returns the key plus any interpolation, so an assertion can tell
|
||||||
|
// "graceUntil with a date" from "grace with none" without depending on the
|
||||||
|
// English copy, which changes.
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) =>
|
||||||
|
options && options.date ? `${key}:${options.date}` : key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const withSubscription = (subscription: Record<string, unknown> | null) => {
|
||||||
|
mockUser.mockReturnValue(
|
||||||
|
subscription === null ? { id: "u" } : { id: "u", subscription_details: subscription }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SubscriptionBanner", () => {
|
||||||
|
it("says nothing while the subscription is active", () => {
|
||||||
|
withSubscription({ state: "ACTIVE", can_write: true });
|
||||||
|
const { container } = render(<SubscriptionBanner />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing for a workspace with no plan", () => {
|
||||||
|
// NONE is a legitimate state, not a fault. Warning about it would put a
|
||||||
|
// permanent banner on every workspace that has never had a subscription.
|
||||||
|
withSubscription({ state: "NONE", can_write: true });
|
||||||
|
const { container } = render(<SubscriptionBanner />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing when the server sent no subscription at all", () => {
|
||||||
|
withSubscription(null);
|
||||||
|
const { container } = render(<SubscriptionBanner />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns during grace, and says until when", () => {
|
||||||
|
withSubscription({
|
||||||
|
state: "GRACE",
|
||||||
|
can_write: false,
|
||||||
|
grace_until: "2026-03-14",
|
||||||
|
});
|
||||||
|
render(<SubscriptionBanner />);
|
||||||
|
|
||||||
|
// "Read-only" without "until when" leaves the customer no deadline to
|
||||||
|
// act on, which is the whole content of the message.
|
||||||
|
expect(screen.getByRole("status").textContent).toContain(
|
||||||
|
"subscription.graceUntil"
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("status").textContent).toMatch(/2026/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still warns during grace when no date came back", () => {
|
||||||
|
withSubscription({ state: "GRACE", can_write: false, grace_until: null });
|
||||||
|
render(<SubscriptionBanner />);
|
||||||
|
expect(screen.getByRole("status").textContent).toContain("subscription.grace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["EXPIRED", "CANCELLED", "SUSPENDED"])(
|
||||||
|
"warns when the workspace is %s",
|
||||||
|
(state) => {
|
||||||
|
withSubscription({ state, can_write: false });
|
||||||
|
render(<SubscriptionBanner />);
|
||||||
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("falls back rather than showing a raw key for a state it does not know", () => {
|
||||||
|
// The server's lifecycle can gain a state before the console does. An
|
||||||
|
// untranslated key on screen is worse than a general message.
|
||||||
|
withSubscription({ state: "SOMETHING_NEW", can_write: false });
|
||||||
|
render(<SubscriptionBanner />);
|
||||||
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is announced to assistive technology", () => {
|
||||||
|
// It appears without the user doing anything, so a screen reader has to
|
||||||
|
// be told. Everyone else sees it because it is at the top of the page.
|
||||||
|
withSubscription({ state: "GRACE", can_write: false, grace_until: "2026-03-14" });
|
||||||
|
render(<SubscriptionBanner />);
|
||||||
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Says why the product has stopped letting someone save things.
|
||||||
|
*
|
||||||
|
* The subscription lifecycle is enforced on every write and, until now, was
|
||||||
|
* never shown. A workspace in grace is read-only: the customer's next save fails
|
||||||
|
* with a message about permissions, which reads as a bug rather than as an
|
||||||
|
* unpaid invoice — and the one action that would fix it is the one nobody
|
||||||
|
* realises they need to take.
|
||||||
|
*/
|
||||||
|
const SubscriptionBanner: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation(["common"]);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const subscription = user?.subscription_details;
|
||||||
|
const state = subscription?.state;
|
||||||
|
|
||||||
|
// ACTIVE and NONE are the normal states; a workspace with no plan is not in
|
||||||
|
// trouble, and banners that appear when nothing is wrong stop being read.
|
||||||
|
if (!state || state === "ACTIVE" || state === "NONE") return null;
|
||||||
|
|
||||||
|
const until = subscription?.grace_until
|
||||||
|
? new Date(subscription.grace_until).toLocaleDateString(i18n.language, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isGrace = state === "GRACE";
|
||||||
|
const Icon = isGrace ? Clock : AlertTriangle;
|
||||||
|
|
||||||
|
const message = isGrace
|
||||||
|
? until
|
||||||
|
? t("subscription.graceUntil", { date: until })
|
||||||
|
: t("subscription.grace")
|
||||||
|
: t(`subscription.${state.toLowerCase()}`, {
|
||||||
|
defaultValue: t("subscription.inactive"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={`flex items-start gap-3 border-b px-4 py-3 text-sm sm:px-6 ${isGrace
|
||||||
|
? "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
|
||||||
|
: "border-red-300 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<p className="min-w-0">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionBanner;
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
getAccessToken,
|
getAccessToken,
|
||||||
clearAuthCookies,
|
clearAuthCookies,
|
||||||
} from "../lib/authCookies";
|
} from "../lib/authCookies";
|
||||||
import i18n from "../i18n/config";
|
import i18n, { loadLanguage } from "../i18n/config";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
@@ -44,8 +44,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const userData = await authApi.me();
|
const userData = await authApi.me();
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
|
||||||
if (userData.preferred_language) {
|
const preferred = userData.preferred_language;
|
||||||
i18n.changeLanguage(userData.preferred_language);
|
if (preferred) {
|
||||||
|
// Loaded before switching: changing to a language whose bundles have
|
||||||
|
// not arrived renders a screen of raw keys.
|
||||||
|
void loadLanguage(preferred).then(() => i18n.changeLanguage(preferred));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
clearAuthCookies();
|
clearAuthCookies();
|
||||||
@@ -91,7 +94,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
|
||||||
if (userData.preferred_language) {
|
if (userData.preferred_language) {
|
||||||
i18n.changeLanguage(userData.preferred_language);
|
const preferred = userData.preferred_language;
|
||||||
|
if (preferred) {
|
||||||
|
void loadLanguage(preferred).then(() => i18n.changeLanguage(preferred));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-43
@@ -3,22 +3,42 @@ import { initReactI18next } from 'react-i18next';
|
|||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
import enCommon from './locales/en/common.json';
|
import enCommon from './locales/en/common.json';
|
||||||
import arCommon from './locales/ar/common.json';
|
|
||||||
import enUsers from './locales/en/users.json';
|
import enUsers from './locales/en/users.json';
|
||||||
import arUsers from './locales/ar/users.json';
|
|
||||||
import enRoles from './locales/en/roles.json';
|
import enRoles from './locales/en/roles.json';
|
||||||
import arRoles from './locales/ar/roles.json';
|
|
||||||
import enProfile from './locales/en/profile.json';
|
import enProfile from './locales/en/profile.json';
|
||||||
import arProfile from './locales/ar/profile.json';
|
|
||||||
import enDashboard from './locales/en/dashboard.json';
|
import enDashboard from './locales/en/dashboard.json';
|
||||||
import arDashboard from './locales/ar/dashboard.json';
|
|
||||||
import enModules from './locales/en/modules.json';
|
import enModules from './locales/en/modules.json';
|
||||||
import arModules from './locales/ar/modules.json';
|
|
||||||
import enLogs from './locales/en/logs.json';
|
import enLogs from './locales/en/logs.json';
|
||||||
import arLogs from './locales/ar/logs.json';
|
import enOperations from './locales/en/operations.json';
|
||||||
|
|
||||||
import enTheme from './locales/en/theme.json';
|
import enTheme from './locales/en/theme.json';
|
||||||
import arTheme from './locales/ar/theme.json';
|
import enSecurity from './locales/en/security.json';
|
||||||
|
import enNotifications from './locales/en/notifications.json';
|
||||||
|
import enApiKeys from './locales/en/apikeys.json';
|
||||||
|
import enWebhooks from './locales/en/webhooks.json';
|
||||||
|
import enInvitations from './locales/en/invitations.json';
|
||||||
|
import enSso from './locales/en/sso.json';
|
||||||
|
import enOrganisation from './locales/en/organisation.json';
|
||||||
|
import enEmail from './locales/en/email.json';
|
||||||
|
import enReference from './locales/en/reference.json';
|
||||||
|
import enDocuments from './locales/en/documents.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translations, with only the language in use in the initial payload.
|
||||||
|
*
|
||||||
|
* Every namespace used to be imported statically for **both** languages, so a
|
||||||
|
* signed-out visitor downloaded 81 kB of JSON — half of it in a language they
|
||||||
|
* had not chosen — before the sign-in form could render. That is fine at two
|
||||||
|
* namespaces and grew to twenty, taking first paint from 524 kB to 536 kB
|
||||||
|
* against a 550 kB budget in the course of one afternoon's features.
|
||||||
|
*
|
||||||
|
* English is bundled because it is the fallback: something has to be there when
|
||||||
|
* a key is missing, and a fallback that needs a network round trip is not one.
|
||||||
|
* Arabic is fetched only when it is the language in use, which is the request
|
||||||
|
* almost nobody makes and everybody was paying for.
|
||||||
|
*
|
||||||
|
* `loadLanguage` is exported so the language switcher can fetch a bundle before
|
||||||
|
* it switches, rather than switching to a language that has not arrived.
|
||||||
|
*/
|
||||||
|
|
||||||
export const languages = {
|
export const languages = {
|
||||||
en: { name: 'English', dir: 'ltr' },
|
en: { name: 'English', dir: 'ltr' },
|
||||||
@@ -27,38 +47,81 @@ export const languages = {
|
|||||||
|
|
||||||
export type SupportedLanguage = keyof typeof languages;
|
export type SupportedLanguage = keyof typeof languages;
|
||||||
|
|
||||||
const resources = {
|
const NAMESPACES = [
|
||||||
en: {
|
'common', 'users', 'roles', 'profile', 'dashboard', 'modules', 'logs',
|
||||||
common: enCommon,
|
'operations', 'theme', 'security', 'notifications', 'apikeys', 'webhooks',
|
||||||
users: enUsers,
|
'invitations', 'sso', 'organisation', 'email', 'reference', 'documents',
|
||||||
roles: enRoles,
|
] as const;
|
||||||
profile: enProfile,
|
|
||||||
dashboard: enDashboard,
|
const english = {
|
||||||
modules: enModules,
|
common: enCommon,
|
||||||
logs: enLogs,
|
users: enUsers,
|
||||||
theme: enTheme,
|
roles: enRoles,
|
||||||
},
|
profile: enProfile,
|
||||||
ar: {
|
dashboard: enDashboard,
|
||||||
common: arCommon,
|
modules: enModules,
|
||||||
users: arUsers,
|
logs: enLogs,
|
||||||
roles: arRoles,
|
operations: enOperations,
|
||||||
profile: arProfile,
|
theme: enTheme,
|
||||||
dashboard: arDashboard,
|
security: enSecurity,
|
||||||
modules: arModules,
|
notifications: enNotifications,
|
||||||
logs: arLogs,
|
apikeys: enApiKeys,
|
||||||
theme: arTheme,
|
webhooks: enWebhooks,
|
||||||
},
|
invitations: enInvitations,
|
||||||
|
sso: enSso,
|
||||||
|
organisation: enOrganisation,
|
||||||
|
email: enEmail,
|
||||||
|
reference: enReference,
|
||||||
|
documents: enDocuments,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a language's bundles and register them.
|
||||||
|
*
|
||||||
|
* A no-op for English, which is already here, and idempotent for anything else
|
||||||
|
* — i18next holds what it has been given, so a second call costs one already-
|
||||||
|
* resolved dynamic import.
|
||||||
|
*
|
||||||
|
* A failure is swallowed: the fallback is English and it is already loaded, so
|
||||||
|
* the worst outcome is a screen in the wrong language rather than a blank one.
|
||||||
|
*/
|
||||||
|
export const loadLanguage = async (language: string): Promise<void> => {
|
||||||
|
if (language === 'en' || i18next.hasResourceBundle(language, 'common')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundles = await Promise.all(
|
||||||
|
NAMESPACES.map((namespace) =>
|
||||||
|
// The path is built from a literal prefix and suffix so the bundler can
|
||||||
|
// see the whole set and split it — a fully dynamic path would either
|
||||||
|
// fail to resolve or pull in everything it could not rule out.
|
||||||
|
import(`./locales/${language}/${namespace}.json`)
|
||||||
|
.then((module) => module.default as Record<string, unknown>)
|
||||||
|
.catch(() => ({}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
NAMESPACES.forEach((namespace, index) => {
|
||||||
|
i18next.addResourceBundle(language, namespace, bundles[index], true, true);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// English is loaded and is the fallback. A wrong language beats no screen.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const detected =
|
||||||
|
(typeof localStorage !== 'undefined' &&
|
||||||
|
localStorage.getItem('preferred_language')) ||
|
||||||
|
'en';
|
||||||
|
|
||||||
i18next
|
i18next
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources,
|
resources: { en: english },
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: Object.keys(languages),
|
supportedLngs: Object.keys(languages),
|
||||||
|
|
||||||
detection: {
|
detection: {
|
||||||
order: ['localStorage', 'navigator'],
|
order: ['localStorage', 'navigator'],
|
||||||
caches: ['localStorage'],
|
caches: ['localStorage'],
|
||||||
@@ -70,20 +133,18 @@ i18next
|
|||||||
},
|
},
|
||||||
|
|
||||||
react: {
|
react: {
|
||||||
|
// Already false before this change. It matters more now: a namespace that
|
||||||
|
// has not arrived yet renders its key rather than suspending, and the
|
||||||
|
// bundle lands a moment later.
|
||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateDirection = (lng: string) => {
|
// Started immediately rather than awaited: the app renders in English for the
|
||||||
const dir = languages[lng as SupportedLanguage]?.dir || 'ltr';
|
// moment it takes, which is far better than a blank page — and for the majority
|
||||||
document.documentElement.setAttribute('dir', dir);
|
// who use English there is nothing to fetch at all.
|
||||||
document.documentElement.setAttribute('lang', lng);
|
if (detected !== 'en') {
|
||||||
};
|
void loadLanguage(detected);
|
||||||
|
|
||||||
i18next.on('languageChanged', updateDirection);
|
|
||||||
|
|
||||||
if (i18next.language) {
|
|
||||||
updateDirection(i18next.language);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default i18next;
|
export default i18next;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"title": "مفاتيح الواجهة البرمجية",
|
||||||
|
"subtitle": "بيانات اعتماد تخصّ التكامل لا الشخص. لا تستطيع إلا ما تستطيعه أنت، وتتوقف فور تعطّل حسابك.",
|
||||||
|
"issue": "إصدار مفتاح",
|
||||||
|
"revoke": "إبطال",
|
||||||
|
"empty": "لا توجد مفاتيح بعد. أصدر واحدًا ليتمكن برنامج أو تكامل من العمل على مساحة العمل هذه.",
|
||||||
|
"neverUsed": "لم يُستخدم قط",
|
||||||
|
"lastUsed": "آخر استخدام {{when}}",
|
||||||
|
"expires": "ينتهي في {{when}}",
|
||||||
|
"state": {
|
||||||
|
"active": "نشط",
|
||||||
|
"expired": "منتهٍ",
|
||||||
|
"revoked": "مُبطَل"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"inherited": "صلاحية كاملة — كل ما تستطيعه",
|
||||||
|
"limited_zero": "بلا صلاحيات",
|
||||||
|
"limited_one": "مقيّد بصلاحية واحدة",
|
||||||
|
"limited_two": "مقيّد بصلاحيتين",
|
||||||
|
"limited_few": "مقيّد بـ {{count}} صلاحيات",
|
||||||
|
"limited_many": "مقيّد بـ {{count}} صلاحية",
|
||||||
|
"limited_other": "مقيّد بـ {{count}} صلاحية"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "ما الغرض منه؟",
|
||||||
|
"namePlaceholder": "استيراد ليلي، نشر آلي، مزامنة Okta…",
|
||||||
|
"expiry": "ينتهي بعد (أيام)",
|
||||||
|
"expiryPlaceholder": "اتركه فارغًا لبقائه بلا انتهاء",
|
||||||
|
"scopeNote": "يعمل المفتاح بصلاحياتك. إذا قُلّصت صلاحيتك أو عُطّل حسابك، تقلّص صلاحية المفتاح معه.",
|
||||||
|
"submit": "إصدار المفتاح"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"title": "مفتاحك الجديد",
|
||||||
|
"explain": "انسخه الآن. يُخزَّن مشفَّرًا، وهذه هي المرة الوحيدة التي يُعرض فيها — إن فقدته فعليك إبطاله وإصدار غيره.",
|
||||||
|
"acknowledge": "نسختُ المفتاح إلى مكان آمن.",
|
||||||
|
"done": "تم"
|
||||||
|
},
|
||||||
|
"confirmRevoke": {
|
||||||
|
"title": "إبطال هذا المفتاح؟",
|
||||||
|
"message": "سيتوقف كل ما يستخدم «{{name}}» فورًا. لا يمكن التراجع عن ذلك — أصدر مفتاحًا جديدًا بدلًا منه."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"loadFailed": "تعذّر تحميل المفاتيح الآن."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,15 @@
|
|||||||
"settings": "الإعدادات",
|
"settings": "الإعدادات",
|
||||||
"configuration": "التكوين",
|
"configuration": "التكوين",
|
||||||
"dropdowns": "القوائم المنسدلة",
|
"dropdowns": "القوائم المنسدلة",
|
||||||
"modules": "الوحدات"
|
"modules": "الوحدات",
|
||||||
|
"operations": "العمليات",
|
||||||
|
"apiKeys": "مفاتيح الواجهة البرمجية",
|
||||||
|
"webhooks": "الويب هوك",
|
||||||
|
"signIn": "تسجيل الدخول",
|
||||||
|
"organisation": "الهيكل التنظيمي",
|
||||||
|
"email": "البريد",
|
||||||
|
"reference": "البيانات المرجعية",
|
||||||
|
"documents": "المستندات"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
@@ -112,5 +120,13 @@
|
|||||||
"createSuccess": "تم الإنشاء بنجاح",
|
"createSuccess": "تم الإنشاء بنجاح",
|
||||||
"error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
|
"error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
|
||||||
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا العنصر؟"
|
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا العنصر؟"
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"graceUntil": "انتهى اشتراكك. مساحة العمل للقراءة فقط حتى {{date}} — جدّد الاشتراك قبل ذلك للمتابعة.",
|
||||||
|
"grace": "انتهى اشتراكك. مساحة العمل للقراءة فقط حتى يتم التجديد.",
|
||||||
|
"expired": "انتهت صلاحية اشتراكك. جدّده لاستعادة الوصول.",
|
||||||
|
"cancelled": "تم إلغاء هذا الاشتراك. تواصل مع المسؤول لاستعادة الوصول.",
|
||||||
|
"suspended": "تم تعليق مساحة العمل هذه. تواصل مع المسؤول.",
|
||||||
|
"inactive": "مساحة العمل هذه غير نشطة حالياً."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "المستندات",
|
||||||
|
"pageSubtitle": "الملفات التي تخصّ مساحة العمل لا سجلًا بعينه.",
|
||||||
|
"title": "المرفقات",
|
||||||
|
"upload": "رفع",
|
||||||
|
"uploaded": "رُفع الملف",
|
||||||
|
"deleted": "حُذف الملف",
|
||||||
|
"empty": "لا توجد مرفقات بعد.",
|
||||||
|
"accepted": "ملفات PDF والصور ومستندات Word وExcel والنصوص وملفات CSV.",
|
||||||
|
"usage": "استُخدم {{used}} من {{quota}}",
|
||||||
|
"nearlyFull": "أوشكت مساحة المستندات على النفاد. احذف شيئًا لإفساح المجال قبل رفع ملف كبير.",
|
||||||
|
"noWorkspace": "هذا الحساب غير مرتبط بمساحة عمل.",
|
||||||
|
"confirmDelete": {
|
||||||
|
"title": "حذف هذا الملف؟",
|
||||||
|
"message": "ستُحذف {{name}} وتُحرَّر المساحة التي تشغلها. ويبقى سجل الحذف ومن قام به.",
|
||||||
|
"confirm": "حذف"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"tooLarge": "حجم الملف أكبر من {{limit}}.",
|
||||||
|
"uploadFailed": "تعذّر رفع الملف",
|
||||||
|
"downloadFailed": "تعذّر تنزيل الملف",
|
||||||
|
"loadFailed": "تعذّر تحميل المرفقات الآن."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"title": "البريد الصادر",
|
||||||
|
"subtitle": "أرسل الدعوات ورموز كلمة المرور من عنوانك بدلًا من عنواننا. الرسائل التي تتحدث عن نطاقك وتصل من مرسِل غير معروف هي ما تبحث عنه مرشِّحات البريد المزعج.",
|
||||||
|
"saved": "حُفظت الإعدادات",
|
||||||
|
"cleared": "عُدنا إلى حساب المنصّة",
|
||||||
|
"status": {
|
||||||
|
"none": "لا يوجد إعداد. نرسل كل شيء من حسابنا، وهو يعمل لكنه يعني أن رسائل الحسابات تصل أفرادك من مرسِل لا يعرفونه.",
|
||||||
|
"unverified": "محفوظ ولم يُتحقق منه. أرسل رسالة تجريبية لتفعيله — وحتى ذلك الحين نواصل الإرسال من حسابنا، فلا شيء معطّل.",
|
||||||
|
"active": "مفعّل. جرى التحقق {{when}}."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"host": "مضيف SMTP",
|
||||||
|
"port": "المنفذ",
|
||||||
|
"user": "اسم المستخدم",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordReplace": "استبدال كلمة المرور",
|
||||||
|
"passwordNote": "توجد كلمة مرور مخزَّنة. اترك الحقل فارغًا للإبقاء عليها — لا يمكننا عرضها لك.",
|
||||||
|
"ssl": "الاتصال عبر SSL (المنفذ ٤٦٥ عادةً)",
|
||||||
|
"fromAddress": "عنوان المرسِل",
|
||||||
|
"fromName": "اسم المرسِل",
|
||||||
|
"spfNote": "لتصل الرسائل بثبات، تأكد أن هذا المضيف مسموح له بالإرسال باسم نطاقك في سجل SPF، وأن DKIM يوقّع له.",
|
||||||
|
"save": "حفظ",
|
||||||
|
"clear": "استخدام حسابنا بدلًا من ذلك"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"title": "إرسال رسالة تجريبية",
|
||||||
|
"note": "نجاح الاختبار هو ما يفعّل هذه الإعدادات. الحفظ وحده لا يفعّلها، فلا يستطيع خطأ مطبعي أن يوقف وصول دعواتك بصمت.",
|
||||||
|
"to": "أرسلها إلى",
|
||||||
|
"send": "إرسال"
|
||||||
|
},
|
||||||
|
"confirmClear": {
|
||||||
|
"title": "العودة إلى حساب المنصّة؟",
|
||||||
|
"message": "ستُحذف إعداداتك ونعود إلى الإرسال من عنواننا. يمكنك إعدادها مجددًا في أي وقت."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"saveFailed": "تعذّر حفظ الإعدادات",
|
||||||
|
"loadFailed": "تعذّر تحميل إعدادات البريد الآن."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"title": "الدعوات",
|
||||||
|
"subtitle": "ادعُ الأشخاص ليختاروا كلمة مرورهم بأنفسهم بدلًا من أن تختارها لهم.",
|
||||||
|
"invite": "دعوة شخص",
|
||||||
|
"resend": "إعادة الإرسال",
|
||||||
|
"empty": "لا توجد دعوات بعد.",
|
||||||
|
"expiresOn": "تنتهي في {{when}}",
|
||||||
|
"acceptedOn": "قُبلت في {{when}}",
|
||||||
|
"state": {
|
||||||
|
"pending": "بانتظار الرد",
|
||||||
|
"accepted": "مقبولة",
|
||||||
|
"expired": "منتهية",
|
||||||
|
"revoked": "مسحوبة"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"firstName": "الاسم الأول",
|
||||||
|
"lastName": "اسم العائلة",
|
||||||
|
"role": "الدور",
|
||||||
|
"explain": "سيصله رابط يعمل مرة واحدة وتنتهي صلاحيته خلال سبعة أيام. سيختار كلمة مروره بنفسه، ولن تعرفها أنت.",
|
||||||
|
"submit": "إرسال الدعوة"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"title": "أُرسلت الدعوة",
|
||||||
|
"sent": "أرسلنا رابطًا إلى {{email}}.",
|
||||||
|
"notSent": "دعوة {{email}} جاهزة، لكن تعذّر إرسالها بالبريد.",
|
||||||
|
"notSentExplain": "الدعوة نفسها صالحة. انسخ الرابط أدناه وأرسله بنفسك.",
|
||||||
|
"warning": "أي شخص يحمل هذا الرابط يستطيع الانضمام إلى مساحة العمل بهذا العنوان، فأرسله كما ترسل كلمة مرور.",
|
||||||
|
"done": "تم"
|
||||||
|
},
|
||||||
|
"confirmRevoke": {
|
||||||
|
"title": "سحب هذه الدعوة؟",
|
||||||
|
"message": "سيتوقف الرابط المُرسل إلى {{email}} عن العمل. يمكنك دعوته مجددًا بعد ذلك.",
|
||||||
|
"confirm": "سحب"
|
||||||
|
},
|
||||||
|
"accept": {
|
||||||
|
"title": "الانضمام إلى {{workspace}}",
|
||||||
|
"forAddress": "هذه الدعوة موجّهة إلى {{email}}.",
|
||||||
|
"password": "اختر كلمة مرور",
|
||||||
|
"confirm": "تأكيد كلمة المرور",
|
||||||
|
"privacy": "لن يعرف أحد كلمة المرور هذه — ولا حتى من دعاك.",
|
||||||
|
"mismatch": "كلمتا المرور غير متطابقتين.",
|
||||||
|
"submit": "إنشاء حسابي",
|
||||||
|
"invalidTitle": "هذا الرابط غير صالح",
|
||||||
|
"invalidBody": "ربما انتهت صلاحيته أو استُخدم أو سُحب. اطلب رابطًا جديدًا ممن دعاك.",
|
||||||
|
"doneTitle": "حسابك جاهز",
|
||||||
|
"doneBody": "سجّل الدخول بالعنوان الذي دُعيت به وبكلمة المرور التي اخترتها للتو.",
|
||||||
|
"toSignIn": "الذهاب إلى تسجيل الدخول"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"loadFailed": "تعذّر تحميل الدعوات الآن."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"title": "الإشعارات",
|
||||||
|
"markAll": "تعليم الكل كمقروء",
|
||||||
|
"empty": "لا يوجد ما يُبلَّغ عنه.",
|
||||||
|
"loadFailed": "تعذّر تحميل الإشعارات الآن.",
|
||||||
|
"aria": {
|
||||||
|
"none": "الإشعارات",
|
||||||
|
"withCount_zero": "الإشعارات، لا يوجد غير مقروء",
|
||||||
|
"withCount_one": "الإشعارات، إشعار واحد غير مقروء",
|
||||||
|
"withCount_two": "الإشعارات، إشعاران غير مقروءين",
|
||||||
|
"withCount_few": "الإشعارات، {{count}} إشعارات غير مقروءة",
|
||||||
|
"withCount_many": "الإشعارات، {{count}} إشعارًا غير مقروء",
|
||||||
|
"withCount_other": "الإشعارات، {{count}} إشعار غير مقروء",
|
||||||
|
"unread": "غير مقروء"
|
||||||
|
},
|
||||||
|
"preferences": {
|
||||||
|
"title": "ما الذي يتم إشعارك به",
|
||||||
|
"description": "كل نوع مُفعَّل ما لم تقم بإيقافه. إيقافه يوقف الإشعار لا الحدث نفسه — فهو يُسجَّل في سجل التدقيق على أي حال.",
|
||||||
|
"security": "أمان",
|
||||||
|
"loadFailed": "تعذّر تحميل تفضيلاتك الآن.",
|
||||||
|
"saveFailed": "لم يتم الحفظ. لم يتغيّر شيء."
|
||||||
|
},
|
||||||
|
"kinds": {
|
||||||
|
"security.account_locked": {
|
||||||
|
"title": "حسابك مقفل",
|
||||||
|
"description": "محاولات تسجيل دخول فاشلة متتالية أكثر من اللازم."
|
||||||
|
},
|
||||||
|
"security.mfa_enabled": {
|
||||||
|
"title": "تم تفعيل المصادقة الثنائية",
|
||||||
|
"description": "قام أحدهم بإضافة عامل تحقق ثانٍ إلى حسابك."
|
||||||
|
},
|
||||||
|
"security.mfa_disabled": {
|
||||||
|
"title": "تم إيقاف المصادقة الثنائية",
|
||||||
|
"description": "قام أحدهم بإزالة عامل التحقق الثاني من حسابك."
|
||||||
|
},
|
||||||
|
"api_key.issued": {
|
||||||
|
"title": "تم إصدار مفتاح واجهة برمجة",
|
||||||
|
"description": "تم إنشاء مفتاح جديد يمكنه التصرف باسم مساحة العمل هذه."
|
||||||
|
},
|
||||||
|
"webhook.disabled": {
|
||||||
|
"title": "توقّف أحد الـ webhooks",
|
||||||
|
"description": "فشلت نقطة النهاية مرات كافية فتوقّفنا عن الإرسال إليها."
|
||||||
|
},
|
||||||
|
"invitation.accepted": {
|
||||||
|
"title": "تم قبول دعوة",
|
||||||
|
"description": "انضم إلى مساحة العمل شخص قمت بدعوته."
|
||||||
|
},
|
||||||
|
"subscription.expiring": {
|
||||||
|
"title": "الاشتراك على وشك الانتهاء",
|
||||||
|
"description": "اشتراك مساحة العمل ينتهي قريبًا."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"title": "العمليات",
|
||||||
|
"subtitle": "ما الذي كانت تقوم به المهام الخلفية. عبر جميع مساحات العمل.",
|
||||||
|
"loadError": "تعذّر تحميل ملخص العمليات.",
|
||||||
|
"figures": {
|
||||||
|
"noBillingContact": "لا يوجد مسؤول فوترة",
|
||||||
|
"noBillingContactHint": "مساحات عمل لها تاريخ انتهاء ولا أحد لتحذيره قبله",
|
||||||
|
"stuckEvents": "أحداث متعثرة",
|
||||||
|
"stuckEventsHint": "متأخرة وأُعيدت محاولتها — إحدى الوحدات لا تستقبل التسليمات",
|
||||||
|
"tokenReuse": "اكتُشف إعادة استخدام رمز",
|
||||||
|
"tokenReuseHint": "قُدّم رمز تحديث بعد أن استهلكه العميل الأصلي بالفعل"
|
||||||
|
},
|
||||||
|
"panels": {
|
||||||
|
"notices": "إشعارات الاشتراك (آخر {{days}} يوماً)",
|
||||||
|
"noNotices": "لم يُرسل شيء في هذه الفترة.",
|
||||||
|
"recordedNotSent": "{{count}} مسجّلة ولم تُرسل: لا يوجد عنوان فوترة لمساحة العمل.",
|
||||||
|
"outbox": "صندوق صادر الأحداث",
|
||||||
|
"noEvents": "لا توجد أحداث.",
|
||||||
|
"oldestPending": "أقدم حدث معلّق: {{when}}",
|
||||||
|
"sessions": "الجلسات",
|
||||||
|
"activeSessions": "نشطة",
|
||||||
|
"noEndedSessions": "لم تنتهِ أي جلسة.",
|
||||||
|
"failingTargets": "وجهات تسليم فاشلة",
|
||||||
|
"noFailingTargets": "يجري تسليم كل شيء.",
|
||||||
|
"recent": "أحدث الإشعارات",
|
||||||
|
"auditRetention": "الاحتفاظ بسجل التدقيق",
|
||||||
|
"auditEntries": "قيد تدقيق",
|
||||||
|
"pastRetention_zero": "لا شيء تجاوز مدّته.",
|
||||||
|
"pastRetention_one": "قيد واحد تجاوز مدّته وينتظر التنظيف.",
|
||||||
|
"pastRetention_other": "{{count}} قيود تجاوزت مدّتها وتنتظر التنظيف.",
|
||||||
|
"retentionWindow": "يُحفظ {{days}} يومًا؛ وقيود الأمان {{securityDays}} يومًا.",
|
||||||
|
"oldestEntry": "أقدم قيد: {{when}}",
|
||||||
|
"retentionUnavailable": "الاحتفاظ لا يُبلِّغ. فهو يتصل بدور قاعدة بيانات خاص به، وقد لا يكون مهيّأً بعد."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"workspace": "مساحة العمل",
|
||||||
|
"kind": "الإشعار",
|
||||||
|
"sentTo": "أُرسل إلى",
|
||||||
|
"sentAt": "التاريخ",
|
||||||
|
"nobody": "لا أحد لإبلاغه"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"open": "{{count}} تنبيه(ات) نشطة الآن",
|
||||||
|
"undelivered": "— تعذّر تسليمه إلى أي جهة"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"title": "الهيكل التنظيمي",
|
||||||
|
"subtitle": "الأقسام والفروع والفِرق — ومسؤول لكل منها إن أردت.",
|
||||||
|
"add": "إضافة وحدة",
|
||||||
|
"empty": "لا توجد وحدات بعد. أضف واحدة لتجميع الأشخاص حسب الفرع أو القسم أو الفريق.",
|
||||||
|
"form": {
|
||||||
|
"name": "الاسم",
|
||||||
|
"namePlaceholder": "فرع لاهور، الهندسة، المبيعات…",
|
||||||
|
"code": "الرمز المختصر",
|
||||||
|
"codeNote": "اختياري. معرّف ثابت تستخدمه أنظمتك للإشارة إلى هذه الوحدة دون معرفة معرّفاتنا.",
|
||||||
|
"parent": "تتبع",
|
||||||
|
"topLevel": "لا شيء — المستوى الأعلى",
|
||||||
|
"submit": "إنشاء الوحدة"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"open": "الأشخاص",
|
||||||
|
"empty": "لا أحد في هذه الوحدة بعد.",
|
||||||
|
"add": "إضافة شخص",
|
||||||
|
"addButton": "إضافة",
|
||||||
|
"person": "الشخص",
|
||||||
|
"lead": "المسؤول"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"grant": "تحديد النطاق هنا",
|
||||||
|
"revoke": "إلغاء النطاق",
|
||||||
|
"explain": "قصر إدارة هذا الشخص للمستخدمين على هذه الوحدة وما تحتها.",
|
||||||
|
"note": "تحديد النطاق لا يفعل سوى التقليص. من لا نطاق له يدير مساحة العمل كاملة — وهو الوضع الافتراضي لكل مسؤول — فإزالة آخر نطاق تعيد له ذلك.",
|
||||||
|
"title": "المسؤولون",
|
||||||
|
"person": "الشخص"
|
||||||
|
},
|
||||||
|
"confirmRemove": {
|
||||||
|
"title": "حذف هذه الوحدة؟",
|
||||||
|
"message": "ستُحذف {{name}}. يُرفض الحذف ما دامت تضم وحدات تحتها أو أشخاصًا فيها — انقلهم أولًا.",
|
||||||
|
"confirm": "حذف"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"loadFailed": "تعذّر تحميل الهيكل التنظيمي الآن."
|
||||||
|
},
|
||||||
|
"seats": {
|
||||||
|
"purchased": "المقاعد المشتراة",
|
||||||
|
"allocated": "المخصّصة للفروع",
|
||||||
|
"unallocated": "المتبقّي للتوزيع",
|
||||||
|
"unlimited": "لا حدّ للمقاعد في هذه الباقة",
|
||||||
|
"edit": "تحديد حدّ مقاعد هذا الفرع",
|
||||||
|
"usedUncapped_one": "عضو واحد",
|
||||||
|
"usedUncapped_other": "{{count}} أعضاء",
|
||||||
|
"usedOfLimit": "{{used}} من {{limit}}",
|
||||||
|
"title": "مقاعد {{name}}",
|
||||||
|
"explanation": "عدد مقاعد مساحة العمل التي يجوز لهذا الفرع استخدامها. الفروع بلا حدّ مقيَّدة بحدّ مساحة العمل وحده.",
|
||||||
|
"limit": "حدّ المقاعد",
|
||||||
|
"uncapped": "بلا حدّ",
|
||||||
|
"emptyMeansUncapped": "اتركه فارغًا لإزالة الحدّ. وهذا يختلف عن 0، الذي يعني ألّا يكون في هذا الفرع أحد.",
|
||||||
|
"currentUse_one": "عضو واحد حاليًا",
|
||||||
|
"currentUse_other": "{{count}} أعضاء حاليًا",
|
||||||
|
"availableToThisUnit_one": "مقعد واحد متاح",
|
||||||
|
"availableToThisUnit_other": "{{count}} مقاعد متاحة",
|
||||||
|
"notAWholeNumber": "أدخل عددًا صحيحًا، أو اتركه فارغًا لإزالة الحدّ.",
|
||||||
|
"saveFailed": "لم يتم الحفظ."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"accountInfo": "معلومات السجل",
|
"accountInfo": "معلومات السجل",
|
||||||
"appearance": "المظهر",
|
"appearance": "المظهر",
|
||||||
"appearanceDesc": "تخصيص مظهر التطبيق"
|
"appearanceDesc": "تخصيص مظهر التطبيق",
|
||||||
|
"sessions": "الجلسات النشطة",
|
||||||
|
"sessionsDesc": "الأماكن التي سُجّل الدخول منها إلى هذا الحساب. أنهِ أي جلسة لا تعرفها."
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"firstName": "الاسم الأول",
|
"firstName": "الاسم الأول",
|
||||||
@@ -15,13 +17,17 @@
|
|||||||
"role": "الدور",
|
"role": "الدور",
|
||||||
"accountCreated": "تاريخ إنشاء الحساب",
|
"accountCreated": "تاريخ إنشاء الحساب",
|
||||||
"lastUpdated": "آخر تحديث",
|
"lastUpdated": "آخر تحديث",
|
||||||
"unknown": "غير معروف"
|
"unknown": "غير معروف",
|
||||||
|
"thisDevice": "هذا الجهاز",
|
||||||
|
"unknownDevice": "جهاز غير معروف"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"changePassword": "تغيير كلمة المرور",
|
"changePassword": "تغيير كلمة المرور",
|
||||||
"editProfile": "تعديل الملف الشخصي",
|
"editProfile": "تعديل الملف الشخصي",
|
||||||
"updatePassword": "تحديث كلمة المرور",
|
"updatePassword": "تحديث كلمة المرور",
|
||||||
"saveChanges": "حفظ التغييرات"
|
"saveChanges": "حفظ التغييرات",
|
||||||
|
"endSession": "إنهاء الجلسة",
|
||||||
|
"signOutOthers": "تسجيل الخروج من الأجهزة الأخرى"
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"changePasswordTitle": "تغيير كلمة المرور",
|
"changePasswordTitle": "تغيير كلمة المرور",
|
||||||
@@ -43,7 +49,9 @@
|
|||||||
"passwordLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
|
"passwordLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
|
||||||
"requiredFields": "جميع الحقول مطلوبة",
|
"requiredFields": "جميع الحقول مطلوبة",
|
||||||
"firstNameRequired": "الاسم الأول مطلوب",
|
"firstNameRequired": "الاسم الأول مطلوب",
|
||||||
"loadError": "تعذر تحميل الملف الشخصي للمستخدم. يرجى محاولة تحديث الصفحة."
|
"loadError": "تعذر تحميل الملف الشخصي للمستخدم. يرجى محاولة تحديث الصفحة.",
|
||||||
|
"noSessions": "لا توجد جلسات نشطة.",
|
||||||
|
"loadFailed": "تعذّر تحميل جلساتك. حدّث الصفحة للمحاولة مجدداً."
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"profileSettings": "إعدادات الملف الشخصي",
|
"profileSettings": "إعدادات الملف الشخصي",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"title": "البيانات المرجعية",
|
||||||
|
"subtitle": "القوائم التي تتكوّن منها قوائمك المنسدلة — أنواع المستندات، مراكز التكلفة، وكل ما تختار منه مساحة عملك.",
|
||||||
|
"add": "قائمة جديدة",
|
||||||
|
"empty": "لا توجد قوائم بعد.",
|
||||||
|
"platform": {
|
||||||
|
"badge": "قياسية",
|
||||||
|
"extendable": "هذه قائمة قياسية نتولّى صيانتها. لا يمكنك تعديل محتواها، لكن يمكنك إضافة مدخلات خاصة بك — تخصّك وحدك ولا تراها أي مساحة عمل أخرى.",
|
||||||
|
"closed": "هذه قائمة قياسية نتولّى صيانتها. لا يمكن تعديلها ولا الإضافة إليها لأن قيمها محدّدة بمعيار خارج هذا المنتج."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "الاسم",
|
||||||
|
"namePlaceholder": "مراكز التكلفة، أنواع الإجازات…",
|
||||||
|
"code": "الرمز",
|
||||||
|
"codeNote": "الطريقة التي تشير بها تكاملاتك وتقاريرك إلى هذه القائمة. لا يمكن تغييره لاحقًا — فكل ما يشير إليها سيتوقف عن العمل.",
|
||||||
|
"submit": "إنشاء القائمة"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"open": "المدخلات",
|
||||||
|
"empty": "لا توجد مدخلات في هذه القائمة بعد.",
|
||||||
|
"add": "إضافة مدخل",
|
||||||
|
"addButton": "إضافة",
|
||||||
|
"label": "التسمية",
|
||||||
|
"code": "الرمز",
|
||||||
|
"codeNote": "التسمية هي ما يقرأه الناس ويمكن تغييرها في أي وقت. أما الرمز فهو ما تشير إليه السجلات ولا يمكن تغييره.",
|
||||||
|
"retired": "متقاعد",
|
||||||
|
"restore": "استعادة",
|
||||||
|
"showRetired": "إظهار المدخلات المتقاعدة"
|
||||||
|
},
|
||||||
|
"confirmDelete": {
|
||||||
|
"title": "حذف هذه القائمة؟",
|
||||||
|
"message": "ستُحذف {{name}}. يُرفض الحذف ما دامت تضم مدخلات — أحِلها إلى التقاعد أو احذفها أولًا.",
|
||||||
|
"confirm": "حذف"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"loadFailed": "تعذّر تحميل البيانات المرجعية الآن."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"title": "المصادقة الثنائية",
|
||||||
|
"subtitle": "كلمة المرور يُعاد استخدامها وتُسرَّب من مواقع أخرى. أما الرمز الذي يظهر على هاتفك فلا تتجاوز صلاحيته ثوانٍ معدودة.",
|
||||||
|
"state": {
|
||||||
|
"on": "مُفعّلة",
|
||||||
|
"off": "غير مُفعّلة",
|
||||||
|
"pendingWarning": "بدأت الإعداد ولم تُكمله. حسابك غير محمي إلى أن تُدخل رمزًا من تطبيق المصادقة."
|
||||||
|
},
|
||||||
|
"off": {
|
||||||
|
"explain": "ستحتاج إلى تطبيق مصادقة — مثل Google Authenticator أو Microsoft Authenticator أو 1Password أو Authy.",
|
||||||
|
"start": "إعداد المصادقة الثنائية"
|
||||||
|
},
|
||||||
|
"enrol": {
|
||||||
|
"step1": "امسح هذا الرمز بتطبيق المصادقة.",
|
||||||
|
"qrAlt": "رمز الاستجابة السريعة لتطبيق المصادقة",
|
||||||
|
"manual": "أو أدخل هذا المفتاح يدويًا:",
|
||||||
|
"step2": "ثم أدخل الرمز الظاهر في التطبيق. إن كان الرمز قد تغيّر للتو، استخدم الجديد.",
|
||||||
|
"codeLabel": "الرمز من التطبيق",
|
||||||
|
"confirm": "تفعيل"
|
||||||
|
},
|
||||||
|
"on": {
|
||||||
|
"recoveryRemaining_zero": "لم يتبقَّ أي رمز استرداد.",
|
||||||
|
"recoveryRemaining_one": "بقي رمز استرداد واحد.",
|
||||||
|
"recoveryRemaining_two": "بقي رمزا استرداد.",
|
||||||
|
"recoveryRemaining_few": "بقيت {{count}} رموز استرداد.",
|
||||||
|
"recoveryRemaining_many": "بقي {{count}} رمز استرداد.",
|
||||||
|
"recoveryRemaining_other": "بقي {{count}} رمز استرداد.",
|
||||||
|
"runningLow": "أوشكت رموز الاسترداد على النفاد. أنشئ مجموعة جديدة وهاتفك ما زال بحوزتك — فقدانه من دونها يعني مراجعة الدعم.",
|
||||||
|
"regenerate": "رموز استرداد جديدة",
|
||||||
|
"disable": "إيقاف"
|
||||||
|
},
|
||||||
|
"recovery": {
|
||||||
|
"title": "رموز الاسترداد",
|
||||||
|
"explain": "احتفظ بها في مكان آمن بعيدًا عن هاتفك. كل رمز يُستخدم مرة واحدة، وهي وسيلتك للدخول إذا فقدت جهازك. هذه هي المرة الوحيدة التي تُعرض فيها — فهي مخزَّنة مشفّرة ولا يمكن استرجاعها.",
|
||||||
|
"copy": "نسخ",
|
||||||
|
"download": "تنزيل",
|
||||||
|
"acknowledge": "حفظتُها في مكان آمن.",
|
||||||
|
"done": "تم"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"disableExplain": "سيؤدي هذا إلى إزالة العامل الثاني من حسابك، وستكفي كلمة المرور وحدها لتسجيل الدخول.",
|
||||||
|
"regenerateExplain": "سيستبدل هذا المجموعة بالكامل، وستتوقف أي رموز في قائمتك القديمة عن العمل.",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"code": "الرمز من التطبيق",
|
||||||
|
"codePlaceholder": "رمز من ٦ أرقام أو رمز استرداد",
|
||||||
|
"submit": "تأكيد"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"badCode": "الرمز غير صحيح.",
|
||||||
|
"loadFailed": "تعذّر التحقق من إعدادات الأمان الآن."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"title": "تسجيل الدخول والتزويد",
|
||||||
|
"subtitle": "دع الأشخاص يسجّلون الدخول بحسابات مؤسستك، وأنشئ الحسابات وألغِها تلقائيًا من دليلك.",
|
||||||
|
"add": "إضافة اتصال",
|
||||||
|
"empty": "لا توجد اتصالات بعد. أضف واحدًا ليتمكن أفرادك من تسجيل الدخول بحسابات مؤسستك.",
|
||||||
|
"on": "مفعّل",
|
||||||
|
"off": "متوقف",
|
||||||
|
"rediscover": "تحديث",
|
||||||
|
"noSecret": "لا يوجد مفتاح سري",
|
||||||
|
"notDiscovered": "لم يُتحقق منه بعد",
|
||||||
|
"noIssuer": "لم يُحدَّد المُصدِر",
|
||||||
|
"domains": {
|
||||||
|
"any": "أي عنوان يضمنه المزوّد",
|
||||||
|
"limited": "{{domains}} فقط"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"editTitle": "تعديل الاتصال",
|
||||||
|
"name": "الاسم",
|
||||||
|
"slug": "الاسم المختصر",
|
||||||
|
"slugNote": "يصبح جزءًا من رابط تسجيل الدخول، فلا يمكن تغييره لاحقًا — وأي رابط وزّعته سيتوقف عن العمل.",
|
||||||
|
"issuer": "رابط المُصدِر",
|
||||||
|
"issuerNote": "نقرأ إعدادات المزوّد من هنا، فلا تُدخَل نقاط النهاية يدويًا. يجب أن يكون https.",
|
||||||
|
"clientId": "معرّف العميل",
|
||||||
|
"secret": "المفتاح السري",
|
||||||
|
"secretReplace": "استبدال المفتاح السري",
|
||||||
|
"secretNote": "يوجد مفتاح مخزَّن. اترك الحقل فارغًا للإبقاء عليه — لا يمكننا عرضه لك.",
|
||||||
|
"domains": "نطاقات البريد المسموح بها",
|
||||||
|
"domainsNote": "افصل بينها بفواصل. اتركه فارغًا لقبول كل من يضمنه المزوّد، وهو ما يعني لدى مزوّد عام: أي شخص.",
|
||||||
|
"jit": "إنشاء الحسابات تلقائيًا عند أول تسجيل دخول",
|
||||||
|
"linkByEmail": "السماح لتسجيل دخول جديد بالمطالبة بحساب قائم بالعنوان نفسه",
|
||||||
|
"linkByEmailNote": "معطّل افتراضيًا. العنوان يُعاد تخصيصه عند مغادرة شخص، والمطابقة عليه تسلّم خلفه الحسابَ القديم. ولا تُطابَق إلا العناوين المُتحقَّق منها.",
|
||||||
|
"create": "إضافة الاتصال",
|
||||||
|
"save": "حفظ التغييرات"
|
||||||
|
},
|
||||||
|
"scim": {
|
||||||
|
"title": "التزويد التلقائي (SCIM)",
|
||||||
|
"subtitle": "وجّه Okta أو Entra ID أو OneLogin إلى هذا الرابط لتُنشأ الحسابات وتُحدَّث وتُعطَّل هنا مع تغيّرها في دليلك. هذا ما يمنع بقاء حسابات المغادرين.",
|
||||||
|
"baseUrl": "رابط SCIM الأساسي",
|
||||||
|
"token": "استخدم مفتاح واجهة برمجية كرمز حامل — لا توجد بيانات اعتماد منفصلة لـ SCIM.",
|
||||||
|
"step1": "أصدر مفتاحًا بصلاحيات قراءة المستخدمين وإنشائهم وتحديثهم وحذفهم، وقراءة الأدوار.",
|
||||||
|
"step2": "الصق الرابط الأساسي والمفتاح في إعدادات التزويد لدى دليلك.",
|
||||||
|
"step3": "إبطال المفتاح يوقف المزامنة، وسجل التدقيق يوثّق أي مفتاح أجرى كل تغيير."
|
||||||
|
},
|
||||||
|
"confirmRemove": {
|
||||||
|
"title": "إزالة هذا الاتصال؟",
|
||||||
|
"message": "لن يتمكن الأشخاص من تسجيل الدخول عبر {{name}}. تبقى الحسابات المُنشأة كما هي.",
|
||||||
|
"confirm": "إزالة"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"loadFailed": "تعذّر تحميل الاتصالات الآن."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,5 +62,15 @@
|
|||||||
"tenantsLoadError": "تعذر تحميل المستأجرين.",
|
"tenantsLoadError": "تعذر تحميل المستأجرين.",
|
||||||
"rolesLoadError": "تعذر تحميل الأدوار.",
|
"rolesLoadError": "تعذر تحميل الأدوار.",
|
||||||
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا المستخدم؟"
|
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا المستخدم؟"
|
||||||
|
},
|
||||||
|
"invite": "دعوة",
|
||||||
|
"deleted": {
|
||||||
|
"title": "المحذوفون مؤخرًا",
|
||||||
|
"empty": "لم يُحذف أحد مؤخرًا.",
|
||||||
|
"on": "حُذف في {{when}}",
|
||||||
|
"restore": "استعادة",
|
||||||
|
"restored": "استُعيد الحساب",
|
||||||
|
"restoreFailed": "تعذّرت استعادة الحساب",
|
||||||
|
"loadFailed": "تعذّر تحميل الحسابات المحذوفة الآن."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"title": "الويب هوك",
|
||||||
|
"subtitle": "دع المنصة تُبلغ أنظمتك بما يحدث بدلًا من أن تستعلم منّا باستمرار.",
|
||||||
|
"add": "إضافة وجهة",
|
||||||
|
"test": "إرسال اختبار",
|
||||||
|
"rotate": "تدوير المفتاح",
|
||||||
|
"empty": "لا توجد وجهات بعد. أضف واحدة وسنرسل حدثًا موقّعًا كلما وقع ما يهمّك.",
|
||||||
|
"neverDelivered": "لم يُسلَّم شيء بعد",
|
||||||
|
"lastSuccess": "آخر تسليم {{when}}",
|
||||||
|
"events": {
|
||||||
|
"all": "كل الأحداث",
|
||||||
|
"some_zero": "لا أنواع أحداث",
|
||||||
|
"some_one": "نوع حدث واحد",
|
||||||
|
"some_two": "نوعا أحداث",
|
||||||
|
"some_few": "{{count}} أنواع أحداث",
|
||||||
|
"some_many": "{{count}} نوع حدث",
|
||||||
|
"some_other": "{{count}} نوع حدث"
|
||||||
|
},
|
||||||
|
"disabled": {
|
||||||
|
"generic": "هذه الوجهة موقوفة، ولا يُرسَل إليها شيء.",
|
||||||
|
"reEnable": "إعادة تشغيلها"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"url": "رابط الوجهة",
|
||||||
|
"urlNote": "يجب أن يكون https ويمكن الوصول إليه من الإنترنت. نتحقق منه الآن ومع كل عملية تسليم، فالمضيف الذي يتعذّر الوصول إليه يتوقف عن استقبال الأحداث.",
|
||||||
|
"description": "ما الغرض منها؟",
|
||||||
|
"descriptionPlaceholder": "التزويد، مزامنة إدارة العملاء…",
|
||||||
|
"events": "أي الأحداث؟",
|
||||||
|
"eventsNote": "اترك الاختيار فارغًا لاستقبال كل شيء. الاسم غير المعروف يُرفض بدل قبوله بصمت، لأن انتظار حدث لا يمكن أن يصل يبدو تمامًا كأن شيئًا لم يحدث.",
|
||||||
|
"submit": "إضافة الوجهة"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"title": "مفتاح التوقيع",
|
||||||
|
"explain": "ضع هذا في المستقبِل لديك للتحقق من توقيع كل عملية تسليم. راجع وثائق الويب هوك لمعرفة الطريقة — وهي HMAC-SHA256 على الطابع الزمني ونص الطلب الخام.",
|
||||||
|
"acknowledge": "نسختُ المفتاح إلى مكان آمن.",
|
||||||
|
"done": "تم"
|
||||||
|
},
|
||||||
|
"deliveries": {
|
||||||
|
"open": "عمليات التسليم",
|
||||||
|
"title": "أحدث عمليات التسليم",
|
||||||
|
"empty": "لم يُرسَل شيء إلى هذه الوجهة بعد.",
|
||||||
|
"attempts_zero": "بلا محاولات",
|
||||||
|
"attempts_one": "محاولة واحدة",
|
||||||
|
"attempts_two": "محاولتان",
|
||||||
|
"attempts_few": "{{count}} محاولات",
|
||||||
|
"attempts_many": "{{count}} محاولة",
|
||||||
|
"attempts_other": "{{count}} محاولة",
|
||||||
|
"status": {
|
||||||
|
"delivered": "تم التسليم",
|
||||||
|
"pending": "إعادة المحاولة",
|
||||||
|
"failed": "توقّفت المحاولات"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testResult": {
|
||||||
|
"title": "تسليم تجريبي",
|
||||||
|
"ok": "استجابت وجهتك بالرمز {{status}}. إنها تعمل.",
|
||||||
|
"failed": "لم تقبل وجهتك التسليم."
|
||||||
|
},
|
||||||
|
"confirmRemove": {
|
||||||
|
"title": "إزالة هذه الوجهة؟",
|
||||||
|
"message": "سنتوقف عن إرسال الأحداث إلى {{url}}، وسيُحذف سجل التسليم معها.",
|
||||||
|
"confirm": "إزالة"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "تعذّر إتمام العملية. حاول مرة أخرى.",
|
||||||
|
"loadFailed": "تعذّر تحميل الوجهات الآن."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"title": "API keys",
|
||||||
|
"subtitle": "A credential that belongs to an integration rather than to a person. It can do only what you can do, and it stops working the moment your account does.",
|
||||||
|
"issue": "Issue a key",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"empty": "No keys yet. Issue one to let a script or an integration act on this workspace.",
|
||||||
|
"neverUsed": "Never used",
|
||||||
|
"lastUsed": "Last used {{when}}",
|
||||||
|
"expires": "Expires {{when}}",
|
||||||
|
"state": {
|
||||||
|
"active": "Active",
|
||||||
|
"expired": "Expired",
|
||||||
|
"revoked": "Revoked"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"inherited": "Full access — whatever you can do",
|
||||||
|
"limited_one": "Limited to {{count}} permission",
|
||||||
|
"limited_other": "Limited to {{count}} permissions"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "What is it for?",
|
||||||
|
"namePlaceholder": "Nightly import, CI deploy, Okta sync…",
|
||||||
|
"expiry": "Expires after (days)",
|
||||||
|
"expiryPlaceholder": "Leave blank for no expiry",
|
||||||
|
"scopeNote": "The key acts with your permissions. If your access is reduced or your account is disabled, the key narrows with it.",
|
||||||
|
"submit": "Issue the key"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"title": "Your new key",
|
||||||
|
"explain": "Copy this now. It is stored hashed, so this is the only time it can be shown — if you lose it you will have to revoke this key and issue another.",
|
||||||
|
"acknowledge": "I have copied the key somewhere safe.",
|
||||||
|
"done": "Done"
|
||||||
|
},
|
||||||
|
"confirmRevoke": {
|
||||||
|
"title": "Revoke this key?",
|
||||||
|
"message": "Anything using \"{{name}}\" stops working immediately. This cannot be undone — issue a new key instead."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "That did not work. Please try again.",
|
||||||
|
"loadFailed": "We could not load your keys just now."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,15 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"configuration": "Configuration",
|
"configuration": "Configuration",
|
||||||
"dropdowns": "Dropdowns",
|
"dropdowns": "Dropdowns",
|
||||||
"modules": "Modules"
|
"modules": "Modules",
|
||||||
|
"operations": "Operations",
|
||||||
|
"apiKeys": "API keys",
|
||||||
|
"webhooks": "Webhooks",
|
||||||
|
"signIn": "Sign-in",
|
||||||
|
"organisation": "Organisation",
|
||||||
|
"email": "Email",
|
||||||
|
"reference": "Reference data",
|
||||||
|
"documents": "Documents"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -112,5 +120,13 @@
|
|||||||
"createSuccess": "Created successfully",
|
"createSuccess": "Created successfully",
|
||||||
"error": "An error occurred. Please try again.",
|
"error": "An error occurred. Please try again.",
|
||||||
"confirmDelete": "Are you sure you want to delete this item?"
|
"confirmDelete": "Are you sure you want to delete this item?"
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"graceUntil": "Your subscription has lapsed. The workspace is read-only until {{date}} — renew before then to keep working.",
|
||||||
|
"grace": "Your subscription has lapsed. The workspace is read-only until it is renewed.",
|
||||||
|
"expired": "Your subscription has expired. Renew it to restore access.",
|
||||||
|
"cancelled": "This subscription has been cancelled. Contact your administrator to restore access.",
|
||||||
|
"suspended": "This workspace has been suspended. Contact your administrator.",
|
||||||
|
"inactive": "This workspace is not currently active."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Documents",
|
||||||
|
"pageSubtitle": "Files that belong to the workspace rather than to any one record.",
|
||||||
|
"title": "Attachments",
|
||||||
|
"upload": "Upload",
|
||||||
|
"uploaded": "File uploaded",
|
||||||
|
"deleted": "File deleted",
|
||||||
|
"empty": "Nothing attached yet.",
|
||||||
|
"accepted": "PDFs, images, Word and Excel documents, plain text and CSV.",
|
||||||
|
"usage": "{{used}} of {{quota}} used",
|
||||||
|
"nearlyFull": "You are nearly out of document storage. Delete something to make room before uploading anything large.",
|
||||||
|
"noWorkspace": "This account is not associated with a workspace.",
|
||||||
|
"confirmDelete": {
|
||||||
|
"title": "Delete this file?",
|
||||||
|
"message": "{{name}} will be removed and the space it uses freed. The record that it was deleted, and by whom, is kept.",
|
||||||
|
"confirm": "Delete"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "That did not work. Please try again.",
|
||||||
|
"tooLarge": "That file is larger than {{limit}}.",
|
||||||
|
"uploadFailed": "Could not upload that file",
|
||||||
|
"downloadFailed": "Could not download that file",
|
||||||
|
"loadFailed": "We could not load the attachments just now."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"title": "Outgoing email",
|
||||||
|
"subtitle": "Send invitations and password codes from your own address instead of ours. Messages about your domain, arriving from an unfamiliar sender, are what a spam filter is built to catch.",
|
||||||
|
"saved": "Settings saved",
|
||||||
|
"cleared": "Reverted to the platform's own account",
|
||||||
|
"status": {
|
||||||
|
"none": "Nothing configured. We send everything from our own account, which works but means your people receive account mail from a sender they do not recognise.",
|
||||||
|
"unverified": "Saved but not verified. Send a test message to switch it on — until then we keep sending from our own account, so nothing is broken in the meantime.",
|
||||||
|
"active": "Active. Verified {{when}}."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"host": "SMTP host",
|
||||||
|
"port": "Port",
|
||||||
|
"user": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordReplace": "Replace the password",
|
||||||
|
"passwordNote": "A password is already stored. Leave this blank to keep it — we cannot show it back to you.",
|
||||||
|
"ssl": "Connect over SSL (usually port 465)",
|
||||||
|
"fromAddress": "From address",
|
||||||
|
"fromName": "From name",
|
||||||
|
"spfNote": "For messages to arrive reliably, make sure this host is allowed to send for your domain in your SPF record, and that DKIM is signing for it.",
|
||||||
|
"save": "Save",
|
||||||
|
"clear": "Use our account instead"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"title": "Send a test",
|
||||||
|
"note": "A successful test is what switches these settings on. Saving alone does not, so a typo cannot quietly stop your invitations arriving.",
|
||||||
|
"to": "Send it to",
|
||||||
|
"send": "Send test"
|
||||||
|
},
|
||||||
|
"confirmClear": {
|
||||||
|
"title": "Revert to the platform's account?",
|
||||||
|
"message": "Your settings are removed and we go back to sending from our own address. You can configure it again at any time."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "That did not work. Please try again.",
|
||||||
|
"saveFailed": "Could not save those settings",
|
||||||
|
"loadFailed": "We could not load your email settings just now."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"title": "Invitations",
|
||||||
|
"subtitle": "Invite people to set their own password, rather than choosing one for them.",
|
||||||
|
"invite": "Invite someone",
|
||||||
|
"resend": "Resend",
|
||||||
|
"empty": "No invitations yet.",
|
||||||
|
"expiresOn": "Expires {{when}}",
|
||||||
|
"acceptedOn": "Accepted {{when}}",
|
||||||
|
"state": {
|
||||||
|
"pending": "Waiting",
|
||||||
|
"accepted": "Accepted",
|
||||||
|
"expired": "Expired",
|
||||||
|
"revoked": "Revoked"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"email": "Email address",
|
||||||
|
"firstName": "First name",
|
||||||
|
"lastName": "Last name",
|
||||||
|
"role": "Role",
|
||||||
|
"explain": "They will receive a link that works once and expires in seven days. They choose their own password — you will never know it.",
|
||||||
|
"submit": "Send the invitation"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"title": "Invitation sent",
|
||||||
|
"sent": "We emailed {{email}} a link.",
|
||||||
|
"notSent": "The invitation for {{email}} is ready, but we could not email it.",
|
||||||
|
"notSentExplain": "The invitation itself is valid. Copy the link below and pass it on yourself.",
|
||||||
|
"warning": "Anyone holding this link can join the workspace as that address, so send it the way you would send a password.",
|
||||||
|
"done": "Done"
|
||||||
|
},
|
||||||
|
"confirmRevoke": {
|
||||||
|
"title": "Revoke this invitation?",
|
||||||
|
"message": "The link sent to {{email}} stops working. You can invite them again afterwards.",
|
||||||
|
"confirm": "Revoke"
|
||||||
|
},
|
||||||
|
"accept": {
|
||||||
|
"title": "Join {{workspace}}",
|
||||||
|
"forAddress": "This invitation is for {{email}}.",
|
||||||
|
"password": "Choose a password",
|
||||||
|
"confirm": "Confirm password",
|
||||||
|
"privacy": "Nobody else will know this password — not even the person who invited you.",
|
||||||
|
"mismatch": "Those two passwords do not match.",
|
||||||
|
"submit": "Create my account",
|
||||||
|
"invalidTitle": "This link is not valid",
|
||||||
|
"invalidBody": "It may have expired, been used already, or been withdrawn. Ask whoever invited you for a new one.",
|
||||||
|
"doneTitle": "Your account is ready",
|
||||||
|
"doneBody": "Sign in with the address you were invited at and the password you just chose.",
|
||||||
|
"toSignIn": "Go to sign in"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "That did not work. Please try again.",
|
||||||
|
"loadFailed": "We could not load your invitations just now."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"title": "Notifications",
|
||||||
|
"markAll": "Mark all read",
|
||||||
|
"empty": "Nothing to report.",
|
||||||
|
"loadFailed": "We could not load your notifications just now.",
|
||||||
|
"aria": {
|
||||||
|
"none": "Notifications",
|
||||||
|
"withCount_one": "Notifications, {{count}} unread",
|
||||||
|
"withCount_other": "Notifications, {{count}} unread",
|
||||||
|
"unread": "Unread"
|
||||||
|
},
|
||||||
|
"preferences": {
|
||||||
|
"title": "What you are notified about",
|
||||||
|
"description": "Every kind is on unless you turn it off. Turning one off stops the notice, not the event — it is still recorded in the audit trail.",
|
||||||
|
"security": "Security",
|
||||||
|
"loadFailed": "We could not load your preferences just now.",
|
||||||
|
"saveFailed": "That did not save. Nothing has changed."
|
||||||
|
},
|
||||||
|
"kinds": {
|
||||||
|
"security.account_locked": {
|
||||||
|
"title": "Your account is locked",
|
||||||
|
"description": "Too many failed sign-in attempts in a row."
|
||||||
|
},
|
||||||
|
"security.mfa_enabled": {
|
||||||
|
"title": "Two-factor authentication turned on",
|
||||||
|
"description": "Somebody added a second factor to your account."
|
||||||
|
},
|
||||||
|
"security.mfa_disabled": {
|
||||||
|
"title": "Two-factor authentication turned off",
|
||||||
|
"description": "Somebody removed the second factor from your account."
|
||||||
|
},
|
||||||
|
"api_key.issued": {
|
||||||
|
"title": "An API key was issued",
|
||||||
|
"description": "A new key that can act as this workspace was created."
|
||||||
|
},
|
||||||
|
"webhook.disabled": {
|
||||||
|
"title": "A webhook stopped working",
|
||||||
|
"description": "An endpoint failed enough times that we stopped sending to it."
|
||||||
|
},
|
||||||
|
"invitation.accepted": {
|
||||||
|
"title": "An invitation was accepted",
|
||||||
|
"description": "Somebody you invited has joined the workspace."
|
||||||
|
},
|
||||||
|
"subscription.expiring": {
|
||||||
|
"title": "The subscription is expiring",
|
||||||
|
"description": "The workspace's subscription ends soon."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"title": "Operations",
|
||||||
|
"subtitle": "What the background jobs have been doing. Across all workspaces.",
|
||||||
|
"loadError": "Could not load the operations summary.",
|
||||||
|
"figures": {
|
||||||
|
"noBillingContact": "No billing contact",
|
||||||
|
"noBillingContactHint": "Workspaces with an end date and nobody to warn before it lapses",
|
||||||
|
"stuckEvents": "Stuck events",
|
||||||
|
"stuckEventsHint": "Overdue, already retried — a module is not accepting deliveries",
|
||||||
|
"tokenReuse": "Token reuse detected",
|
||||||
|
"tokenReuseHint": "A refresh token was presented after the real client had already spent it"
|
||||||
|
},
|
||||||
|
"panels": {
|
||||||
|
"notices": "Subscription notices (last {{days}} days)",
|
||||||
|
"noNotices": "Nothing sent in this window.",
|
||||||
|
"recordedNotSent": "{{count}} recorded but not sent: the workspace had no billing address.",
|
||||||
|
"outbox": "Event outbox",
|
||||||
|
"noEvents": "No events.",
|
||||||
|
"oldestPending": "Oldest pending event: {{when}}",
|
||||||
|
"sessions": "Sessions",
|
||||||
|
"activeSessions": "active",
|
||||||
|
"noEndedSessions": "No sessions have ended.",
|
||||||
|
"failingTargets": "Failing delivery targets",
|
||||||
|
"noFailingTargets": "Everything is being delivered.",
|
||||||
|
"recent": "Recent notices",
|
||||||
|
"auditRetention": "Audit retention",
|
||||||
|
"auditEntries": "audit entries",
|
||||||
|
"pastRetention_zero": "Nothing is past its window.",
|
||||||
|
"pastRetention_one": "1 entry is past its window and awaiting the sweep.",
|
||||||
|
"pastRetention_other": "{{count}} entries are past their window and awaiting the sweep.",
|
||||||
|
"retentionWindow": "Kept for {{days}} days; security entries for {{securityDays}}.",
|
||||||
|
"oldestEntry": "Oldest entry: {{when}}",
|
||||||
|
"retentionUnavailable": "Retention is not reporting. It connects as its own database role, which may not be configured yet."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"workspace": "Workspace",
|
||||||
|
"kind": "Notice",
|
||||||
|
"sentTo": "Sent to",
|
||||||
|
"sentAt": "Sent",
|
||||||
|
"nobody": "nobody to tell"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"open": "{{count}} alert(s) firing now",
|
||||||
|
"undelivered": "— could not be delivered anywhere"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"title": "Organisation",
|
||||||
|
"subtitle": "Departments, branches and teams — and, if you want it, an administrator who looks after just one of them.",
|
||||||
|
"add": "Add a unit",
|
||||||
|
"empty": "No units yet. Add one to group people by branch, department or team.",
|
||||||
|
"form": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Lahore branch, Engineering, Sales…",
|
||||||
|
"code": "Short code",
|
||||||
|
"codeNote": "Optional. A stable handle your own systems can use to refer to this unit without knowing our identifiers.",
|
||||||
|
"parent": "Sits under",
|
||||||
|
"topLevel": "Nothing — top level",
|
||||||
|
"submit": "Create the unit"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"open": "People",
|
||||||
|
"empty": "Nobody is in this unit yet.",
|
||||||
|
"add": "Add somebody",
|
||||||
|
"addButton": "Add",
|
||||||
|
"person": "Person",
|
||||||
|
"lead": "Lead"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"grant": "Scope here",
|
||||||
|
"revoke": "Unscope",
|
||||||
|
"explain": "Limit this person's user administration to this unit and everything under it.",
|
||||||
|
"note": "Scoping only ever takes access away. Somebody with no scope administers the whole workspace — which is what every administrator does by default — so removing their last scope gives that back.",
|
||||||
|
"title": "Administrators",
|
||||||
|
"person": "Person"
|
||||||
|
},
|
||||||
|
"confirmRemove": {
|
||||||
|
"title": "Delete this unit?",
|
||||||
|
"message": "{{name}} will be removed. This is refused while it still has units beneath it or people in it — move those first.",
|
||||||
|
"confirm": "Delete"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "That did not work. Please try again.",
|
||||||
|
"loadFailed": "We could not load your organisation just now."
|
||||||
|
},
|
||||||
|
"seats": {
|
||||||
|
"purchased": "Seats bought",
|
||||||
|
"allocated": "Allocated to branches",
|
||||||
|
"unallocated": "Left to give",
|
||||||
|
"unlimited": "This plan has no seat limit",
|
||||||
|
"edit": "Set this branch's seat limit",
|
||||||
|
"usedUncapped_one": "{{count}} member",
|
||||||
|
"usedUncapped_other": "{{count}} members",
|
||||||
|
"usedOfLimit": "{{used}} of {{limit}}",
|
||||||
|
"title": "Seats for {{name}}",
|
||||||
|
"explanation": "How many of the workspace's seats this branch may use. Branches without a limit are bounded only by the workspace.",
|
||||||
|
"limit": "Seat limit",
|
||||||
|
"uncapped": "No limit",
|
||||||
|
"emptyMeansUncapped": "Leave it empty to remove the limit. That is not the same as 0, which would mean nobody may be in this branch.",
|
||||||
|
"currentUse_one": "{{count}} member now",
|
||||||
|
"currentUse_other": "{{count}} members now",
|
||||||
|
"availableToThisUnit_one": "{{count}} seat available",
|
||||||
|
"availableToThisUnit_other": "{{count}} seats available",
|
||||||
|
"notAWholeNumber": "Enter a whole number, or leave it empty for no limit.",
|
||||||
|
"saveFailed": "That did not save."
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user