8 Commits
142 changed files with 12294 additions and 553 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.
+3
View File
@@ -19,5 +19,8 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
])
+1486 -7
View File
File diff suppressed because it is too large Load Diff
+13 -3
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"
}
}
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
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");
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).`);
+17
View File
@@ -0,0 +1,17 @@
import { apiClient } from "../../lib/apiClient";
import type { ApiKeyCreateRequest, ApiKeyCreated, ApiKeyList } from "./ApiKeyTypes";
export const apiKeyApi = {
list: () => apiClient.get<ApiKeyList>("/api/api-keys"),
issue: (payload: ApiKeyCreateRequest) =>
apiClient.post<ApiKeyCreated>("/api/api-keys", payload, {
toast: false,
}),
revoke: (id: string) =>
apiClient.delete<null>(`/api/api-keys/${id}`, {
successMessage: "Key revoked",
errorMessage: "Could not revoke the key",
}),
};
+30
View File
@@ -0,0 +1,30 @@
export type ApiKeyState = "active" | "expired" | "revoked";
export type ApiKey = {
id: string;
name: string;
prefix: string;
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;
key: string;
};
export type ApiKeyList = {
items: ApiKey[];
total: number;
};
export type ApiKeyCreateRequest = {
name: string;
scopes: string[];
expires_in_days?: number | null;
};
@@ -0,0 +1,137 @@
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";
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 () => {
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();
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 () => {
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();
});
});
+295
View File
@@ -0,0 +1,295 @@
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";
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(),
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 {
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>
{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",
}
),
};
@@ -18,6 +18,16 @@ export type SubscriptionDetails = {
end_date?: string | null;
status?: string | null;
is_active?: boolean | null;
state?: string | null;
can_sign_in?: boolean | null;
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 +37,7 @@ export type AuthUser = {
last_name?: string | null;
phone_number?: string | null;
status?: string;
is_superadmin?: boolean;
tenant_id?: string | null;
tenant_name?: string | null;
tenant_logo_url?: string | null;
@@ -41,6 +52,7 @@ export type SigninRequest = {
email: string;
password: string;
remember_me?: boolean;
mfa_code?: string;
};
export type SignupRequest = {
@@ -0,0 +1,139 @@
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";
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 () => {
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 () => {
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,8 +2,10 @@ 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";
import { moduleApi } from "../../modules/user/UserModuleApi";
export default function SignInForm() {
const navigate = useNavigate();
@@ -13,6 +15,8 @@ export default function SignInForm() {
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [mfaRequired, setMfaRequired] = useState(false);
const [mfaCode, setMfaCode] = useState("");
const { login } = useAuth();
@@ -24,18 +28,59 @@ export default function SignInForm() {
const payload: SigninRequest = {
email,
password,
...(mfaCode ? { mfa_code: mfaCode } : {}),
};
try {
await login(payload, isChecked);
// Check if this login originated from a specific module (e.g. ?client_id=docqube, ?client_id=inventory, ?client_id=fl)
const searchParams = new URLSearchParams(window.location.search);
const clientId = searchParams.get("client_id");
if (clientId) {
try {
const launchRes = await moduleApi.launchModule(clientId);
const ssoRes = await fetch(launchRes.target_url, {
method: "POST",
headers: { "Content-Type": "application/json", ...launchRes.headers },
body: JSON.stringify(launchRes.payload),
credentials: "include",
});
if (ssoRes.ok && launchRes.redirect_url) {
if (window.top && window.top !== window) {
window.top.location.href = launchRes.redirect_url;
} else {
window.location.href = launchRes.redirect_url;
}
return;
} else {
const errText = await ssoRes.text();
console.error("SSO launch failed:", ssoRes.status, errText);
setErrorMessage(`Failed to complete SSO into ${clientId}: ${ssoRes.statusText || ssoRes.status}`);
return;
}
} catch (ssoErr: any) {
console.error("Auto SSO module launch error:", ssoErr);
setErrorMessage(`Unable to connect to ${clientId} backend server. Please make sure it is running.`);
return;
}
}
navigate("/dashboard");
} catch (error) {
const message =
error instanceof Error
? error.message
: "Unable to sign in. Please try again.";
setErrorMessage(message);
if (error instanceof ApiError && error.mfaRequired) {
setMfaRequired(true);
setErrorMessage("");
} else {
setMfaCode("");
const message =
error instanceof Error
? error.message
: "Unable to sign in. Please try again.";
setErrorMessage(message);
}
} finally {
setIsLoading(false);
}
@@ -77,6 +122,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}
@@ -110,17 +170,6 @@ export default function SignInForm() {
</div>
</form>
{/* <div className="mt-5">
<p className="text-sm font-normal text-center text-gray-300 sm:text-start">
Don&apos;t have an account? {""}
<Link
to="/signup"
className="text-blue-400 hover:text-blue-300"
>
Sign Up
</Link>
</p>
</div> */}
</div>
</div>
</div>
+27 -8
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { ExternalLink, Box, Search, Rocket } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { ExternalLink, Box, Search, Rocket, Settings } from 'lucide-react';
import { moduleApi, type Module } from '../modules/user/UserModuleApi';
import { useAuth } from '../../context/AuthContext';
import { useTranslation } from 'react-i18next';
@@ -7,6 +8,8 @@ import CustomInput from '../../components/custom/CustomInput';
const Dashboard = () => {
const { user } = useAuth();
const navigate = useNavigate();
const isSuperAdmin = Boolean(user?.is_superadmin);
const { t } = useTranslation(['dashboard', 'common']);
const [modules, setModules] = useState<Module[]>([]);
@@ -29,15 +32,24 @@ const Dashboard = () => {
}, []);
const handleModuleClick = async (moduleId: string) => {
if (isSuperAdmin) {
navigate(`/admin/modules?edit=${encodeURIComponent(moduleId)}`);
return;
}
try {
const response = await moduleApi.launchModule(moduleId);
await fetch(response.target_url, {
const ssoRes = await fetch(response.target_url, {
method: "POST",
headers: { "Content-Type": "application/json", ...response.headers },
body: JSON.stringify(response.payload),
credentials: "include",
});
if (!ssoRes.ok) {
const errText = await ssoRes.text();
throw new Error(`SSO login failed: ${ssoRes.status} ${errText}`);
}
if (response.redirect_url) {
try {
@@ -85,7 +97,6 @@ const Dashboard = () => {
return (
<div className="space-y-6 min-h-screen">
{/* Hero Section */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 animate-fade-in-up">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-(--text-primary)">
@@ -109,7 +120,6 @@ const Dashboard = () => {
</div>
</div>
{/* Modules Grid */}
<div className="space-y-12">
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@@ -167,10 +177,19 @@ const Dashboard = () => {
<div className="pt-4 border-t border-gray-50 mt-auto">
<button
onClick={() => handleModuleClick(module.module_id)}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-50 hover:bg-blue-600 text-gray-700 hover:text-white rounded-xl font-medium transition-all duration-200 group-hover:shadow-md"
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-50 hover:bg-blue-600 text-gray-700 hover:text-white rounded-xl font-medium transition-all duration-200 group-hover:shadow-md cursor-pointer"
>
<Rocket size={18} className="transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
{t('dashboard:actions.launch')}
{isSuperAdmin ? (
<>
<Settings size={18} className="transition-transform group-hover:rotate-45 duration-200" />
Manage Module
</>
) : (
<>
<Rocket size={18} className="transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
{t('dashboard:actions.launch')}
</>
)}
</button>
</div>
</div>
@@ -202,4 +221,4 @@ const Dashboard = () => {
);
};
export default Dashboard;
export default Dashboard;
@@ -0,0 +1,39 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../context/AuthContext";
import DocumentsPanel from "./DocumentsPanel";
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,168 @@
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";
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_()]);
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");
await user.click(buttons[buttons.length - 2]);
await waitFor(() =>
expect(blob).toHaveBeenCalledWith("/api/documents/d1/content")
);
expect(revokeObjectURL).toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,259 @@
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";
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;
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("");
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();
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>
{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;
+324
View File
@@ -0,0 +1,324 @@
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";
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 ?? "");
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>
)}
{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,115 @@
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";
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" })
)
);
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();
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,192 @@
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";
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) {
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,48 @@
import { apiClient } from "../../lib/apiClient";
import type { InvitationCreated, InvitationList } from "./InvitationTypes";
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, {
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",
}),
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,26 @@
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;
state: InvitationState;
};
export type InvitationCreated = {
invitation: Invitation;
acceptance_url: string;
email_sent: boolean;
};
export type InvitationList = {
items: Invitation[];
total: number;
};
@@ -0,0 +1,333 @@
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";
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();
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;
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Plus, Edit, Trash2, Settings, Shield, Eye, EyeOff } from 'lucide-react';
import { useModuleApi } from '../hooks/useModuleApi';
@@ -11,6 +11,8 @@ import CustomButton from '../../../../components/custom/CustomButton';
const ModuleList = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const editId = searchParams.get('edit');
const { t } = useTranslation(['modules', 'common']);
const { listModules, deleteModule, loading } = useModuleApi();
const [modules, setModules] = useState<Module[]>([]);
@@ -30,6 +32,16 @@ const ModuleList = () => {
fetchModules();
}, []);
useEffect(() => {
if (editId && modules.length > 0) {
const found = modules.find(m => m.id === editId || m.module_id === editId);
if (found) {
setSelectedModule(found);
setShowForm(true);
}
}
}, [editId, modules]);
const handleCreate = () => {
setSelectedModule(null);
setShowForm(true);
@@ -56,6 +68,9 @@ const ModuleList = () => {
const handleFormClose = (saved: boolean) => {
setShowForm(false);
setSelectedModule(null);
if (searchParams.get('edit')) {
setSearchParams({}, { replace: true });
}
if (saved) {
fetchModules();
}
@@ -0,0 +1,39 @@
import { apiClient } from "../../lib/apiClient";
import type {
NotificationList,
NotificationPreference,
UnreadCount,
} from "./NotificationTypes";
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,
}),
preferences: () =>
apiClient.get<NotificationPreference[]>("/api/notifications/preferences", {
toast: false,
}),
setPreference: (kind: string, enabled: boolean) =>
apiClient.put<NotificationPreference[]>(
"/api/notifications/preferences",
{ kind, channel: "in_app", enabled },
{ toast: false }
),
};
@@ -0,0 +1,199 @@
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";
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 () => {
unreadCount.mockResolvedValue({ unread: 250 });
render(<NotificationBell />);
expect(await screen.findByText("99+")).toBeTruthy();
});
it("stays quiet when the count cannot be fetched", async () => {
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 () => {
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"));
await waitFor(() => expect(unreadCount).toHaveBeenCalledTimes(2));
hidden.mockRestore();
});
});
@@ -0,0 +1,271 @@
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";
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 {
}
}, []);
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 {
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 {
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">
{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,133 @@
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";
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 () => {
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 () => {
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 () => {
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 () => {
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 () => {
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 () => {
preferences.mockResolvedValue([preference("invitation.accepted")]);
render(<NotificationPreferencesPanel />);
expect(
await screen.findByRole("switch", { name: /invitation\.accepted/i })
).toBeInTheDocument();
});
});
@@ -0,0 +1,143 @@
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";
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 {
setFailed(true);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const toggle = async (preference: NotificationPreference) => {
const wanted = !preference.enabled;
setBusyKind(preference.kind);
setSaveFailed(null);
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
)
);
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,28 @@
export type NotificationSeverity = "info" | "warning";
export type Notification = {
id: string;
kind: string;
severity: NotificationSeverity;
title: string;
body?: string | null;
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;
};
export type NotificationPreference = {
kind: string;
channel: string;
enabled: boolean;
};
@@ -0,0 +1,33 @@
import { apiClient } from "../../lib/apiClient";
import type {
AuditRetentionStatus,
NoticeSummary,
OpenAlert,
OutboxSummary,
SessionSummary,
} from "./OperationsTypes";
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,238 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import OperationsPage from "./OperationsPage";
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 () => {
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 () => {
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 () => {
healthy();
auditRetention.mockRejectedValue(new Error("not configured"));
render(<OperationsPage />);
expect(
await screen.findByText("panels.retentionUnavailable")
).toBeInTheDocument();
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 () => {
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,373 @@
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";
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 {
const [n, o, s, a] = await Promise.all([
operationsApi.notices(),
operationsApi.outbox(),
operationsApi.sessions(),
operationsApi.alerts(),
]);
setNotices(n);
setOutbox(o);
setSessions(s);
setAlerts(a);
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 && (
<span className="ms-2 text-xs italic text-red-700 dark:text-red-400">
{t("alerts.undelivered")}
</span>
)}
</li>
))}
</ul>
</div>
)}
<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>
<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,54 @@
export type NoticeRow = {
tenant_name: string;
kind: string;
for_end_date: string | null;
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_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>;
stuck: number;
oldest_pending_at: string | null;
failing_targets: FailingTarget[];
};
export type SessionSummary = {
active: number;
ended_by_reason: Record<string, number>;
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;
notify_count: number;
};
export type AuditRetentionStatus = {
total_entries: number;
oldest_entry: string | null;
past_retention: number;
retention_days: number;
security_retention_days: number;
};
+70
View File
@@ -0,0 +1,70 @@
import { apiClient } from "../../lib/apiClient";
import type { OrgMember, OrgUnit, SeatSummary } from "./OrgTypes";
export const orgApi = {
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",
}),
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",
}),
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",
}),
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",
}),
seatSummary: () =>
apiClient.get<SeatSummary>("/api/org-units/seats", { toast: false }),
setSeats: (id: string, seatLimit: number | null) =>
apiClient.put<SeatSummary>(
`/api/org-units/${id}/seats`,
{ seat_limit: seatLimit },
{ toast: false }
),
};
+24
View File
@@ -0,0 +1,24 @@
export type OrgUnit = {
id: string;
name: string;
code?: string | null;
parent_id?: string | null;
path: string;
depth: number;
is_active: boolean;
seat_limit?: number | null;
seats_used: number;
};
export type SeatSummary = {
purchased: number | null;
allocated: number;
unallocated: number | null;
};
export type OrgMember = {
user_id: string;
email: string;
is_primary: boolean;
is_lead: boolean;
};
@@ -0,0 +1,500 @@
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";
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>
<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);
}
try {
setSeats(await orgApi.seatSummary());
} catch {
setSeats(null);
}
}, []);
useEffect(() => {
void load();
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) {
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>
<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"
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>
<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}
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,195 @@
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";
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 () => {
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 () => {
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 () => {
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 () => {
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", () => {
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", () => {
const { container } = render(<SeatSummaryStrip summary={null} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,102 @@
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";
const SeatAllocationDialog: React.FC<{
unit: OrgUnit | null;
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(() => {
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) {
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>
<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,46 @@
import React from "react";
import { useTranslation } from "react-i18next";
import type { SeatSummary } from "./OrgTypes";
const SeatSummaryStrip: React.FC<{ summary: SeatSummary | null }> = ({ summary }) => {
const { t } = useTranslation(["organisation", "common"]);
if (!summary) return null;
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;
+9 -23
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 = {
@@ -63,7 +66,6 @@ const ProfilePage: React.FC = () => {
const [profileSuccess, setProfileSuccess] = useState("");
const [isProfileSubmitting, setIsProfileSubmitting] = useState(false);
// Theme Logic
const { currentPalette, setTheme } = useTheme();
const [palettes, setPalettes] = useState<ColorPalette[]>([]);
const [isThemesLoading, setIsThemesLoading] = useState(false);
@@ -118,7 +120,6 @@ const ProfilePage: React.FC = () => {
setPasswordError("");
setPasswordSuccess("");
// Client-side validation
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
setPasswordError(t('messages.requiredFields'));
return;
@@ -148,7 +149,6 @@ const ProfilePage: React.FC = () => {
confirmPassword: "",
});
// Close modal after 1.5 seconds
setTimeout(() => {
setIsPasswordModalOpen(false);
setPasswordSuccess("");
@@ -172,7 +172,6 @@ const ProfilePage: React.FC = () => {
setPasswordSuccess("");
};
// Edit Profile handlers
const handleOpenEditModal = () => {
if (user) {
setProfileForm({
@@ -212,12 +211,10 @@ const ProfilePage: React.FC = () => {
});
setProfileSuccess(t('messages.profileSuccess'));
// Refresh user data
if (refreshUser) {
await refreshUser();
}
// Close modal after 1.5 seconds
setTimeout(() => {
setIsEditModalOpen(false);
setProfileSuccess("");
@@ -238,7 +235,6 @@ const ProfilePage: React.FC = () => {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{t('title')}</h1>
<p className="text-sm text-[var(--text-secondary)]">
@@ -246,12 +242,9 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Profile Card */}
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
{/* User Header Section */}
<div className="border-b border-(--card-border) bg-(--table-row-hover) px-6 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
{/* User Info */}
<div className="flex items-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-indigo-600 text-2xl font-bold text-white shadow-lg">
{initials}
@@ -282,13 +275,11 @@ const ProfilePage: React.FC = () => {
</div>
</div>
{/* User Details Section */}
<div className="p-6">
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
{t('sections.accountInfo')}
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* First Name */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.firstName')}
@@ -298,7 +289,6 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Last Name */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.lastName')}
@@ -308,7 +298,6 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Email */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.email')}
@@ -318,7 +307,6 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Phone Number */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.phone')}
@@ -328,7 +316,6 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Tenant */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.tenant')}
@@ -338,7 +325,6 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Role */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.role')}
@@ -348,7 +334,6 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Account Created */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.accountCreated')}
@@ -358,7 +343,6 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Last Updated */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.lastUpdated')}
@@ -371,7 +355,12 @@ const ProfilePage: React.FC = () => {
</div>
</div>
{/* Appearance / Theme Section */}
<SecurityPanel />
<SessionsPanel />
<NotificationPreferencesPanel />
<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)]">
{t('sections.appearance')}
@@ -409,7 +398,6 @@ const ProfilePage: React.FC = () => {
)}
</div>
{/* Color Preview Circles */}
<div className="flex -space-x-2 overflow-hidden ml-4">
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.sidebar_bg }} />
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.primary }} />
@@ -429,7 +417,6 @@ const ProfilePage: React.FC = () => {
)}
</div>
{/* Change Password Modal */}
<CustomModal
isOpen={isPasswordModalOpen}
onClose={handlePasswordModalClose}
@@ -498,7 +485,6 @@ const ProfilePage: React.FC = () => {
</form>
</CustomModal>
{/* Edit Profile Modal */}
<CustomModal
isOpen={isEditModalOpen}
onClose={handleEditModalClose}
+10
View File
@@ -11,3 +11,13 @@ export type FormatDateFunction = (value: string) => string;
export type FormatNameFunction = (firstName: string, lastName?: string | null) => string;
export type RenderStatusBadgeFunction = (status?: string) => JSX.Element;
export interface UserSession {
id: string;
user_agent: string | null;
ip_address: string | null;
created_at: string;
last_used_at: string;
expires_at: string;
is_current: boolean;
}
@@ -0,0 +1,137 @@
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";
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 () => {
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 () => {
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");
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 () => {
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()
);
});
});
+192
View File
@@ -0,0 +1,192 @@
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";
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 {
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>
{!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;
+74
View File
@@ -0,0 +1,74 @@
import { apiClient } from "../../lib/apiClient";
export type ReferenceList = {
id: string;
code: string;
name: string;
description?: string | null;
allows_custom_items: boolean;
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;
};
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",
}),
retireItem: (id: string) =>
apiClient.delete<ReferenceItem>(`/api/reference/items/${id}`, {
successMessage: "Item retired",
errorMessage: "Could not retire the item",
}),
};
+410
View File
@@ -0,0 +1,410 @@
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";
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>
{!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) {
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;
+2 -1
View File
@@ -16,7 +16,8 @@ export const rolesApi = {
getById: (roleId: string) =>
apiClient.get<RoleWithAccesses>(`/api/role/get/${roleId}`),
getAccesses: () => apiClient.get<RoleAccess[]>("/api/access/get"),
getAccesses: (tenantId?: string) =>
apiClient.get<RoleAccess[]>(`/api/access/get${tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : ""}`),
create: (payload: RoleCreateRequest) =>
apiClient.post<Role>("/api/role/create", payload, { successMessage: "Role created successfully", errorMessage: "Failed to create role" }),
+37 -47
View File
@@ -40,27 +40,6 @@ const AddRoles = () => {
}
let isMounted = true;
const loadAccesses = async () => {
setIsAccessLoading(true);
try {
const data = await rolesApi.getAccesses();
if (isMounted) {
setAccessOptions(data);
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error
? error.message
: "Unable to load access options.";
setErrorMessage(message);
}
} finally {
if (isMounted) {
setIsAccessLoading(false);
}
}
};
const loadTenants = async () => {
if (!canReadAllTenants) {
@@ -128,44 +107,55 @@ const AddRoles = () => {
}
};
loadAccesses();
loadTenants();
return () => {
isMounted = false;
};
}, [canReadAllTenants, isAuthLoading, user?.tenant_id, user?.tenant_name]);
useEffect(() => {
if (isAuthLoading) return;
let isMounted = true;
const loadAccesses = async () => {
setIsAccessLoading(true);
try {
const data = await rolesApi.getAccesses(formData.tenant_id || undefined);
if (isMounted) {
setAccessOptions(data);
const validIds = new Set(data.map((d) => d.id));
setFormData((prev) => ({
...prev,
access_ids: (prev.access_ids || []).filter((id) => validIds.has(id)),
}));
}
} catch (error) {
if (isMounted) {
const message =
error instanceof Error
? error.message
: "Unable to load access options.";
setErrorMessage(message);
}
} finally {
if (isMounted) {
setIsAccessLoading(false);
}
}
};
loadAccesses();
return () => {
isMounted = false;
};
}, [formData.tenant_id, isAuthLoading]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const availableAccessOptions = useMemo(() => {
if (canReadAllTenants) return accessOptions;
if (!user?.role?.accesses) return [];
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
const includedIds = new Set<string>();
accessOptions.forEach((option) => {
if (user.role?.accesses.includes(option.access_code)) {
let current: RoleAccess | undefined = option;
while (current) {
if (includedIds.has(current.id)) break;
includedIds.add(current.id);
if (current.parent_id) {
current = accessMap.get(current.parent_id);
} else {
current = undefined;
}
}
}
});
return accessOptions.filter((option) => includedIds.has(option.id));
}, [accessOptions, user, canReadAllTenants]);
return accessOptions;
}, [accessOptions]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
+7 -30
View File
@@ -94,31 +94,8 @@ const AllRoles = () => {
const [isDeleting, setIsDeleting] = useState(false);
const availableAccessOptions = useMemo(() => {
if (canReadAllTenants) return accessOptions;
if (!currentUser?.role?.accesses) return [];
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
const includedIds = new Set<string>();
accessOptions.forEach((option) => {
if (currentUser.role?.accesses.includes(option.access_code)) {
let current: RoleAccess | undefined = option;
while (current) {
if (includedIds.has(current.id)) break;
includedIds.add(current.id);
if (current.parent_id) {
current = accessMap.get(current.parent_id);
} else {
current = undefined;
}
}
}
});
return accessOptions.filter((option) => includedIds.has(option.id));
}, [accessOptions, currentUser, canReadAllTenants]);
return accessOptions;
}, [accessOptions]);
useEffect(() => {
let isMounted = true;
@@ -267,7 +244,11 @@ const AllRoles = () => {
setEditError("");
setIsEditOpen(true);
try {
const details = await rolesApi.getById(role.id);
const [details, tenantAccesses] = await Promise.all([
rolesApi.getById(role.id),
rolesApi.getAccesses(role.tenant_id || undefined),
]);
setAccessOptions(tenantAccesses);
setEditAccessIds(details.accesses?.map((a) => a.id) || []);
} catch (err) {
setEditError(err instanceof Error ? err.message : t('messages.error'));
@@ -476,7 +457,6 @@ const AllRoles = () => {
</div>
{errorMessage ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
{errorMessage}
@@ -504,7 +484,6 @@ const AllRoles = () => {
/>
)}
{/* View Modal */}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
@@ -548,7 +527,6 @@ const AllRoles = () => {
)}
</CustomModal>
{/* Edit Modal */}
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
@@ -600,7 +578,6 @@ const AllRoles = () => {
</form>
</CustomModal>
{/* Delete Modal */}
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
@@ -289,7 +289,6 @@ export const GroupedAccessSelector = ({
return (
<div className="space-y-4 pt-2">
{/* Global Header */}
<div className="flex items-center justify-between border-b pb-4 mb-6">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<div className="flex items-center gap-2">
@@ -317,7 +316,6 @@ export const GroupedAccessSelector = ({
const isExpanded = expandedModules[moduleGroup.name];
return (
<div key={moduleGroup.name} className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden">
{/* Module Header (Collapsible) */}
<div
className="flex items-center justify-between p-4 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => toggleModuleExpansion(moduleGroup.name)}
@@ -326,7 +324,7 @@ export const GroupedAccessSelector = ({
{isExpanded ? <ChevronDown size={20} className="text-gray-500" /> : <ChevronRight size={20} className="text-gray-500" />}
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Prevent collapse when clicking checkbox
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
@@ -342,7 +340,6 @@ export const GroupedAccessSelector = ({
</div>
</div>
{/* Module Content (Collapsible) */}
{isExpanded && (
<div className="p-4 border-t border-gray-200 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="space-y-8">
@@ -351,7 +348,6 @@ export const GroupedAccessSelector = ({
key={category}
className="rounded-lg border border-gray-200 bg-gray-50/10 p-4"
>
{/* Category Header with Select All Category */}
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
<input
type="checkbox"
@@ -368,12 +364,10 @@ export const GroupedAccessSelector = ({
</div>
<div className="space-y-4">
{/* 1. Root Branches (Cards) */}
{roots
.filter((r) => childMap[r.id]?.length > 0)
.map((root) => renderNode(root, 0))}
{/* 2. Root Leaves (Grid) */}
{roots.some((r) => !childMap[r.id]?.length) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 mt-4 ml-1">
{roots
@@ -392,4 +386,4 @@ export const GroupedAccessSelector = ({
</div>
</div>
);
};
};
@@ -1,105 +1,188 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Layers, ShieldCheck } from "lucide-react";
import type { RoleAccess } from "../RolesTypes";
interface GroupedAccessViewerProps {
accesses: RoleAccess[];
}
interface CategoryGroup {
category: string;
accesses: RoleAccess[];
children: Record<string, CategoryGroup>;
}
export const GroupedAccessViewer = ({ accesses }: GroupedAccessViewerProps) => {
const hierarchicalGroups = useMemo(() => {
const parents: RoleAccess[] = [];
const children: Record<string, RoleAccess[]> = {};
accesses.forEach(access => {
if (!access.parent_id) {
parents.push(access);
const { moduleGroups, totalCount } = useMemo(() => {
const ids = new Set(accesses.map((a) => a.id));
const childMap: Record<string, RoleAccess[]> = {};
const roots: RoleAccess[] = [];
accesses.forEach((access) => {
const hasParent = access.parent_id && ids.has(access.parent_id);
if (hasParent) {
if (!childMap[access.parent_id!]) {
childMap[access.parent_id!] = [];
}
childMap[access.parent_id!].push(access);
} else {
if (!children[access.parent_id]) {
children[access.parent_id] = [];
}
children[access.parent_id].push(access);
roots.push(access);
}
});
const categoryGroups: Record<string, CategoryGroup> = {};
parents.forEach(parent => {
if (!categoryGroups[parent.category]) {
categoryGroups[parent.category] = {
category: parent.category,
accesses: [],
children: {}
};
const modGroups: Record<string, Record<string, { roots: RoleAccess[]; count: number }>> = {};
roots.forEach((root) => {
const moduleName = root.module_name || "SaaS (Internal)";
const cat = root.category || "General";
if (!modGroups[moduleName]) {
modGroups[moduleName] = {};
}
categoryGroups[parent.category].accesses.push(parent);
const parentChildren = children[parent.id] || [];
parentChildren.forEach(child => {
if (!categoryGroups[parent.category].children[child.category]) {
categoryGroups[parent.category].children[child.category] = {
category: child.category,
accesses: [],
children: {}
};
}
categoryGroups[parent.category].children[child.category].accesses.push(child);
});
if (!modGroups[moduleName][cat]) {
modGroups[moduleName][cat] = { roots: [], count: 0 };
}
modGroups[moduleName][cat].roots.push(root);
// Count root and all its children
const countTree = (nodeId: string): number => {
const children = childMap[nodeId] || [];
return 1 + children.reduce((sum, c) => sum + countTree(c.id), 0);
};
modGroups[moduleName][cat].count += countTree(root.id);
});
return categoryGroups;
const sortedModuleNames = Object.keys(modGroups).sort((a, b) => {
if (a === "SaaS (Internal)") return -1;
if (b === "SaaS (Internal)") return 1;
return a.localeCompare(b);
});
const orderedGroups = sortedModuleNames.map((name) => {
const categories = modGroups[name];
const moduleTotal = Object.values(categories).reduce((sum, c) => sum + c.count, 0);
return {
name,
total: moduleTotal,
categories,
};
});
return { moduleGroups: orderedGroups, childMap, totalCount: accesses.length };
}, [accesses]);
const [expandedModules, setExpandedModules] = useState<Record<string, boolean>>(() => {
// Expand all by default
const initial: Record<string, boolean> = {};
moduleGroups.forEach((m) => {
initial[m.name] = true;
});
return initial;
});
const toggleModule = (moduleName: string) => {
setExpandedModules((prev) => ({
...prev,
[moduleName]: !prev[moduleName],
}));
};
if (!accesses || accesses.length === 0) {
return <p className="text-sm text-gray-500">No permissions assigned.</p>;
return (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/50 p-6 text-center">
<ShieldCheck className="mx-auto h-8 w-8 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">No permissions assigned to this role.</p>
</div>
);
}
return (
<div className="space-y-4">
{Object.entries(hierarchicalGroups).map(([category, group]) => (
<div key={category} className="rounded-lg border border-gray-200 bg-gray-50/50 p-4">
<h4 className="mb-3 text-sm font-semibold text-gray-800 uppercase tracking-wide border-b border-gray-200 pb-2">
{category}
</h4>
{group.accesses.length > 0 && (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 mb-3">
{group.accesses.map((access) => (
<li key={access.id} className="flex items-start gap-2 text-sm text-gray-700">
<span className="mt-1.5 size-1.5 rounded-full bg-blue-500 shrink-0" />
<span>{access.name}</span>
</li>
))}
</ul>
)}
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Total Assigned: {totalCount} {totalCount === 1 ? "Permission" : "Permissions"}
</span>
<button
type="button"
onClick={() => {
const allExpanded = moduleGroups.every((m) => expandedModules[m.name]);
const next: Record<string, boolean> = {};
moduleGroups.forEach((m) => {
next[m.name] = !allExpanded;
});
setExpandedModules(next);
}}
className="text-xs font-medium text-blue-600 hover:text-blue-700 underline cursor-pointer"
>
{moduleGroups.every((m) => expandedModules[m.name]) ? "Collapse All" : "Expand All"}
</button>
</div>
{Object.keys(group.children).length > 0 && (
<div className="space-y-3 mt-3">
{Object.entries(group.children).map(([childCategory, childGroup]) => (
<div key={childCategory} className="rounded-md border border-gray-300 bg-white p-3 ml-4">
<h5 className="mb-2 text-xs font-semibold text-gray-700 uppercase tracking-wide border-b border-gray-200 pb-1">
{childCategory}
</h5>
<ul className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{childGroup.accesses.map((access) => (
<li key={access.id} className="flex items-start gap-1.5 text-xs text-gray-600">
<span className="mt-1 size-1 rounded-full bg-green-500 shrink-0" />
<span>{access.name}</span>
</li>
))}
</ul>
<div className="space-y-3">
{moduleGroups.map((moduleGroup) => {
const isExpanded = expandedModules[moduleGroup.name] ?? true;
return (
<div
key={moduleGroup.name}
className="rounded-lg border border-gray-200 bg-white shadow-xs overflow-hidden transition-all"
>
{/* Module Header */}
<div
onClick={() => toggleModule(moduleGroup.name)}
className="flex items-center justify-between px-4 py-3 bg-gray-50/80 hover:bg-gray-100/80 cursor-pointer border-b border-gray-100 select-none transition-colors"
>
<div className="flex items-center gap-2.5">
<span className="text-gray-400">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</span>
<div className="flex items-center gap-2">
<Layers size={16} className="text-blue-600" />
<h3 className="text-sm font-bold text-gray-900">{moduleGroup.name}</h3>
</div>
</div>
))}
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-700 border border-blue-100">
{moduleGroup.total} {moduleGroup.total === 1 ? "access" : "accesses"}
</span>
</div>
{/* Module Content */}
{isExpanded && (
<div className="p-4 space-y-4">
{Object.entries(moduleGroup.categories).map(([category, { roots }]) => (
<div
key={category}
className="rounded-md border border-gray-100 bg-gray-50/40 p-3"
>
<div className="mb-2.5 flex items-center justify-between border-b border-gray-200/60 pb-1.5">
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
{category}
</h4>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{roots.map((root) => (
<div
key={root.id}
className="flex items-start gap-2 rounded bg-white p-2 border border-gray-100 shadow-xs"
>
<span className="mt-1.5 size-1.5 rounded-full bg-blue-500 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-gray-800 block truncate" title={root.name}>
{root.name}
</span>
{root.access_code && (
<span className="text-[10px] text-gray-400 block font-mono">
{root.access_code}
</span>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
);
})}
</div>
</div>
);
};
@@ -131,12 +131,10 @@ export const NodeGroupAccessViewer = ({ accesses }: NodeGroupAccessViewerProps)
<div className="space-y-8">
{Object.entries(categoryGroups).map(([category, roots]) => (
<div key={category} className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Category Header */}
<div className="bg-gray-50/80 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h3 className="font-bold text-gray-800 text-lg">{category}</h3>
</div>
{/* Roots in this Category - Grid Layout */}
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
{roots.map(root => renderNode(root, 0))}
</div>
@@ -144,4 +142,4 @@ export const NodeGroupAccessViewer = ({ accesses }: NodeGroupAccessViewerProps)
))}
</div>
);
};
};
+45
View File
@@ -0,0 +1,45 @@
import { apiClient } from "../../lib/apiClient";
import type {
MfaEnrolmentStarted,
MfaRecoveryCodes,
MfaStatus,
} from "./SecurityTypes";
export const securityApi = {
status: () => apiClient.get<MfaStatus>("/api/auth/mfa"),
beginEnrolment: () =>
apiClient.post<MfaEnrolmentStarted>("/api/auth/mfa/enrol", null, {
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,196 @@
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";
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),
},
}));
vi.mock("./qr", () => ({
toQrDataUrl: async () => "data:image/png;base64,x",
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
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();
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 () => {
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 () => {
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 () => {
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 () => {
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();
});
});
+414
View File
@@ -0,0 +1,414 @@
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";
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 = () => {
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>
<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 {
setFailed(true);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const startEnrolment = async () => {
setError("");
setIsBusy(true);
try {
const started = await securityApi.beginEnrolment();
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) {
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>}
{!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>
{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;
+14
View File
@@ -0,0 +1,14 @@
export type MfaStatus = {
enabled: boolean;
enrolment_pending: boolean;
recovery_codes_remaining: number;
};
export type MfaEnrolmentStarted = {
secret: string;
otpauth_uri: string;
};
export type MfaRecoveryCodes = {
codes: string[];
};
+8
View File
@@ -0,0 +1,8 @@
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 -7
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,12 +31,14 @@ const SettingsPage: React.FC = () => {
setShowConfirmation(false);
try {
await loadLanguage(pendingLanguage);
localStorage.setItem('preferred_language', pendingLanguage);
if (user) {
await updateLanguage(pendingLanguage);
}
window.location.reload();
} catch (error) {
console.error('Failed to update language:', error);
@@ -53,7 +55,6 @@ const SettingsPage: React.FC = () => {
return (
<div className="max-w-4xl mx-auto">
{/* Page Header */}
<div className="flex flex-col gap-1 mb-6">
<h1 className="text-2xl font-bold text-(--text-primary)">
{t('settings.title')}
@@ -61,7 +62,6 @@ const SettingsPage: React.FC = () => {
<p className="text-sm text-[var(--text-secondary)]">Manage your account settings and application preferences.</p>
</div>
{/* Language Settings Section */}
<div className="bg-(--card-bg) rounded-lg border border-(--card-border) p-6">
<div className="flex items-center gap-3 mb-4">
<Globe className="w-5 h-5 text-(--primary)" />
@@ -89,7 +89,6 @@ const SettingsPage: React.FC = () => {
</div>
</div>
{/* Confirmation Modal */}
<CustomConfirmationModal
isOpen={showConfirmation}
onClose={handleCancelLanguageChange}
@@ -103,4 +102,4 @@ const SettingsPage: React.FC = () => {
);
};
export default SettingsPage;
export default SettingsPage;
+34
View File
@@ -0,0 +1,34 @@
import { apiClient } from "../../lib/apiClient";
import type {
IdentityProvider,
IdentityProviderCreate,
IdentityProviderUpdate,
} from "./SsoTypes";
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",
}),
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",
}),
};
+397
View File
@@ -0,0 +1,397 @@
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";
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 ?? "");
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,
...(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,
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>
<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;
+40
View File
@@ -0,0 +1,40 @@
export type IdentityProvider = {
id: string;
tenant_id: string;
kind: string;
name: string;
slug: string;
enabled: boolean;
issuer?: string | null;
client_id?: string | null;
scopes: string;
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;
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">
> & {
enabled?: boolean;
};
@@ -5,6 +5,7 @@ export type SubscriptionPlan = {
price?: number | null;
duration_days?: number | null;
max_users_allowed?: number | null;
grace_period_days?: number | null;
is_public: boolean;
status: string;
created_at: string;
@@ -22,6 +23,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 +36,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,126 @@
import { describe, expect, it } from "vitest";
import { buildPlanCreatePayload, buildPlanUpdatePayload } from "./buildPlanPayload";
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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,56 @@
import { splitAccessIds, type AccessLike } from "./splitAccessIds";
import type {
SubscriptionPlanCreateRequest,
SubscriptionPlanUpdateRequest,
} from "./SubscriptionTypes";
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;
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),
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,19 @@ const AddSubscriptions = () => {
}))
}
/>
<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) =>
@@ -581,7 +553,6 @@ const AllSubscriptions = () => {
/>
)}
{/* View Modal */}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
@@ -693,7 +664,6 @@ const AllSubscriptions = () => {
)}
</CustomModal>
{/* Edit Modal */}
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
@@ -773,6 +743,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
@@ -829,7 +812,6 @@ const AllSubscriptions = () => {
</form>
</CustomModal>
{/* Delete Modal */}
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { splitAccessIds } from "./splitAccessIds";
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", () => {
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", () => {
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", () => {
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", () => {
const result = splitAccessIds(
["a"],
[platform("a"), platform("b"), ofModule("m")]
);
expect(result.accessIds).toEqual(["a"]);
expect(result.moduleAccessIds).toEqual([]);
});
});
@@ -0,0 +1,29 @@
export type AccessLike = {
id: string;
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);
if (access?.module_id) {
moduleAccessIds.push(id);
} else {
accessIds.push(id);
}
}
return { accessIds, moduleAccessIds };
};
+1 -3
View File
@@ -1,4 +1,3 @@
// src/features/Tenants/TenantsApi.ts
import { apiClient } from "../../lib/apiClient";
import { buildQueryString } from "../../lib/queryParams";
import type {
@@ -26,7 +25,6 @@ export const tenantsApi = {
remove: (tenantId: string) =>
apiClient.delete<ApiMessage>(`/api/tenant/delete/${tenantId}`, { successMessage: "Tenant deleted", errorMessage: "Failed to delete tenant" }),
// Fixed: Manually append query params since apiClient doesn't support { params }
getPaginated: (params: {
page?: number;
page_size?: number;
@@ -55,4 +53,4 @@ export const tenantsApi = {
`/api/tenant/list${queryString}`
);
},
};
};
+4 -1
View File
@@ -6,6 +6,7 @@ export type Tenant = {
tenant_name: string;
tenant_domain: string;
tenant_logo_url?: string | null;
billing_email?: string | null;
is_active: boolean;
plan_id?: string | null;
start_date?: string | null;
@@ -24,6 +25,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 +37,7 @@ export type TenantUpdateRequest = {
tenant_name?: string;
tenant_domain?: string;
tenant_logo_url?: string | null;
billing_email?: string | null;
is_active?: boolean;
plan_id?: string;
start_date?: string | null;
@@ -52,4 +55,4 @@ export type TenantPaginatedResponse = {
export type ApiMessage = {
message?: string;
};
};
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
buildTenantCreatePayload,
buildTenantUpdatePayload,
} from "./buildTenantPayload";
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
expect(
buildTenantUpdatePayload({ ...editBase, isActive: false }).is_active
).toBe(false);
});
it("leaves the plan alone when none was chosen", () => {
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,66 @@
import type {
ModuleEnvironmentAssignment,
TenantCreateRequest,
TenantStatus,
TenantUpdateRequest,
} from "./TenantsTypes";
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,
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(),
tenant_logo_url: trimmed(values.tenantLogoUrl) ?? null,
billing_email: trimmed(values.billingEmail) ?? null,
is_active: values.isActive,
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,15 @@ const AddTenants = () => {
onChange={(e) => setTenantLogoUrl(e.target.value)}
/>
<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
@@ -367,7 +379,6 @@ const AddTenants = () => {
/>
</div>
{/* Module Environment Assignment — appears after plan selection */}
{selectedPlanId && (
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-1">Module Environments</h3>
@@ -442,4 +453,4 @@ const AddTenants = () => {
);
};
export default AddTenants;
export default AddTenants;
@@ -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: "",
@@ -99,7 +102,6 @@ const AllTenants = () => {
status: "ACTIVE",
});
// Subscription plans for dropdown
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [editError, setEditError] = useState("");
@@ -318,6 +320,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 +377,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 +845,15 @@ const AllTenants = () => {
onChange={handleEditChange}
/>
<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"
+2 -5
View File
@@ -14,15 +14,12 @@ export interface ColorSet {
header_text: string;
header_border: string;
// Backgrounds
background: string;
background_secondary: string;
// Text
text_primary: string;
text_secondary: string;
// Cards / Surfaces
card_bg: string;
card_border: string;
@@ -30,7 +27,7 @@ export interface ColorSet {
table_row_hover: string;
table_border: string;
[key: string]: string; // Allow extra colors
[key: string]: string;
}
export interface ColorPalette {
@@ -42,7 +39,7 @@ export interface ColorPalette {
tenant_id?: string;
created_at?: string;
updated_at?: string;
[key: string]: unknown; // Allow for other properties and CustomTable compatibility
[key: string]: unknown;
}
export interface ColorPalettePayload {
@@ -79,7 +79,6 @@ const AllPalettes: React.FC = () => {
}
setIsModalOpen(false);
fetchPalettes();
// Optionally refresh theme if the updated palette was the active one
refreshTheme();
} finally {
setIsLoading(false);
+54 -34
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,
@@ -68,13 +69,11 @@ const AllUsers = () => {
const { t, i18n } = useTranslation(['users', 'common']);
const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth();
// State
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState("");
// Pagination & filters
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() =>
resolveStoredTablePageSize({
@@ -90,6 +89,7 @@ const AllUsers = () => {
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
const [roleFilter, setRoleFilter] = useState<string[]>([]);
const [totalRows, setTotalRows] = useState(0);
const [refreshToken, setRefreshToken] = useState(0);
const [activeSort, setActiveSort] = useState<{
column: "first_name" | "email" | "status" | "tenant_id" | "role_id" | null;
direction: ColumnSortDirection;
@@ -100,7 +100,6 @@ const AllUsers = () => {
const prevLoadingRef = useRef(isLoading);
const latestUsersRequestRef = useRef(0);
// Modals
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
@@ -111,7 +110,6 @@ const AllUsers = () => {
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Supporting data
const [roles, setRoles] = useState<Role[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [allUsersForCounts, setAllUsersForCounts] = useState<User[]>([]);
@@ -211,14 +209,13 @@ const AllUsers = () => {
tenantFilter,
roleFilter,
activeSort,
refreshToken,
]);
// Reset page on search/filter change
useEffect(() => {
setPage(1);
}, [debouncedSearch, nameFilter, emailFilter, statusFilter, tenantFilter, roleFilter, activeSort]);
// Restore search focus
useEffect(() => {
if (prevLoadingRef.current && !isLoading && search.trim()) {
searchInputRef.current?.focus({ preventScroll: true });
@@ -558,27 +555,33 @@ const AllUsers = () => {
key: "id",
header: t('columns.actions'),
searchable: false,
render: (row) => (
<CustomActionMenu>
<ProtectedComponent requiredAccess="admin.user.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> {t('actions.view')}
</CustomActionItem>
</ProtectedComponent>
render: (row) => {
const isSelf = Boolean(currentUser?.id && row.id === currentUser.id);
<ProtectedComponent requiredAccess="admin.user.update">
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
</CustomActionItem>
</ProtectedComponent>
return (
<CustomActionMenu>
<ProtectedComponent requiredAccess="admin.user.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> {t('actions.view')}
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="admin.user.delete">
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
</CustomActionItem>
</ProtectedComponent>
</CustomActionMenu>
),
<ProtectedComponent requiredAccess="admin.user.update">
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
</CustomActionItem>
</ProtectedComponent>
{!isSelf && (
<ProtectedComponent requiredAccess="admin.user.delete">
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
</CustomActionItem>
</ProtectedComponent>
)}
</CustomActionMenu>
);
},
},
],
[activeSort, emailCounts, emailFilter, getTenantName, getRoleName, hasAccess, nameCounts, nameFilter, openDelete, openEdit, openView, roleCounts, roleFilter, roles, statusCounts, statusFilter, t, tenantCounts, tenantFilter, tenants]
@@ -592,14 +595,18 @@ 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>
</Link>
<div className="flex flex-wrap gap-2">
<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>
{errorMessage ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
{errorMessage}
@@ -627,7 +634,10 @@ const AllUsers = () => {
/>
)}
{/* View Modal */}
<ProtectedComponent requiredAccess="admin.user.create">
<DeletedUsersPanel onRestored={() => setRefreshToken((n) => n + 1)} />
</ProtectedComponent>
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
@@ -662,7 +672,6 @@ const AllUsers = () => {
)}
</CustomModal>
{/* Edit Modal */}
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
@@ -692,15 +701,26 @@ const AllUsers = () => {
disabled={isRoleLoading || filteredRoles.length === 0}
/>
<CustomInput label={t('fields.password')} name="password" type="password" placeholder={t('fields.passwordEditPlaceholder')} value={editForm.password} onChange={handleEditChange} />
<div className="flex items-center">
<CustomCheckBox label={t('fields.active')} checked={editForm.status === "active"} onChange={handleStatusChange} />
<div className="flex flex-col gap-1">
<div className="flex items-center">
<CustomCheckBox
label={t('fields.active')}
checked={editForm.status === "active"}
onChange={handleStatusChange}
disabled={Boolean(currentUser?.id && selectedUser?.id === currentUser.id)}
/>
</div>
{Boolean(currentUser?.id && selectedUser?.id === currentUser.id) && (
<span className="text-xs text-amber-600 ml-6">
You cannot deactivate your own account.
</span>
)}
</div>
</div>
{editError && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">{editError}</div>}
</form>
</CustomModal>
{/* Delete Modal */}
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
@@ -0,0 +1,135 @@
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";
type DeletedUser = {
id: string;
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;
+58
View File
@@ -0,0 +1,58 @@
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, {
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 }
),
sendTest: (id: string) =>
apiClient.post<WebhookTestResult>(`/api/webhooks/${id}/test`, null, {
toast: false,
}),
};
+55
View File
@@ -0,0 +1,55 @@
export type WebhookEndpoint = {
id: string;
url: string;
description?: string | null;
event_types: string[];
is_active: boolean;
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;
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;
};
export const EVENT_TYPES = [
"user.created",
"user.updated",
"user.deleted",
"user.invited",
"invitation.accepted",
"subscription.changed",
] as const;
+491
View File
@@ -0,0 +1,491 @@
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";
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) => {
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">
{!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;
+3 -6
View File
@@ -20,16 +20,14 @@ export const CustomActionMenu: React.FC<CustomActionMenuProps> = ({
const menuRect = menuRef.current?.getBoundingClientRect();
let top = buttonRect.bottom + 4;
let left = buttonRect.right - (menuRect?.width || 160); // Default items width
let left = buttonRect.right - (menuRect?.width || 160);
// Check if it fits vertically
if (menuRect && top + menuRect.height > window.innerHeight) {
top = buttonRect.top - menuRect.height - 4; // Flip up
top = buttonRect.top - menuRect.height - 4;
}
// Check if it fits horizontally
if (left < 0) {
left = buttonRect.left; // Align left if no space on right
left = buttonRect.left;
}
setPosition({ top, left });
@@ -54,7 +52,6 @@ export const CustomActionMenu: React.FC<CustomActionMenuProps> = ({
};
}, [isOpen]);
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
buildColumnFilterOptions,
resolveColumnSortState,
} from "./CustomColumnFilter.utils";
describe("buildColumnFilterOptions", () => {
it("puts the count in the label and the bare value in the value", () => {
expect(buildColumnFilterOptions([["ACTIVE", 12]])).toEqual([
{ label: "ACTIVE (12)", value: "ACTIVE" },
]);
});
it("keeps the order it was given", () => {
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", () => {
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", () => {
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);
});
});
@@ -29,14 +29,12 @@ const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
const [previews, setPreviews] = useState<{ [key: string]: string }>({});
useEffect(() => {
// Cleanup object URLs to avoid memory leaks
return () => {
Object.values(previews).forEach((url) => URL.revokeObjectURL(url));
};
}, [previews]);
useEffect(() => {
// Generate previews for new files
const newPreviews: { [key: string]: string } = {};
value.forEach((file) => {
if (file.type.startsWith("image/") && !previews[file.name]) {
@@ -90,7 +88,6 @@ const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
const newFiles = value.filter((_, i) => i !== index);
onChange?.(newFiles);
// Cleanup preview if it exists
if (previews[fileToRemove.name]) {
URL.revokeObjectURL(previews[fileToRemove.name]);
setPreviews((prev) => {
+7 -3
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,9 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
},
ref
) => {
const generatedId = useId();
const inputId = props.id ?? generatedId;
const isPassword = type === "password";
const isPhone = phonePrefix !== undefined;
const [showPassword, setShowPassword] = useState(false);
@@ -51,7 +54,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 +81,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
<input
ref={ref}
id={inputId}
type={inputType}
maxLength={
type === "number" || type === "tel" ? undefined : maxLength
@@ -129,4 +133,4 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
}
);
export default CustomInput;
export default CustomInput;
-3
View File
@@ -35,7 +35,6 @@ const CustomOTPInput: React.FC<CustomOTPInputProps> = ({
const combinedOtp = newOtp.join("");
onChange(combinedOtp);
// Move to next input if value is entered
if (val && index < length - 1 && inputs.current[index + 1]) {
inputs.current[index + 1]?.focus();
}
@@ -51,7 +50,6 @@ const CustomOTPInput: React.FC<CustomOTPInputProps> = ({
index > 0 &&
inputs.current[index - 1]
) {
// Move to previous input on backspace if current is empty
inputs.current[index - 1]?.focus();
}
};
@@ -61,7 +59,6 @@ const CustomOTPInput: React.FC<CustomOTPInputProps> = ({
const pastedData = e.clipboardData.getData("text").slice(0, length);
if (/^\d+$/.test(pastedData)) {
onChange(pastedData);
// Focus the last filled input or the next empty one
const nextIndex = Math.min(pastedData.length, length - 1);
inputs.current[nextIndex]?.focus();
}
@@ -51,7 +51,6 @@ const CountrySelectWithSearch = ({ value, onChange, options, disabled }: any) =>
}, [options, search]);
const selectedOption = options.find((opt: any) => opt.value === value);
// Use explicit flag from library if available, otherwise fallback to text
const FlagComponent = value && (Flags as any)[value] ? (Flags as any)[value] : null;
return (
@@ -42,7 +42,6 @@ const CustomSearchableDropdown = React.forwardRef<
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Sync search term with selected value on mount or value update
useEffect(() => {
const selectedOption = options.find((opt) => String(opt.value) === String(value));
if (selectedOption) {
@@ -63,7 +62,6 @@ const CustomSearchableDropdown = React.forwardRef<
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
// Revert to selected value label if closing without selection
const selectedOption = options.find((opt) => String(opt.value) === String(value));
if (selectedOption) {
setSearchTerm(selectedOption.label);
@@ -87,7 +85,7 @@ const CustomSearchableDropdown = React.forwardRef<
setSearchTerm(e.target.value);
setIsOpen(true);
if (e.target.value === "") {
onChange?.(""); // Clear selection if input is cleared
onChange?.("");
}
};
@@ -205,7 +203,6 @@ const CustomSearchableDropdown = React.forwardRef<
)}
</div>
{/* Dropdown Menu */}
{isOpen && !disabled && filteredOptions.length > 0 && (
<div className="absolute z-[999] w-full mt-2 bg-(--card-bg) border border-(--card-border) rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
{filteredOptions.map((option, index) => (
+3 -5
View File
@@ -9,12 +9,11 @@ export type StatusVariant =
| "neutral";
interface CustomStatusProps {
status: string; // The text to display
variant?: StatusVariant; // Explicit variant
className?: string; // Additional classes
status: string;
variant?: StatusVariant;
className?: string;
}
// Helper to deduce variant from common status strings if not provided
const getVariantFromStatus = (status: string): StatusVariant => {
const lower = status.toLowerCase();
@@ -34,7 +33,6 @@ const CustomStatus: React.FC<CustomStatusProps> = ({
const { t } = useTranslation(['orders', 'common']);
const finalVariant = variant || getVariantFromStatus(status);
// Helper to map backend status (e.g. READY_TO_SHIP) to translation key (e.g. readyToShip)
const getTranslationKey = (statusStr: string) => {
const normalize = (s: string) => s.toUpperCase().replace(/\s+/g, '_');
const normalized = normalize(statusStr);
+1 -1
View File
@@ -136,7 +136,7 @@ export function DataTable<T extends Record<string, unknown>>(
onPageSizeChange,
onSearchChange,
searchInputRef,
maxHeight = "70vh", // Default max height
maxHeight = "70vh",
isLoading,
} = props;
+3 -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 = () => {
@@ -26,7 +27,6 @@ const AppHeader: React.FC = () => {
try {
await authApi.logout();
} catch {
// ignore
}
clearAuthCookies();
navigate("/signin");
@@ -54,7 +54,6 @@ const AppHeader: React.FC = () => {
return (
<header className="sticky top-0 z-40 flex w-full bg-white/80 backdrop-blur-md border-b border-gray-200 transition-all duration-200">
<div className="flex flex-grow items-center gap-4 px-4 py-3 lg:px-6">
{/* LEFT: Mobile Toggle & Brand (Mobile only) */}
<div className="flex items-center gap-3">
<button
className="flex items-center justify-center p-2 text-gray-500 hover:bg-gray-100 rounded-lg lg:hidden transition-colors"
@@ -69,10 +68,10 @@ const AppHeader: React.FC = () => {
</Link>
</div>
{/* RIGHT: Actions */}
<div className="flex items-center gap-2 sm:gap-4 ltr:ml-auto rtl:mr-auto">
{/* User Menu */}
<NotificationBell />
<div className="relative" ref={userMenuRef}>
<button
type="button"
@@ -101,7 +100,6 @@ const AppHeader: React.FC = () => {
/>
</button>
{/* Dropdown */}
{isUserMenuOpen && (
<div
className="
+3
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,8 @@ const LayoutContent: React.FC = () => {
>
<AppHeader />
<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 />
+73 -12
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,7 @@ interface NavItem {
name: string;
path: string;
access?: string;
superadminOnly?: boolean;
submenu?: SubMenuItem[];
}
@@ -88,6 +97,54 @@ const navItems: NavItem[] = [
path: "/logs",
access: "admin.logs.read",
},
{
icon: <Activity size={22} />,
name: "Operations",
path: "/operations",
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 +182,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 +208,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 +217,7 @@ const AppSidebar: React.FC = () => {
return can(item.access);
}),
[can]
[can, user]
);
const isActive = useCallback(
@@ -171,25 +237,24 @@ const AppSidebar: React.FC = () => {
return filtered;
};
// 1. Overview
const dashboardItems = getItems(["Dashboard"]);
if (dashboardItems.length > 0) {
groups.push({ label: t('nav.groups.overview', 'Overview'), items: dashboardItems });
}
// 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 });
}
// Catch-all
const remaining = visibleNavItems.filter(item => !addedPaths.has(item.path));
if (remaining.length > 0) {
groups.push({ label: t('nav.groups.other', 'Other'), items: remaining });
@@ -211,7 +276,6 @@ const AppSidebar: React.FC = () => {
try {
await authApi.logout();
} catch {
// ignore
}
clearAuthCookies();
navigate("/signin");
@@ -236,7 +300,6 @@ const AppSidebar: React.FC = () => {
sidebar
`}
>
{/* Header */}
<div className={`h-[70px] flex items-center px-4 border-b border-white/5 ${!isExpanded && !isMobile ? 'justify-center' : 'justify-between'}`}>
{(isExpanded || isMobile) && (
<Link to="/dashboard" className="flex items-center gap-3 overflow-hidden ml-1" onClick={isMobile ? closeMobileSidebar : undefined}>
@@ -281,7 +344,6 @@ const AppSidebar: React.FC = () => {
)}
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-4 px-3 custom-scrollbar">
<div className="flex flex-col gap-6">
{groupedNavItems.map((group, idx) => (
@@ -401,7 +463,6 @@ const AppSidebar: React.FC = () => {
</div>
</nav>
{/* Footer */}
<div className="p-4 border-t border-white/5">
<button
onClick={handleLogout}
@@ -421,4 +482,4 @@ const AppSidebar: React.FC = () => {
);
};
export default AppSidebar;
export default AppSidebar;
@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import SubscriptionBanner from "./SubscriptionBanner";
const mockUser = vi.fn();
vi.mock("../../context/AuthContext", () => ({
useAuth: () => ({ user: mockUser() }),
}));
vi.mock("react-i18next", () => ({
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", () => {
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 />);
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", () => {
withSubscription({ state: "SOMETHING_NEW", can_write: false });
render(<SubscriptionBanner />);
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("is announced to assistive technology", () => {
withSubscription({ state: "GRACE", can_write: false, grace_until: "2026-03-14" });
render(<SubscriptionBanner />);
expect(screen.getByRole("status")).toBeInTheDocument();
});
});
@@ -0,0 +1,49 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Clock } from "lucide-react";
import { useAuth } from "../../context/AuthContext";
const SubscriptionBanner: React.FC = () => {
const { t, i18n } = useTranslation(["common"]);
const { user } = useAuth();
const subscription = user?.subscription_details;
const state = subscription?.state;
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;
+9 -12
View File
@@ -7,10 +7,9 @@ import type {
} from "../application/authentication/AuthTypes";
import {
setAuthCookies,
getAccessToken,
clearAuthCookies,
} from "../lib/authCookies";
import i18n from "../i18n/config";
import i18n, { loadLanguage } from "../i18n/config";
interface AuthContextType {
user: AuthUser | null;
@@ -31,21 +30,16 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Bootstrap auth on app load
useEffect(() => {
const bootstrapAuth = async () => {
const token = getAccessToken();
if (!token) {
setIsLoading(false);
return;
}
try {
const userData = await authApi.me();
setUser(userData);
setAuthCookies(null, true);
if (userData.preferred_language) {
i18n.changeLanguage(userData.preferred_language);
const preferred = userData.preferred_language;
if (preferred) {
void loadLanguage(preferred).then(() => i18n.changeLanguage(preferred));
}
} catch {
clearAuthCookies();
@@ -91,7 +85,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 {
}
+2 -9
View File
@@ -25,35 +25,28 @@ export const useSidebar = () => {
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
// Desktop state: defaulting to expanded
const [isExpanded, setIsExpanded] = useState(true);
// Mobile state: defaulting to closed
const [isMobileOpen, setIsMobileOpen] = useState(false);
// Hover state for collapsed desktop sidebar
const [isHovered, setIsHovered] = useState(false);
// Screen size state
const [isMobile, setIsMobile] = useState(false);
const location = useLocation();
// Handle Resize
useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth < 1024; // lg breakpoint
const mobile = window.innerWidth < 1024;
setIsMobile(mobile);
if (!mobile) {
setIsMobileOpen(false); // Close mobile drawer when switching to desktop
setIsMobileOpen(false);
}
};
// Initial check
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Close mobile sidebar on route change
useEffect(() => {
if (isMobile) {
setIsMobileOpen(false);
+1 -5
View File
@@ -17,7 +17,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const [currentPalette, setCurrentPalette] = useState<ColorPalette | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Function to apply colors to CSS variables
const applyTheme = (palette: ColorPalette) => {
const root = document.documentElement;
root.style.setProperty("--background", palette.colors.background);
@@ -65,18 +64,15 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
let targetPalette: ColorPalette | undefined;
// 1. Check local storage preference
const savedPaletteId = localStorage.getItem("user_theme_preference");
if (savedPaletteId) {
targetPalette = palettes.find((p) => p.id === savedPaletteId);
}
// 2. If no preference/not found, use default
if (!targetPalette) {
targetPalette = palettes.find((p) => p.is_default);
}
// 3. Fallback to first available
if (!targetPalette && palettes.length > 0) {
targetPalette = palettes[0];
}
@@ -95,7 +91,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
useEffect(() => {
refreshTheme();
}, [user]); // Re-run when user logs in/out
}, [user]);
return (
<ThemeContext.Provider value={{ currentPalette, refreshTheme, setTheme, isLoading }}>
+67 -43
View File
@@ -3,22 +3,25 @@ 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';
export const languages = {
en: { name: 'English', dir: 'ltr' },
@@ -27,38 +30,67 @@ export const languages = {
export type SupportedLanguage = keyof typeof languages;
const resources = {
en: {
common: enCommon,
users: enUsers,
roles: enRoles,
profile: enProfile,
dashboard: enDashboard,
modules: enModules,
logs: enLogs,
theme: enTheme,
},
ar: {
common: arCommon,
users: arUsers,
roles: arRoles,
profile: arProfile,
dashboard: arDashboard,
modules: arModules,
logs: arLogs,
theme: arTheme,
},
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,
profile: enProfile,
dashboard: enDashboard,
modules: enModules,
logs: enLogs,
operations: enOperations,
theme: enTheme,
security: enSecurity,
notifications: enNotifications,
apikeys: enApiKeys,
webhooks: enWebhooks,
invitations: enInvitations,
sso: enSso,
organisation: enOrganisation,
email: enEmail,
reference: enReference,
documents: enDocuments,
};
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) =>
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 {
}
};
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),
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
@@ -74,16 +106,8 @@ i18next
},
});
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);
if (detected !== 'en') {
void loadLanguage(detected);
}
export default i18next;
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": "تعذّر تحميل المفاتيح الآن."
}
}

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