feat: updated saas

This commit is contained in:
Furqan-14
2026-08-31 20:05:33 -04:00
parent 4bf09862c8
commit 6fe1382a7f
115 changed files with 13319 additions and 195 deletions
+255
View File
@@ -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.
+1499 -8
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -13,10 +13,14 @@
"build:test": "tsc -b && vite build --mode test",
"build:prod": "tsc -b && vite build --mode production",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test:unit": "vitest run",
"test:watch": "vitest",
"check:size": "node scripts/check-bundle-size.mjs"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/qrcode": "^1.5.6",
"axios": "^1.13.2",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
@@ -24,6 +28,7 @@
"leaflet-control-geocoder": "^3.3.1",
"leaflet.fullscreen": "^5.3.0",
"lucide-react": "^0.562.0",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.50.0",
@@ -35,6 +40,9 @@
},
"devDependencies": {
"@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/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
@@ -43,8 +51,10 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^3.2.7"
}
}
+102
View File
@@ -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).`);
+27
View File
@@ -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",
}),
};
+36
View File
@@ -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();
});
});
+317
View File
@@ -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;
+19
View File
@@ -1,5 +1,6 @@
import { apiClient } from "../../lib/apiClient";
import type { AuthUser, SigninRequest, SignupRequest, TokenResponse } from "./AuthTypes";
import type { UserSession } from "../profile/ProfileTypes";
type AuthApiOptions = {
tenantId?: string;
@@ -71,4 +72,22 @@ export const authApi = {
refresh: () =>
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;
start_date?: string | null;
end_date?: string | null;
/** The *stored* status: an administrative decision. */
status?: string | 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 = {
@@ -27,6 +43,12 @@ export type AuthUser = {
last_name?: string | null;
phone_number?: string | null;
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_name?: string | null;
tenant_logo_url?: string | null;
@@ -41,6 +63,9 @@ export type SigninRequest = {
email: string;
password: string;
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 = {
@@ -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 { CustomInput, CustomCheckBox, CustomButton } from "../../../components/custom";
import { ApiError } from "../../../lib/apiClient";
import type { SigninRequest } from "../AuthTypes";
import { useAuth } from "../../../context/AuthContext";
@@ -13,6 +14,10 @@ export default function SignInForm() {
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
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();
@@ -24,6 +29,7 @@ export default function SignInForm() {
const payload: SigninRequest = {
email,
password,
...(mfaCode ? { mfa_code: mfaCode } : {}),
};
try {
@@ -31,11 +37,22 @@ export default function SignInForm() {
navigate("/dashboard");
} catch (error) {
// The server distinguishes "wrong credentials" from "right credentials,
// code still needed" with a header rather than with different prose, so
// this does not depend on matching an English string over the wire.
if (error instanceof ApiError && error.mfaRequired) {
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 {
setIsLoading(false);
}
@@ -77,6 +94,21 @@ export default function SignInForm() {
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 && (
<p className="text-sm text-red-400">
{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;
+348
View File
@@ -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;
};
+95
View File
@@ -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 }
),
};
+42
View File
@@ -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;
+16
View File
@@ -7,6 +7,9 @@ import { Key, Pencil } from "lucide-react";
import type { PasswordForm, FormatDateFunction, FormatNameFunction, RenderStatusBadgeFunction } from "./ProfileTypes";
import { useTheme } from "../../context/ThemeContext";
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";
type ProfileForm = {
@@ -371,6 +374,19 @@ const ProfilePage: React.FC = () => {
</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 */}
<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)]">
+13
View File
@@ -11,3 +11,16 @@ export type FormatDateFunction = (value: string) => string;
export type FormatNameFunction = (firstName: string, lastName?: string | null) => string;
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()
);
});
});
+211
View File
@@ -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;
+89
View File
@@ -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",
}),
};
+430
View File
@@ -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;
+61
View File
@@ -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();
});
});
+445
View File
@@ -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;
+20
View File
@@ -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[];
};
+24
View File
@@ -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;
}
};
+6 -1
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { CustomDropdown, CustomConfirmationModal } from '../../components/custom';
import { useAuth } from '../../context/AuthContext';
import { languages } from '../../i18n/config';
import { languages, loadLanguage } from '../../i18n/config';
import type { SupportedLanguage } from '../../i18n/config';
import { Globe } from 'lucide-react';
@@ -31,6 +31,11 @@ const SettingsPage: React.FC = () => {
setShowConfirmation(false);
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);
if (user) {
+48
View File
@@ -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",
}),
};
+420
View File
@@ -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;
+49
View File
@@ -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;
duration_days?: 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;
status: string;
created_at: string;
@@ -22,6 +24,7 @@ export type SubscriptionPlanCreateRequest = {
price?: number;
duration_days?: number;
max_users_allowed?: number;
grace_period_days?: number;
is_public?: boolean;
status?: string;
access_ids?: string[];
@@ -34,6 +37,7 @@ export type SubscriptionPlanUpdateRequest = {
price?: number;
duration_days?: number;
max_users_allowed?: number;
grace_period_days?: number;
is_public?: boolean;
status?: 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 { RoleAccess } from "../../roles/RolesTypes";
import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector";
import { buildPlanCreatePayload } from "../buildPlanPayload";
const AddSubscriptions = () => {
const navigate = useNavigate();
@@ -21,6 +22,7 @@ const AddSubscriptions = () => {
price: undefined,
duration_days: undefined,
max_users_allowed: undefined,
grace_period_days: 0,
is_public: true,
status: "active",
access_ids: [],
@@ -69,42 +71,17 @@ const AddSubscriptions = () => {
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) => {
event.preventDefault();
setErrorMessage("");
setIsLoading(true);
try {
const { accessIds, moduleAccessIds } = splitAccessIds(selectedAccessIds);
const payload: SubscriptionPlanCreateRequest = {
name: formData.name.trim(),
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,
};
const payload = buildPlanCreatePayload(
formData,
selectedAccessIds,
allAccesses
);
await subscriptionsApi.create(payload);
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>
<CustomInput
@@ -24,7 +24,7 @@ import {
} from "../../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
import { formatDate } from "../../../lib/dateFormat";
import type { SubscriptionPlan, SubscriptionPlanUpdateRequest } from "../SubscriptionTypes";
import type { SubscriptionPlan } from "../SubscriptionTypes";
import type { RoleAccess } from "../../roles/RolesTypes";
import { subscriptionsApi } from "../SubscriptionsApi";
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
@@ -35,6 +35,7 @@ import {
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
resolveStoredTablePageSize,
} from "../../../lib/tablePageSize";
import { buildPlanUpdatePayload } from "../buildPlanPayload";
const formatPrice = (price?: number | null) => {
if (price == null) return "-";
@@ -50,6 +51,7 @@ interface EditFormState {
price: number | undefined;
duration_days: number | undefined;
max_users_allowed: number | undefined;
grace_period_days: number | undefined;
is_public: boolean;
status: string;
}
@@ -103,6 +105,7 @@ const AllSubscriptions = () => {
price: undefined,
duration_days: undefined,
max_users_allowed: undefined,
grace_period_days: 0,
is_public: true,
status: "active",
});
@@ -223,26 +226,6 @@ const AllSubscriptions = () => {
return counts;
}, [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) => {
setSelectedPlan(plan);
setViewAccessIds([]);
@@ -266,6 +249,7 @@ const AllSubscriptions = () => {
price: plan.price ?? undefined,
duration_days: plan.duration_days ?? undefined,
max_users_allowed: plan.max_users_allowed ?? undefined,
grace_period_days: plan.grace_period_days ?? 0,
is_public: plan.is_public,
status: plan.status,
});
@@ -322,19 +306,7 @@ const AllSubscriptions = () => {
setEditError("");
try {
const { accessIds, moduleAccessIds } = splitAccessIds(editAccessIds);
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 payload = buildPlanUpdatePayload(editForm, editAccessIds, allAccesses);
const updated = await subscriptionsApi.update(selectedPlan.id, payload);
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>
<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 };
};
+5
View File
@@ -6,6 +6,8 @@ export type Tenant = {
tenant_name: string;
tenant_domain: string;
tenant_logo_url?: string | null;
/** Who to tell before the subscription lapses. */
billing_email?: string | null;
is_active: boolean;
plan_id?: string | null;
start_date?: string | null;
@@ -24,6 +26,7 @@ export type TenantCreateRequest = {
tenant_name: string;
tenant_domain: string;
tenant_logo_url?: string;
billing_email?: string;
plan_id: string;
start_date?: string;
end_date?: string;
@@ -35,6 +38,8 @@ export type TenantUpdateRequest = {
tenant_name?: string;
tenant_domain?: string;
tenant_logo_url?: string | null;
/** Who to tell before the subscription lapses. */
billing_email?: string | null;
is_active?: boolean;
plan_id?: string;
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 { CustomLoader } from "../../../components/custom";
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 { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
@@ -12,6 +12,7 @@ import type { RoleAccess } from "../../roles/RolesTypes";
import { adminModuleApi } from "../../modules/admin/AdminModuleApi";
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
import { AlertCircle } from "lucide-react";
import { buildTenantCreatePayload } from "../buildTenantPayload";
const toIsoDate = (value: Date) => value.toISOString().slice(0, 10);
@@ -31,6 +32,7 @@ const AddTenants = () => {
const [tenantName, setTenantName] = useState("");
const [tenantDomain, setTenantDomain] = useState("");
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
const [billingEmail, setBillingEmail] = useState("");
const [selectedPlanId, setSelectedPlanId] = useState("");
const [startDate, setStartDate] = useState(today);
const [endDate, setEndDate] = useState("");
@@ -212,16 +214,17 @@ const AddTenants = () => {
setIsLoading(true);
try {
const payload: TenantCreateRequest = {
tenant_name: tenantName.trim(),
tenant_domain: tenantDomain.trim(),
tenant_logo_url: tenantLogoUrl.trim() || undefined,
plan_id: selectedPlanId,
start_date: startDate || undefined,
end_date: endDate || undefined,
const payload = buildTenantCreatePayload({
tenantName,
tenantDomain,
tenantLogoUrl,
billingEmail,
planId: selectedPlanId,
startDate,
endDate,
status: tenantStatus,
module_environments: moduleEnvAssignments.filter((a) => a.environment_slug),
};
moduleEnvironments: moduleEnvAssignments,
});
await tenantsApi.create(payload);
navigate("/tenants");
@@ -321,6 +324,19 @@ const AddTenants = () => {
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">
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
<CustomDropdown
@@ -22,11 +22,12 @@ import {
} from "../../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
import { formatDate } from "../../../lib/dateFormat";
import type { Tenant, TenantStatus, TenantUpdateRequest } from "../TenantsTypes";
import type { Tenant, TenantStatus } from "../TenantsTypes";
import { tenantsApi } from "../TenantsApi";
import { useAuth } from "../../../context/AuthContext";
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
import { buildTenantUpdatePayload } from "../buildTenantPayload";
import {
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
@@ -70,6 +71,7 @@ interface EditFormState {
tenant_name: string;
tenant_domain: string;
tenant_logo_url: string;
billing_email: string;
is_active: boolean;
plan_id: string;
start_date: string;
@@ -92,6 +94,7 @@ const AllTenants = () => {
tenant_name: "",
tenant_domain: "",
tenant_logo_url: "",
billing_email: "",
is_active: true,
plan_id: "",
start_date: "",
@@ -318,6 +321,7 @@ const AllTenants = () => {
tenant_name: tenant.tenant_name,
tenant_domain: tenant.tenant_domain,
tenant_logo_url: tenant.tenant_logo_url ?? "",
billing_email: tenant.billing_email ?? "",
is_active: tenant.is_active,
plan_id: tenant.plan_id ?? "",
start_date: tenant.start_date ?? "",
@@ -374,16 +378,17 @@ const AllTenants = () => {
setIsSaving(true);
try {
const payload: TenantUpdateRequest = {
tenant_name: editForm.tenant_name.trim(),
tenant_domain: editForm.tenant_domain.trim(),
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
is_active: editForm.is_active,
plan_id: editForm.plan_id || undefined,
start_date: editForm.start_date || null,
end_date: editForm.end_date || null,
const payload = buildTenantUpdatePayload({
tenantName: editForm.tenant_name,
tenantDomain: editForm.tenant_domain,
tenantLogoUrl: editForm.tenant_logo_url,
billingEmail: editForm.billing_email,
isActive: editForm.is_active,
planId: editForm.plan_id,
startDate: editForm.start_date,
endDate: editForm.end_date,
status: editForm.status,
};
});
const updatedTenant = await tenantsApi.update(tenantId, payload);
@@ -841,6 +846,19 @@ const AllTenants = () => {
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">
<CustomCheckBox
label="Active"
+24 -2
View File
@@ -33,6 +33,7 @@ import { tenantsApi } from "../../tenants/TenantsApi";
import type { Tenant } from "../../tenants/TenantsTypes";
import { useDebounce } from "../../../components/hooks/useDebounce";
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
import DeletedUsersPanel from "./DeletedUsersPanel";
import {
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
@@ -90,6 +91,11 @@ const AllUsers = () => {
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
const [roleFilter, setRoleFilter] = useState<string[]>([]);
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<{
column: "first_name" | "email" | "status" | "tenant_id" | "role_id" | null;
direction: ColumnSortDirection;
@@ -211,6 +217,7 @@ const AllUsers = () => {
tenantFilter,
roleFilter,
activeSort,
refreshToken,
]);
// 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>
</div>
<ProtectedComponent requiredAccess="admin.user.create">
<Link to="/users/add">
<CustomButton variant="primary">+ {t('add')}</CustomButton>
<div className="flex flex-wrap gap-2">
{/* Inviting is the primary action now: it is the one where the
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>
</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 */}
<CustomModal
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;
+66
View File
@@ -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,
}),
};
+66
View File
@@ -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;
+509
View File
@@ -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);
});
});
+10 -2
View File
@@ -1,4 +1,4 @@
import React, { useState, forwardRef } from "react";
import React, { useId, useState, forwardRef } from "react";
import { Phone, Eye, EyeOff } from "lucide-react";
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "url";
@@ -32,6 +32,13 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
},
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 isPhone = phonePrefix !== undefined;
const [showPassword, setShowPassword] = useState(false);
@@ -51,7 +58,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
return (
<div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}>
{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}
{props.required && <span className="ml-1 text-red-500">*</span>}
</label>
@@ -78,6 +85,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
<input
ref={ref}
id={inputId}
type={inputType}
maxLength={
type === "number" || type === "tel" ? undefined : maxLength
+5
View File
@@ -11,6 +11,7 @@ import { useAuth } from "../../context/AuthContext";
import { useTranslation } from "react-i18next";
import { authApi } from "../../application/authentication/AuthApi";
import { clearAuthCookies } from "../../lib/authCookies";
import NotificationBell from "../../application/notifications/NotificationBell";
const AppHeader: React.FC = () => {
@@ -72,6 +73,10 @@ const AppHeader: React.FC = () => {
{/* RIGHT: Actions */}
<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 */}
<div className="relative" ref={userMenuRef}>
<button
+5
View File
@@ -3,6 +3,7 @@ import { Outlet } from "react-router-dom";
import AppHeader from "./AppHeader";
import AppSidebar from "./AppSidebar";
import Backdrop from "./Backdrop";
import SubscriptionBanner from "./SubscriptionBanner";
const LayoutContent: React.FC = () => {
const { isExpanded } = useSidebar();
@@ -20,6 +21,10 @@ const LayoutContent: React.FC = () => {
>
<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">
<div className="max-w-[1600px] mx-auto w-full">
<Outlet />
+77 -3
View File
@@ -11,11 +11,19 @@ import {
LogOut,
PanelLeft,
Palette,
KeyRound,
List,
Paperclip,
LogIn,
Mail,
Network,
Settings,
Webhook,
ChevronDown,
ChevronRight,
CreditCard,
FileText,
Activity,
} from "lucide-react";
import { useSidebar } from "../../context/SidebarContext";
import { usePermission } from "../../lib/usePermission";
@@ -36,6 +44,9 @@ interface NavItem {
name: string;
path: 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[];
}
@@ -88,6 +99,57 @@ const navItems: NavItem[] = [
path: "/logs",
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} />,
name: "Settings",
@@ -125,6 +187,14 @@ const AppSidebar: React.FC = () => {
'Settings': 'settings',
'Modules': 'modules',
'Logs': 'logs',
'Operations': 'operations',
'Documents': 'documents',
'Reference data': 'reference',
'Email': 'email',
'Organisation': 'organisation',
'Sign-in': 'signIn',
'API keys': 'apiKeys',
'Webhooks': 'webhooks',
'Subscriptions': 'subscriptions',
};
@@ -143,6 +213,7 @@ const AppSidebar: React.FC = () => {
const visibleNavItems = useMemo(
() => navItems.filter((item) => {
if (item.superadminOnly) return Boolean(user?.is_superadmin);
if (!item.access) return true;
if (item.submenu && item.submenu.length > 0) {
@@ -151,7 +222,7 @@ const AppSidebar: React.FC = () => {
return can(item.access);
}),
[can]
[can, user]
);
const isActive = useCallback(
@@ -178,13 +249,16 @@ const AppSidebar: React.FC = () => {
}
// 2. Management
const managementItems = getItems(["Tenants", "Subscriptions", "Roles", "Users"]);
const managementItems = getItems(["Tenants", "Subscriptions", "Roles", "Users",
"Documents"]);
if (managementItems.length > 0) {
groups.push({ label: t('nav.groups.management', 'Management'), items: managementItems });
}
// 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) {
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 -4
View File
@@ -10,7 +10,7 @@ import {
getAccessToken,
clearAuthCookies,
} from "../lib/authCookies";
import i18n from "../i18n/config";
import i18n, { loadLanguage } from "../i18n/config";
interface AuthContextType {
user: AuthUser | null;
@@ -44,8 +44,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
const userData = await authApi.me();
setUser(userData);
if (userData.preferred_language) {
i18n.changeLanguage(userData.preferred_language);
const preferred = 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 {
clearAuthCookies();
@@ -91,7 +94,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
setUser(userData);
if (userData.preferred_language) {
i18n.changeLanguage(userData.preferred_language);
const preferred = userData.preferred_language;
if (preferred) {
void loadLanguage(preferred).then(() => i18n.changeLanguage(preferred));
}
}
} catch {
}
+94 -33
View File
@@ -3,22 +3,42 @@ import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import enCommon from './locales/en/common.json';
import arCommon from './locales/ar/common.json';
import enUsers from './locales/en/users.json';
import arUsers from './locales/ar/users.json';
import enRoles from './locales/en/roles.json';
import arRoles from './locales/ar/roles.json';
import enProfile from './locales/en/profile.json';
import arProfile from './locales/ar/profile.json';
import enDashboard from './locales/en/dashboard.json';
import arDashboard from './locales/ar/dashboard.json';
import enModules from './locales/en/modules.json';
import arModules from './locales/ar/modules.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 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 = {
en: { name: 'English', dir: 'ltr' },
@@ -27,8 +47,13 @@ export const languages = {
export type SupportedLanguage = keyof typeof languages;
const resources = {
en: {
const NAMESPACES = [
'common', 'users', 'roles', 'profile', 'dashboard', 'modules', 'logs',
'operations', 'theme', 'security', 'notifications', 'apikeys', 'webhooks',
'invitations', 'sso', 'organisation', 'email', 'reference', 'documents',
] as const;
const english = {
common: enCommon,
users: enUsers,
roles: enRoles,
@@ -36,25 +61,63 @@ const resources = {
dashboard: enDashboard,
modules: enModules,
logs: enLogs,
operations: enOperations,
theme: enTheme,
},
ar: {
common: arCommon,
users: arUsers,
roles: arRoles,
profile: arProfile,
dashboard: arDashboard,
modules: arModules,
logs: arLogs,
theme: arTheme,
},
security: enSecurity,
notifications: enNotifications,
apikeys: enApiKeys,
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
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
resources: { en: english },
defaultNS: 'common',
fallbackLng: 'en',
supportedLngs: Object.keys(languages),
@@ -70,20 +133,18 @@ i18next
},
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,
},
});
const updateDirection = (lng: string) => {
const dir = languages[lng as SupportedLanguage]?.dir || 'ltr';
document.documentElement.setAttribute('dir', dir);
document.documentElement.setAttribute('lang', lng);
};
i18next.on('languageChanged', updateDirection);
if (i18next.language) {
updateDirection(i18next.language);
// Started immediately rather than awaited: the app renders in English for the
// moment it takes, which is far better than a blank page — and for the majority
// who use English there is nothing to fetch at all.
if (detected !== 'en') {
void loadLanguage(detected);
}
export default i18next;
+46
View File
@@ -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": "تعذّر تحميل المفاتيح الآن."
}
}
+17 -1
View File
@@ -13,7 +13,15 @@
"settings": "الإعدادات",
"configuration": "التكوين",
"dropdowns": "القوائم المنسدلة",
"modules": "الوحدات"
"modules": "الوحدات",
"operations": "العمليات",
"apiKeys": "مفاتيح الواجهة البرمجية",
"webhooks": "الويب هوك",
"signIn": "تسجيل الدخول",
"organisation": "الهيكل التنظيمي",
"email": "البريد",
"reference": "البيانات المرجعية",
"documents": "المستندات"
},
"actions": {
"save": "حفظ",
@@ -112,5 +120,13 @@
"createSuccess": "تم الإنشاء بنجاح",
"error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا العنصر؟"
},
"subscription": {
"graceUntil": "انتهى اشتراكك. مساحة العمل للقراءة فقط حتى {{date}} — جدّد الاشتراك قبل ذلك للمتابعة.",
"grace": "انتهى اشتراكك. مساحة العمل للقراءة فقط حتى يتم التجديد.",
"expired": "انتهت صلاحية اشتراكك. جدّده لاستعادة الوصول.",
"cancelled": "تم إلغاء هذا الاشتراك. تواصل مع المسؤول لاستعادة الوصول.",
"suspended": "تم تعليق مساحة العمل هذه. تواصل مع المسؤول.",
"inactive": "مساحة العمل هذه غير نشطة حالياً."
}
}
+25
View File
@@ -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": "تعذّر تحميل المرفقات الآن."
}
}
+40
View File
@@ -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": "تعذّر تحميل إعدادات البريد الآن."
}
}
+54
View File
@@ -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": "تعذّر تحميل الدعوات الآن."
}
}
+53
View File
@@ -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": "اشتراك مساحة العمل ينتهي قريبًا."
}
}
}
+46
View File
@@ -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": "— تعذّر تسليمه إلى أي جهة"
}
}
+61
View File
@@ -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": "لم يتم الحفظ."
}
}
+12 -4
View File
@@ -4,7 +4,9 @@
"sections": {
"accountInfo": "معلومات السجل",
"appearance": "المظهر",
"appearanceDesc": "تخصيص مظهر التطبيق"
"appearanceDesc": "تخصيص مظهر التطبيق",
"sessions": "الجلسات النشطة",
"sessionsDesc": "الأماكن التي سُجّل الدخول منها إلى هذا الحساب. أنهِ أي جلسة لا تعرفها."
},
"fields": {
"firstName": "الاسم الأول",
@@ -15,13 +17,17 @@
"role": "الدور",
"accountCreated": "تاريخ إنشاء الحساب",
"lastUpdated": "آخر تحديث",
"unknown": "غير معروف"
"unknown": "غير معروف",
"thisDevice": "هذا الجهاز",
"unknownDevice": "جهاز غير معروف"
},
"buttons": {
"changePassword": "تغيير كلمة المرور",
"editProfile": "تعديل الملف الشخصي",
"updatePassword": "تحديث كلمة المرور",
"saveChanges": "حفظ التغييرات"
"saveChanges": "حفظ التغييرات",
"endSession": "إنهاء الجلسة",
"signOutOthers": "تسجيل الخروج من الأجهزة الأخرى"
},
"modals": {
"changePasswordTitle": "تغيير كلمة المرور",
@@ -43,7 +49,9 @@
"passwordLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
"requiredFields": "جميع الحقول مطلوبة",
"firstNameRequired": "الاسم الأول مطلوب",
"loadError": "تعذر تحميل الملف الشخصي للمستخدم. يرجى محاولة تحديث الصفحة."
"loadError": "تعذر تحميل الملف الشخصي للمستخدم. يرجى محاولة تحديث الصفحة.",
"noSessions": "لا توجد جلسات نشطة.",
"loadFailed": "تعذّر تحميل جلساتك. حدّث الصفحة للمحاولة مجدداً."
},
"header": {
"profileSettings": "إعدادات الملف الشخصي",
+39
View File
@@ -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": "تعذّر تحميل البيانات المرجعية الآن."
}
}
+53
View File
@@ -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": "تعذّر التحقق من إعدادات الأمان الآن."
}
}
+53
View File
@@ -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": "تعذّر تحميل الاتصالات الآن."
}
}
+10
View File
@@ -62,5 +62,15 @@
"tenantsLoadError": "تعذر تحميل المستأجرين.",
"rolesLoadError": "تعذر تحميل الأدوار.",
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا المستخدم؟"
},
"invite": "دعوة",
"deleted": {
"title": "المحذوفون مؤخرًا",
"empty": "لم يُحذف أحد مؤخرًا.",
"on": "حُذف في {{when}}",
"restore": "استعادة",
"restored": "استُعيد الحساب",
"restoreFailed": "تعذّرت استعادة الحساب",
"loadFailed": "تعذّر تحميل الحسابات المحذوفة الآن."
}
}
+68
View File
@@ -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": "تعذّر تحميل الوجهات الآن."
}
}
+42
View File
@@ -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."
}
}
+17 -1
View File
@@ -13,7 +13,15 @@
"settings": "Settings",
"configuration": "Configuration",
"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": {
"save": "Save",
@@ -112,5 +120,13 @@
"createSuccess": "Created successfully",
"error": "An error occurred. Please try again.",
"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."
}
}
+25
View File
@@ -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."
}
}
+40
View File
@@ -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."
}
}
+54
View File
@@ -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."
}
}
+49
View File
@@ -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."
}
}
}
+46
View File
@@ -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"
}
}
+61
View File
@@ -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