1 Commits
200 changed files with 2168 additions and 20064 deletions
-2
View File
@@ -1,2 +0,0 @@
# Development environment
VITE_API_BASE_URL=https://saas-dev.maskantech.in
-2
View File
@@ -1,2 +0,0 @@
# Local environment
VITE_API_BASE_URL=http://localhost:8000
-2
View File
@@ -1,2 +0,0 @@
# Production environment
VITE_API_BASE_URL=https://api.yourdomain.com
-1
View File
@@ -1 +0,0 @@
VITE_API_BASE_URL=https://saas-test.maskantech.in
+2
View File
@@ -1,4 +1,6 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-255
View File
@@ -1,255 +0,0 @@
# 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,8 +19,5 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
])
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="https://maskantech.netlify.app/maskan_fav.png" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SaaS</title>
<title>saas_frontend</title>
</head>
<body>
<div id="root"></div>
+8 -1581
View File
File diff suppressed because it is too large Load Diff
+3 -20
View File
@@ -4,23 +4,13 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"local": "vite --mode localhost",
"dev": "vite --mode development",
"test": "vite --mode test",
"prod": "vite --mode production",
"dev": "vite",
"build": "tsc -b && vite build",
"build:dev": "tsc -b && vite build --mode development",
"build:test": "tsc -b && vite build --mode test",
"build:prod": "tsc -b && vite build --mode production",
"lint": "eslint .",
"preview": "vite preview",
"test:unit": "vitest run",
"test:watch": "vitest",
"check:size": "node scripts/check-bundle-size.mjs"
"preview": "vite preview"
},
"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",
@@ -28,21 +18,16 @@
"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",
"react-i18next": "^16.5.3",
"react-phone-number-input": "^3.4.14",
"react-router-dom": "^7.12.0",
"react-toastify": "^11.0.5",
"tailwindcss": "^4.1.18"
},
"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",
@@ -51,10 +36,8 @@
"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",
"vitest": "^3.2.7"
"vite": "^7.2.4"
}
}
-72
View File
@@ -1,72 +0,0 @@
#!/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).`);
+6 -19
View File
@@ -1,30 +1,17 @@
import { BrowserRouter } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./toast.css";
import { AuthProvider } from "./context/AuthContext";
import { ThemeProvider } from "./context/ThemeContext";
import AppRoutes from "./routes";
const App = () => {
return (
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<AuthProvider>
<ThemeProvider>
<BrowserRouter>
<AppRoutes />
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
pauseOnHover
draggable
closeButton={true}
/>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</BrowserRouter>
</ThemeProvider>
</AuthProvider>
);
};
@@ -1,6 +1,5 @@
import { apiClient } from "../../lib/apiClient";
import type { AuthUser, SigninRequest, SignupRequest, TokenResponse } from "./AuthTypes";
import type { UserSession } from "../profile/ProfileTypes";
type AuthApiOptions = {
tenantId?: string;
@@ -32,7 +31,7 @@ export const authApi = {
payload,
{ ...withTenantHeader(options?.tenantId), successMessage: "Account created", errorMessage: "Failed to create account" }
),
logout: () => apiClient.post<LogoutResponse>("/api/auth/logout", null, { successMessage: "Signed out", errorMessage: "Failed to sign out" }),
logout: () => apiClient.post<LogoutResponse>("/api/auth/logout", { successMessage: "Signed out", errorMessage: "Failed to sign out" }),
me: () => apiClient.get<AuthUser>("/api/auth/me"),
resetPassword: (oldPassword: string, newPassword: string) =>
apiClient.post<{ message: string }>("/api/auth/reset-password", {
@@ -70,24 +69,8 @@ export const authApi = {
silent: true,
}),
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",
refresh: (refreshToken: string) =>
apiClient.post<TokenResponse>("/api/auth/refresh", {
refresh_token: refreshToken,
}),
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",
}
),
};
};
@@ -6,30 +6,11 @@ export type Access = {
};
export type Role = {
id?: string | null;
role_name?: string | null;
id: string;
role_name: string;
accesses: string[];
};
export type SubscriptionDetails = {
plan_id?: string | null;
plan_name?: string | null;
start_date?: string | null;
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 = {
id: string;
email: string;
@@ -37,11 +18,9 @@ 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;
subscription_details?: SubscriptionDetails | null;
preferred_language?: string | null;
created_at: string;
updated_at: string;
@@ -51,8 +30,6 @@ export type AuthUser = {
export type SigninRequest = {
email: string;
password: string;
remember_me?: boolean;
mfa_code?: string;
};
export type SignupRequest = {
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Mail, Lock, } from "lucide-react";
import { CustomInput, CustomButton, CustomOTPInput, CustomBackButton } from "../../../components/custom";
import { CustomInput, CustomButton, CustomOTPInput, CustomBackButton } from "../../../components/Custom";
import { authApi } from "../AuthApi";
type Step = "email" | "otp" | "password";
@@ -2,10 +2,8 @@ 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();
@@ -15,10 +13,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();
const { login } = useAuth(); // Retrieve login function from context
const handleSignIn = async (event: React.FormEvent) => {
event.preventDefault();
@@ -28,59 +24,20 @@ export default function SignInForm() {
const payload: SigninRequest = {
email,
password,
...(mfaCode ? { mfa_code: mfaCode } : {}),
};
try {
// Use the context login function to ensure state is updated
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;
}
}
// Redirect to the dashboard after successful signin.
navigate("/dashboard");
} catch (error) {
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);
}
const message =
error instanceof Error
? error.message
: "Unable to sign in. Please try again.";
setErrorMessage(message);
} finally {
setIsLoading(false);
}
@@ -100,6 +57,45 @@ export default function SignInForm() {
</p>
</div>
<div>
<div className="flex flex-col gap-4">
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-black transition-colors bg-white rounded-lg px-7 hover:bg-gray-50 hover:text-gray-900 w-full">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z"
fill="#4285F4"
/>
<path
d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z"
fill="#34A853"
/>
<path
d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z"
fill="#FBBC05"
/>
<path
d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z"
fill="#EB4335"
/>
</svg>
Sign in with Google
</button>
</div>
<div className="relative py-3 sm:py-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="p-2 text-gray-400 bg-slate-900 sm:px-5 sm:py-2">
Or
</span>
</div>
</div>
<form onSubmit={handleSignIn}>
<div className="space-y-6">
<CustomInput
@@ -122,21 +118,6 @@ 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}
@@ -170,6 +151,17 @@ 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>
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/custom";
import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/Custom";
import type { SignupRequest } from "../AuthTypes";
import { authApi } from "../AuthApi";
@@ -21,6 +21,7 @@ export default function SignUpForm() {
setIsLoading(true);
if (!isChecked) {
// Require terms acceptance before sending the signup request.
setErrorMessage("Please accept the terms and conditions to continue.");
setIsLoading(false);
return;
@@ -34,8 +35,10 @@ export default function SignUpForm() {
};
try {
// Call the backend signup API.
await authApi.signup(payload);
// Signup does not return tokens, so take the user to signin.
navigate("/signin");
} catch (error) {
const message =
-17
View File
@@ -1,17 +0,0 @@
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
@@ -1,30 +0,0 @@
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;
};
@@ -1,137 +0,0 @@
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
@@ -1,295 +0,0 @@
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;
@@ -1,139 +0,0 @@
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();
});
});
+14 -210
View File
@@ -1,224 +1,28 @@
import { useState, useEffect, useMemo } from '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 { Construction } from 'lucide-react';
import { useTranslation } from 'react-i18next';
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[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const fetchModules = async () => {
try {
const fetchedModules = await moduleApi.getAvailableModules();
setModules(fetchedModules);
} catch (error) {
console.error('Failed to fetch modules', error);
} finally {
setLoading(false);
}
};
fetchModules();
}, []);
const handleModuleClick = async (moduleId: string) => {
if (isSuperAdmin) {
navigate(`/admin/modules?edit=${encodeURIComponent(moduleId)}`);
return;
}
try {
const response = await moduleApi.launchModule(moduleId);
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 {
const url = new URL(response.redirect_url);
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new Error("Unsafe protocol");
}
window.location.href = response.redirect_url;
} catch {
console.error("Invalid redirect URL from SSO");
alert("Failed to launch module. Please try again.");
}
}
} catch (error) {
alert("Failed to launch module. Please try again.");
}
};
const groupedModules = useMemo(() => {
const filtered = modules.filter(m =>
m.module_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(m.description && m.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
const groups: Record<string, Module[]> = {};
if (filtered.length === 0) return {};
filtered.forEach(module => {
const category = module.category || t('dashboard:allModules');
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(module);
});
return Object.keys(groups).sort().reduce(
(obj, key) => {
obj[key] = groups[key];
return obj;
},
{} as Record<string, Module[]>
);
}, [modules, searchTerm, t]);
const { t } = useTranslation('dashboard');
return (
<div className="space-y-6 min-h-screen">
<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)">
{t('dashboard:title')}, {user?.first_name || 'Admin'}! 👋
</h1>
<p className="text-sm text-(--text-secondary)">
{t('dashboard:subtitle')}
</p>
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
<div className="w-full md:w-80">
<CustomInput
placeholder={t('dashboard:searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leftIcon={<Search size={18} />}
/>
<div className="min-h-[80vh] flex flex-col items-center justify-center p-4 text-center animate-fade-in">
<div className="bg-[var(--card-bg)] p-8 rounded-2xl border border-[var(--card-border)] shadow-sm max-w-lg w-full flex flex-col items-center">
<div className="bg-orange-50 p-4 rounded-full mb-6">
<Construction className="w-12 h-12 text-orange-500" />
</div>
</div>
</div>
<h1 className="text-3xl font-bold text-[var(--text-primary)] mb-3">
{t('title')}
</h1>
<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">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<div key={i} className="bg-(--card-bg) border border-(--card-border) rounded-2xl p-6 h-48 animate-pulse shadow-sm flex flex-col">
<div className="h-12 w-12 bg-gray-200 rounded-xl mb-4"></div>
<div className="h-6 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-auto"></div>
<div className="h-4 bg-gray-200 rounded w-1/4 mt-4"></div>
</div>
))}
</div>
) : Object.keys(groupedModules).length > 0 ? (
Object.entries(groupedModules).map(([category, categoryModules]) => (
<section key={category} className="animate-fade-in">
<h2 className="text-xl font-bold text-(--text-primary) mb-6 flex items-center gap-2">
<span className="w-1.5 h-6 bg-blue-500 rounded-full inline-block"></span>
{category}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{categoryModules.map((module) => (
<div
key={module.module_id}
className="group bg-white border border-gray-100 rounded-2xl p-6 hover:shadow-xl hover:border-blue-100 transition-all duration-300 relative overflow-hidden flex flex-col h-full"
>
<div className="absolute top-0 right-0 p-6 opacity-0 group-hover:opacity-100 transition-opacity transform translate-x-2 group-hover:translate-x-0 duration-300">
<ExternalLink className="text-blue-500" size={20} />
</div>
<p className="text-[var(--text-secondary)] text-lg mb-8 leading-relaxed">
{t('message')}
</p>
<div className="flex-1">
<div className="mb-6 relative">
<div className="absolute inset-0 bg-blue-500/5 rounded-xl blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
{module.icon_url ? (
<img
src={module.icon_url}
alt={module.module_name}
className="w-14 h-14 rounded-xl object-contain relative z-10 bg-white p-1 shadow-sm border border-gray-50"
/>
) : (
<div className="w-14 h-14 rounded-xl bg-linear-to-br from-blue-50 to-indigo-50 border border-blue-100 flex items-center justify-center relative z-10 text-blue-600 shadow-sm">
<Box className="w-7 h-7" />
</div>
)}
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors">
{module.module_name}
</h3>
<p className="text-sm text-gray-500 leading-relaxed line-clamp-2 mb-4">
{module.description || "Access powerful tools designed for efficient management and scalability."}
</p>
</div>
<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 cursor-pointer"
>
{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>
))}
</div>
</section>
))
) : (
<div className="flex flex-col items-center justify-center p-12 bg-white border border-gray-100 rounded-2xl text-center shadow-sm">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-4">
<Box className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-1">{t('common:common.noData')}</h3>
<p className="text-gray-500 max-w-sm mx-auto">
{searchTerm ? t('common:common.noData') : "Your dashboard is currently empty. Contact your administrator to assign modules."}
</p>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="mt-4 text-blue-600 font-medium hover:underline"
>
{t('common:actions.reset')}
</button>
)}
</div>
)}
</div>
</div>
);
};
export default Dashboard;
export default Dashboard;
@@ -1,39 +0,0 @@
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;
@@ -1,168 +0,0 @@
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();
});
});
@@ -1,259 +0,0 @@
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
@@ -1,324 +0,0 @@
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;
@@ -1,115 +0,0 @@
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();
});
});
@@ -1,192 +0,0 @@
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;
@@ -1,48 +0,0 @@
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 }
),
};
@@ -1,26 +0,0 @@
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;
};
@@ -1,333 +0,0 @@
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;
-312
View File
@@ -1,312 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CustomColumnFilter, CustomButton } from "../../components/custom";
import { DataTable } from "../../components/custom/CustomTable";
import type { ColumnDef } from "../../components/custom/CustomTable";
import {
buildColumnFilterOptions,
resolveColumnSortState,
} from "../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../components/custom/CustomColumnFilter";
import { useDebounce } from "../../components/hooks/useDebounce";
import { apiClient } from "../../lib/apiClient";
import { buildQueryString } from "../../lib/queryParams";
import {
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
resolveStoredTablePageSize,
} from "../../lib/tablePageSize";
import { RefreshCcw } from "lucide-react";
interface AuditLog extends Record<string, unknown> {
id: string;
module_name: string;
action_type: string;
entity_name: string;
performed_by_email: string;
ip_address: string;
description: string;
created_at: string;
}
interface AuditLogListResponse {
items: AuditLog[];
total: number;
limit: number;
offset: number;
}
const LogsPage = () => {
const { t, i18n } = useTranslation(["logs", "common"]);
const [logs, setLogs] = useState<AuditLog[]>([]);
const [allLogsForCounts, setAllLogsForCounts] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() =>
resolveStoredTablePageSize({
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
defaultPageSize: 10,
})
);
const [search, setSearch] = useState("");
const [moduleFilter, setModuleFilter] = useState<string[]>([]);
const [actionFilter, setActionFilter] = useState<string[]>([]);
const [emailFilter, setEmailFilter] = useState<string[]>([]);
const [totalRows, setTotalRows] = useState(0);
const [activeSort, setActiveSort] = useState<{
column: "module_name" | "action_type" | "performed_by_email" | "created_at" | null;
direction: ColumnSortDirection;
}>({ column: "created_at", direction: "desc" });
const debouncedSearch = useDebounce(search, 500);
const searchInputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const latestLogsRequestRef = useRef(0);
const fetchLogs = useCallback(async () => {
const requestId = ++latestLogsRequestRef.current;
try {
setIsLoading(true);
const queryString = buildQueryString({
limit: pageSize,
offset: (page - 1) * pageSize,
search: debouncedSearch || undefined,
module_names: moduleFilter,
action_types: actionFilter,
performed_by_emails: emailFilter,
sort_by: activeSort.column ?? undefined,
sort_order: activeSort.direction ?? undefined,
});
const response = await apiClient.get<AuditLogListResponse>(
`/api/admin/audit-logs/${queryString}`,
{ silent: true }
);
if (requestId === latestLogsRequestRef.current) {
setLogs(response.items);
setTotalRows(response.total);
}
} catch (error) {
console.error(t("messages.loadError"), error);
} finally {
if (requestId === latestLogsRequestRef.current) {
setIsLoading(false);
}
}
}, [actionFilter, activeSort, debouncedSearch, emailFilter, page, pageSize, moduleFilter, t]);
const fetchFilterOptions = useCallback(async () => {
try {
const response = await apiClient.get<AuditLogListResponse>(
"/api/admin/audit-logs/?limit=500&offset=0",
{ silent: true }
);
setAllLogsForCounts(response.items);
} catch (error) {
console.error(t("messages.loadError"), error);
}
}, [t]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
fetchFilterOptions();
}, [fetchFilterOptions]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, moduleFilter, actionFilter, emailFilter, activeSort]);
useEffect(() => {
if (prevLoadingRef.current && !isLoading && search.trim()) {
searchInputRef.current?.focus({ preventScroll: true });
}
prevLoadingRef.current = isLoading;
}, [isLoading, search]);
const moduleCounts = useMemo(() => {
const counts = new Map<string, number>();
allLogsForCounts.forEach((log) => {
counts.set(log.module_name, (counts.get(log.module_name) ?? 0) + 1);
});
return counts;
}, [allLogsForCounts]);
const actionCounts = useMemo(() => {
const counts = new Map<string, number>();
allLogsForCounts.forEach((log) => {
counts.set(log.action_type, (counts.get(log.action_type) ?? 0) + 1);
});
return counts;
}, [allLogsForCounts]);
const emailCounts = useMemo(() => {
const counts = new Map<string, number>();
allLogsForCounts.forEach((log) => {
counts.set(log.performed_by_email, (counts.get(log.performed_by_email) ?? 0) + 1);
});
return counts;
}, [allLogsForCounts]);
const columns: ColumnDef<AuditLog>[] = useMemo(
() => [
{
key: "module_name",
visibilityLabel: t("columns.module"),
header: (
<div className="flex items-center">
{t("columns.module")}
<CustomColumnFilter
title={t("columns.module")}
options={buildColumnFilterOptions(moduleCounts.entries())}
selectedValues={moduleFilter}
sortDirection={activeSort.column === "module_name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setModuleFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "module_name", direction));
}}
/>
</div>
),
searchable: false,
},
{
key: "action_type",
visibilityLabel: t("columns.action"),
header: (
<div className="flex items-center">
{t("columns.action")}
<CustomColumnFilter
title={t("columns.action")}
options={buildColumnFilterOptions(actionCounts.entries())}
selectedValues={actionFilter}
sortDirection={activeSort.column === "action_type" ? activeSort.direction : null}
enableSearch={false}
onApply={(values, direction) => {
setPage(1);
setActionFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "action_type", direction));
}}
/>
</div>
),
searchable: false,
render: (row) => (
<span
className={`rounded px-2 py-1 text-xs font-bold ${
row.action_type === "CREATE"
? "bg-green-100 text-green-700"
: row.action_type === "UPDATE"
? "bg-blue-100 text-blue-700"
: row.action_type === "DELETE"
? "bg-red-100 text-red-700"
: "bg-gray-100"
}`}
>
{row.action_type}
</span>
),
},
{ key: "description", header: t("columns.description") },
{
key: "performed_by_email",
visibilityLabel: t("columns.performedBy"),
header: (
<div className="flex items-center">
{t("columns.performedBy")}
<CustomColumnFilter
title={t("columns.performedBy")}
options={buildColumnFilterOptions(emailCounts.entries())}
selectedValues={emailFilter}
sortDirection={activeSort.column === "performed_by_email" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setEmailFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "performed_by_email", direction));
}}
/>
</div>
),
searchable: false,
},
{ key: "ip_address", header: t("columns.ipAddress") },
{
key: "created_at",
visibilityLabel: t("columns.timestamp"),
header: (
<div className="flex items-center">
{t("columns.timestamp")}
<CustomColumnFilter
title={t("columns.timestamp")}
options={[]}
selectedValues={[]}
sortDirection={activeSort.column === "created_at" ? activeSort.direction : null}
enableSearch={false}
enableSelectAll={false}
onApply={(_, direction) => {
setPage(1);
setActiveSort(resolveColumnSortState(activeSort, "created_at", direction));
}}
/>
</div>
),
searchable: false,
render: (row) =>
new Date(row.created_at).toLocaleString(
i18n.language === "ar" ? "ar-EG" : "en-GB"
),
},
],
[actionCounts, actionFilter, activeSort, emailCounts, emailFilter, i18n.language, moduleCounts, moduleFilter, t]
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-(--text-primary)">
{t("title")}
</h1>
<p className="text-sm text-[var(--text-secondary)]">Track and audit all system activities and user actions.</p>
</div>
<CustomButton
variant="outlined"
onClick={fetchLogs}
loading={isLoading}
leftIcon={<RefreshCcw size={16} />}
>
{t("common:actions.refresh")}
</CustomButton>
</div>
<DataTable<AuditLog>
data={logs}
columns={columns}
columnVisibilityEnabled
columnVisibilityStorageKey="logs-table-columns"
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
isLoading={isLoading}
exportEnabled={false}
exportFileName={t("exportFileName")}
manualPagination
manualFiltering
totalRows={totalRows}
page={page}
pageSize={pageSize}
search={search}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
/>
</div>
);
};
export default LogsPage;
@@ -1,63 +0,0 @@
import { apiClient } from '../../../lib/apiClient';
import type {
Module,
ModuleCreate,
ModuleUpdate,
ModuleEnvironment,
EnvironmentCreate,
EnvironmentUpdate,
ModulePermission,
TenantModule,
TenantModuleCreate,
TenantModuleUpdate
} from './AdminModuleTypes';
export const adminModuleApi = {
listModules: (): Promise<Module[]> =>
apiClient.get<Module[]>('/api/admin/modules/', { toast: false }),
createModule: (data: ModuleCreate): Promise<Module> =>
apiClient.post<Module>('/api/admin/modules/', data, { toast: false }),
getModule: (moduleId: string): Promise<Module> =>
apiClient.get<Module>(`/api/admin/modules/${moduleId}`, { toast: false }),
updateModule: (moduleId: string, data: ModuleUpdate): Promise<Module> =>
apiClient.put<Module>(`/api/admin/modules/${moduleId}`, data, { toast: false }),
deleteModule: (moduleId: string): Promise<void> =>
apiClient.delete<void>(`/api/admin/modules/${moduleId}`, { toast: false }),
listEnvironments: (moduleId: string): Promise<ModuleEnvironment[]> =>
apiClient.get<ModuleEnvironment[]>(`/api/admin/modules/${moduleId}/environments`, { toast: false }),
createEnvironment: (moduleId: string, data: EnvironmentCreate): Promise<ModuleEnvironment> =>
apiClient.post<ModuleEnvironment>(`/api/admin/modules/${moduleId}/environments`, data, { toast: false }),
updateEnvironment: (moduleId: string, envId: string, data: EnvironmentUpdate): Promise<ModuleEnvironment> =>
apiClient.put<ModuleEnvironment>(`/api/admin/modules/${moduleId}/environments/${envId}`, data, { toast: false }),
setDefaultEnvironment: (moduleId: string, envId: string): Promise<void> =>
apiClient.patch<void>(`/api/admin/modules/${moduleId}/environments/${envId}/default`, undefined, { toast: false }),
deleteEnvironment: (moduleId: string, envId: string): Promise<void> =>
apiClient.delete<void>(`/api/admin/modules/${moduleId}/environments/${envId}`, { toast: false }),
getModulePermissions: (moduleId: string): Promise<ModulePermission[]> =>
apiClient.get<ModulePermission[]>(`/api/admin/modules/${moduleId}/permissions`, { toast: false }),
syncModulePermissions: (moduleId: string): Promise<{ message: string; synced_count: number }> =>
apiClient.post<{ message: string; synced_count: number }>(`/api/admin/modules/${moduleId}/permissions/sync`, undefined, { toast: false }),
listTenantModules: (tenantId: string): Promise<TenantModule[]> =>
apiClient.get<TenantModule[]>(`/api/admin/tenants/${tenantId}/modules`, { toast: false }),
assignModuleToTenant: (tenantId: string, data: TenantModuleCreate): Promise<TenantModule> =>
apiClient.post<TenantModule>(`/api/admin/tenants/${tenantId}/modules`, data, { toast: false }),
updateTenantModule: (tenantId: string, tenantModuleId: string, data: TenantModuleUpdate): Promise<TenantModule> =>
apiClient.put<TenantModule>(`/api/admin/tenants/${tenantId}/modules/${tenantModuleId}`, data, { toast: false }),
removeTenantModule: (tenantId: string, tenantModuleId: string): Promise<void> =>
apiClient.delete<void>(`/api/admin/tenants/${tenantId}/modules/${tenantModuleId}`, { toast: false }),
};
@@ -1,108 +0,0 @@
export type ModuleStatus = 'active' | 'inactive';
export type EnvironmentTrustType = 'internal' | 'full' | 'none';
export interface Module {
id: string;
module_id: string;
module_name: string;
description?: string;
icon_url?: string;
status: ModuleStatus;
display_order: number;
created_at: string;
updated_at?: string;
}
export interface ModuleCreate {
module_id: string;
module_name: string;
description?: string;
icon_url?: string;
status?: ModuleStatus;
display_order?: number;
}
export interface ModuleUpdate {
module_name?: string;
description?: string;
icon_url?: string;
status?: ModuleStatus;
display_order?: number;
}
export interface ModuleEnvironment {
id: string;
module_id: string;
slug: string;
frontend_base_url: string;
backend_base_url: string;
sso_entry_path: string;
permission_sync_endpoint: string;
provisioning_endpoint: string;
trust_type: EnvironmentTrustType;
is_default: boolean;
is_active: boolean;
created_at: string;
updated_at?: string;
}
export interface EnvironmentCreate {
slug: string;
frontend_base_url: string;
backend_base_url: string;
sso_entry_path?: string;
permission_sync_endpoint?: string;
provisioning_endpoint?: string;
trust_type?: EnvironmentTrustType;
trust_credentials?: Record<string, any>;
is_default?: boolean;
is_active?: boolean;
}
export interface EnvironmentUpdate {
slug?: string;
frontend_base_url?: string;
backend_base_url?: string;
sso_entry_path?: string;
permission_sync_endpoint?: string;
provisioning_endpoint?: string;
trust_type?: EnvironmentTrustType;
trust_credentials?: Record<string, any>;
is_default?: boolean;
is_active?: boolean;
}
export interface ModulePermission {
id: string;
access_code: string;
name: string;
category: string;
parent_id?: string;
scope: string;
module_id: string;
}
export interface TenantModule {
id: string;
tenant_id: string;
module_id: string;
module_name: string;
module_icon_url?: string;
assigned_environment_slug: string;
is_active: boolean;
module_config?: Record<string, any>;
created_at: string;
}
export interface TenantModuleCreate {
module_id: string;
assigned_environment_slug?: string;
is_active?: boolean;
module_config?: Record<string, any>;
}
export interface TenantModuleUpdate {
assigned_environment_slug?: string;
is_active?: boolean;
module_config?: Record<string, any>;
}
@@ -1,263 +0,0 @@
import { useState, useEffect } from 'react';
import { adminModuleApi } from '../AdminModuleApi';
import type { ModuleEnvironment, EnvironmentCreate, EnvironmentUpdate } from '../AdminModuleTypes';
import CustomModal from '../../../../components/custom/CustomModal';
interface EnvironmentFormProps {
moduleId: string;
environment: ModuleEnvironment | null;
onClose: (saved: boolean) => void;
}
const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProps) => {
const [formData, setFormData] = useState({
slug: '',
frontend_base_url: '',
backend_base_url: '',
sso_entry_path: '/sso/callback',
permission_sync_endpoint: '/internal/permissions',
provisioning_endpoint: '/internal/tenants/provision',
trust_type: 'hmac',
hmac_secret: '',
is_default: false,
is_active: true,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (environment) {
setFormData({
slug: environment.slug,
frontend_base_url: environment.frontend_base_url,
backend_base_url: environment.backend_base_url,
sso_entry_path: environment.sso_entry_path,
permission_sync_endpoint: environment.permission_sync_endpoint,
provisioning_endpoint: environment.provisioning_endpoint,
trust_type: environment.trust_type,
hmac_secret: '',
is_default: environment.is_default,
is_active: environment.is_active,
});
}
}, [environment]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
const trust_credentials = formData.trust_type === 'hmac'
? { hmac_secret: formData.hmac_secret }
: { secret_key: formData.hmac_secret };
try {
if (environment) {
const updateData: EnvironmentUpdate = {
slug: formData.slug,
frontend_base_url: formData.frontend_base_url,
backend_base_url: formData.backend_base_url,
sso_entry_path: formData.sso_entry_path,
permission_sync_endpoint: formData.permission_sync_endpoint,
provisioning_endpoint: formData.provisioning_endpoint,
trust_type: formData.trust_type as any,
...(formData.hmac_secret && { trust_credentials }),
is_default: formData.is_default,
is_active: formData.is_active,
};
await adminModuleApi.updateEnvironment(moduleId, environment.id, updateData);
} else {
const createData: EnvironmentCreate = {
slug: formData.slug,
frontend_base_url: formData.frontend_base_url,
backend_base_url: formData.backend_base_url,
sso_entry_path: formData.sso_entry_path,
permission_sync_endpoint: formData.permission_sync_endpoint,
provisioning_endpoint: formData.provisioning_endpoint,
trust_type: formData.trust_type as any,
trust_credentials,
is_default: formData.is_default,
is_active: formData.is_active,
};
await adminModuleApi.createEnvironment(moduleId, createData);
}
onClose(true);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to save environment');
} finally {
setLoading(false);
}
};
return (
<CustomModal
isOpen={true}
onClose={() => onClose(false)}
title={environment ? "Edit Environment" : "Create Environment"}
size="lg"
footer={
<div className="flex gap-3 w-full">
<button
type="submit"
form="env-form"
disabled={loading}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? "Saving..." : environment ? "Update" : "Create"}
</button>
<button
type="button"
onClick={() => onClose(false)}
className="flex-1 px-4 py-2 bg-[#334155] text-white rounded-lg hover:bg-[#1e293b] transition-colors"
>
Cancel
</button>
</div>
}
>
<form id="env-form" onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Environment Slug *
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="prod, staging, eu-prod"
required
disabled={!!environment}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Trust Type
</label>
<input
type="text"
value="HMAC-SHA256"
disabled
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-secondary) rounded-lg opacity-60 cursor-not-allowed"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Frontend Base URL *
</label>
<input
type="url"
value={formData.frontend_base_url}
onChange={(e) => setFormData({ ...formData, frontend_base_url: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://module.example.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Backend Base URL *
</label>
<input
type="url"
value={formData.backend_base_url}
onChange={(e) => setFormData({ ...formData, backend_base_url: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://api.module.example.com"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
SSO Entry Path *
</label>
<input
type="text"
value={formData.sso_entry_path}
onChange={(e) => setFormData({ ...formData, sso_entry_path: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Permission Sync Endpoint *
</label>
<input
type="text"
value={formData.permission_sync_endpoint}
onChange={(e) => setFormData({ ...formData, permission_sync_endpoint: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Provisioning Endpoint *
</label>
<input
type="text"
value={formData.provisioning_endpoint}
onChange={(e) => setFormData({ ...formData, provisioning_endpoint: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
HMAC Secret {!environment && "*"}
</label>
<input
type="password"
value={formData.hmac_secret}
onChange={(e) => setFormData({ ...formData, hmac_secret: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={environment ? "Leave blank to keep existing" : "Enter secret"}
required={!environment}
/>
<p className="text-xs text-(--text-secondary) mt-1">
Stored securely on backend
</p>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_default}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="rounded border-(--card-border) bg-(--background)"
/>
<span className="text-sm text-(--text-primary)">Set as default</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="rounded border-(--card-border) bg-(--background)"
/>
<span className="text-sm text-(--text-primary)">Active</span>
</label>
</div>
</form>
</CustomModal>
);
};
export default EnvironmentForm;
@@ -1,207 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Plus, Edit, Trash2, Check, X } from 'lucide-react';
import { adminModuleApi } from '../AdminModuleApi';
import type { ModuleEnvironment, Module } from '../AdminModuleTypes';
import Loader from '../../../../components/custom/CustomLoader';
import EnvironmentForm from './EnvironmentForm';
import { CustomConfirmationModal } from '../../../../components/custom';
const ModuleEnvironments = () => {
const { moduleId } = useParams<{ moduleId: string }>();
const navigate = useNavigate();
const [module, setModule] = useState<Module | null>(null);
const [environments, setEnvironments] = useState<ModuleEnvironment[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [selectedEnv, setSelectedEnv] = useState<ModuleEnvironment | null>(null);
const [envToDelete, setEnvToDelete] = useState<ModuleEnvironment | null>(null);
const fetchData = async () => {
if (!moduleId) return;
try {
const [moduleData, envsData] = await Promise.all([
adminModuleApi.getModule(moduleId),
adminModuleApi.listEnvironments(moduleId)
]);
setModule(moduleData);
setEnvironments(envsData);
} catch (error) {
console.error('Failed to fetch data', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [moduleId]);
const handleSetDefault = async (envId: string) => {
if (!moduleId) return;
try {
await adminModuleApi.setDefaultEnvironment(moduleId, envId);
fetchData();
} catch (error) {
console.error('Failed to set default', error);
}
};
const handleDelete = async (env: ModuleEnvironment) => {
setEnvToDelete(env);
};
const confirmDelete = async () => {
if (!moduleId || !envToDelete) return;
try {
await adminModuleApi.deleteEnvironment(moduleId, envToDelete.id);
setEnvToDelete(null);
fetchData();
} catch (error: any) {
console.error('Failed to delete environment', error);
}
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/modules')}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ArrowLeft size={20} />
</button>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-(--text-primary)">
{module?.module_name || 'Loading...'} - Environments
</h1>
<p className="text-sm text-(--text-secondary)">Configure environment-specific settings</p>
</div>
<div className="ml-auto">
<button
onClick={() => { setSelectedEnv(null); setShowForm(true); }}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add Environment
</button>
</div>
</div>
{loading ? (
<div className="p-12 flex flex-col items-center justify-center min-h-[300px]">
<Loader size="md" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{environments.map((env) => (
<div key={env.id} className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{env.slug}</h3>
{env.is_default && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded">
Default
</span>
)}
{env.is_active ? (
<Check className="w-5 h-5 text-green-600" />
) : (
<X className="w-5 h-5 text-gray-400" />
)}
</div>
</div>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="text-gray-500">Frontend:</span>
<p className="text-(--text-primary) font-mono text-xs break-all">{env.frontend_base_url}</p>
</div>
<div>
<span className="text-gray-500">Backend:</span>
<p className="text-(--text-primary) font-mono text-xs break-all">{env.backend_base_url}</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-gray-500">Trust:</span>
<p className="text-(--text-primary)">{env.trust_type}</p>
</div>
<div>
<span className="text-gray-500">SSO Path:</span>
<p className="text-(--text-primary) font-mono text-xs">{env.sso_entry_path}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 pt-2 border-t">
{!env.is_default && (
<button
onClick={() => handleSetDefault(env.id)}
className="flex-1 px-3 py-2 bg-green-50 text-green-600 rounded-lg hover:bg-green-100 text-sm"
>
Set Default
</button>
)}
<button
onClick={() => { setSelectedEnv(env); setShowForm(true); }}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 text-sm flex items-center justify-center gap-2"
>
<Edit size={16} />
Edit
</button>
<button
onClick={() => handleDelete(env)}
className="flex-1 px-3 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 text-sm flex items-center justify-center gap-2"
>
<Trash2 size={16} />
Delete
</button>
</div>
</div>
))}
</div>
)}
{environments.length === 0 && !loading && (
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
<p className="text-(--text-secondary) mb-4">No environments configured</p>
<button
onClick={() => { setSelectedEnv(null); setShowForm(true); }}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Add First Environment
</button>
</div>
)}
<CustomConfirmationModal
isOpen={!!envToDelete}
onClose={() => setEnvToDelete(null)}
onConfirm={confirmDelete}
title="Delete Environment"
description={`Are you sure you want to delete ${envToDelete?.slug} environment?`}
variant="danger"
/>
{showForm && moduleId && (
<EnvironmentForm
moduleId={moduleId}
environment={selectedEnv}
onClose={(saved) => {
setShowForm(false);
setSelectedEnv(null);
if (saved) fetchData();
}}
/>
)}
</div>
);
};
export default ModuleEnvironments;
@@ -1,180 +0,0 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { X } from 'lucide-react';
import { useModuleApi } from '../hooks/useModuleApi';
import type { Module, ModuleCreate, ModuleUpdate, ModuleStatus } from '../AdminModuleTypes';
import CustomInput from '../../../../components/custom/CustomInput';
import CustomButton from '../../../../components/custom/CustomButton';
interface ModuleFormProps {
module: Module | null;
onClose: (saved: boolean) => void;
}
interface ModuleFormData {
module_id: string;
module_name: string;
description: string;
icon_url: string;
status: ModuleStatus;
display_order: number;
}
const ModuleForm = ({ module, onClose }: ModuleFormProps) => {
const { createModule, updateModule, loading } = useModuleApi();
const { register, handleSubmit, formState: { errors }, reset } = useForm<ModuleFormData>({
defaultValues: {
module_id: '',
module_name: '',
description: '',
icon_url: '',
status: 'active',
display_order: 0,
}
});
useEffect(() => {
if (module) {
reset({
module_id: module.module_id,
module_name: module.module_name,
description: module.description || '',
icon_url: module.icon_url || '',
status: module.status,
display_order: module.display_order,
});
}
}, [module, reset]);
const onSubmit = async (data: ModuleFormData) => {
let success;
if (module) {
const updateData: ModuleUpdate = {
module_name: data.module_name,
description: data.description || undefined,
icon_url: data.icon_url || undefined,
status: data.status,
display_order: data.display_order,
};
success = await updateModule(module.id, updateData);
} else {
const createData: ModuleCreate = {
module_id: data.module_id,
module_name: data.module_name,
description: data.description || undefined,
icon_url: data.icon_url || undefined,
status: data.status,
display_order: data.display_order,
};
success = await createModule(createData);
}
if (success) {
onClose(true);
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-(--card-bg) rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-(--card-border)">
<div className="sticky top-0 bg-(--card-bg) border-b border-(--card-border) px-6 py-4 flex items-center justify-between z-10">
<h2 className="text-xl font-semibold text-(--text-primary)">
{module ? 'Edit Module' : 'Create Module'}
</h2>
<button
onClick={() => onClose(false)}
className="p-2 hover:bg-(--background) rounded-lg transition-colors text-(--text-secondary) hover:text-(--text-primary)"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-4">
<CustomInput
label="Module ID *"
placeholder="e.g., inventory"
{...register('module_id', {
required: 'Module ID is required',
pattern: {
value: /^[a-z0-9-]+$/,
message: 'Only lowercase alphanumeric and hyphens allowed'
}
})}
error={errors.module_id?.message}
disabled={!!module}
/>
<p className="text-xs text-(--text-secondary) -mt-3 ml-1">Unique identifier (lowercase, alphanumeric, hyphens)</p>
<CustomInput
label="Module Name *"
placeholder="e.g., Inventory Management"
{...register('module_name', { required: 'Module Name is required' })}
error={errors.module_name?.message}
/>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-(--text-primary)">
Description
</label>
<textarea
{...register('description')}
className="w-full px-3 py-2 border border-(--card-border) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-(--background) text-(--text-primary) placeholder-(--text-secondary)/50"
placeholder="Brief description of the module"
rows={3}
/>
</div>
<CustomInput
label="Icon URL"
placeholder="https://example.com/icon.png"
type="url"
{...register('icon_url')}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-(--text-primary)">
Status
</label>
<select
{...register('status')}
className="w-full px-3 py-2 border border-(--card-border) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-(--background) text-(--text-primary)"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<CustomInput
label="Display Order"
type="number"
{...register('display_order', { valueAsNumber: true })}
/>
</div>
<div className="flex items-center gap-3 pt-4">
<CustomButton
type="submit"
loading={loading}
className="flex-1"
>
{module ? 'Update Module' : 'Create Module'}
</CustomButton>
<CustomButton
type="button"
variant="secondary"
onClick={() => onClose(false)}
className="flex-1"
disabled={loading}
>
Cancel
</CustomButton>
</div>
</form>
</div>
</div>
);
};
export default ModuleForm;
@@ -1,231 +0,0 @@
import { useState, useEffect } from 'react';
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';
import type { Module } from '../AdminModuleTypes';
import { adminModuleApi } from '../AdminModuleApi';
import ModuleForm from './ModuleForm';
import CustomConfirmationModal from '../../../../components/custom/CustomConfirmationModal';
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[]>([]);
const [showForm, setShowForm] = useState(false);
const [selectedModule, setSelectedModule] = useState<Module | null>(null);
const [moduleToDelete, setModuleToDelete] = useState<Module | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const fetchModules = async () => {
const data = await listModules();
if (data) {
setModules(data);
}
};
useEffect(() => {
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);
};
const handleEdit = (module: Module) => {
setSelectedModule(module);
setShowForm(true);
};
const confirmDelete = (module: Module) => {
setModuleToDelete(module);
};
const handleDelete = async () => {
if (!moduleToDelete) return;
const success = await deleteModule(moduleToDelete.id);
if (success) {
fetchModules();
}
setModuleToDelete(null);
};
const handleFormClose = (saved: boolean) => {
setShowForm(false);
setSelectedModule(null);
if (searchParams.get('edit')) {
setSearchParams({}, { replace: true });
}
if (saved) {
fetchModules();
}
};
if (loading && modules.length === 0) {
return (
<div>
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-300 rounded w-1/4"></div>
<div className="h-64 bg-gray-300 rounded"></div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-(--text-primary)">{t('registry.title')}</h1>
<p className="text-sm text-(--text-secondary)">{t('registry.subtitle')}</p>
</div>
<CustomButton
onClick={handleCreate}
leftIcon={<Plus size={20} />}
>
{t('registry.create_button')}
</CustomButton>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{modules.map((module) => (
<div
key={module.id}
className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4 shadow-sm"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{module.icon_url ? (
<img src={module.icon_url} alt={module.module_name} className="w-12 h-12 rounded-lg" />
) : (
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
<Settings className="w-6 h-6 text-blue-600" />
</div>
)}
<div>
<h3 className="text-lg font-semibold text-(--text-primary)">{module.module_name}</h3>
<p className="text-sm text-(--text-secondary)">{module.module_id}</p>
</div>
</div>
<div
className={`flex items-center gap-1 transition-opacity ${togglingId === module.id
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:text-blue-600'
}`}
title={module.status === 'active' ? 'Deactivate' : 'Activate'}
onClick={async () => {
if (togglingId) return;
try {
setTogglingId(module.id);
const updated = await adminModuleApi.updateModule(module.id, {
status: module.status === 'active' ? 'inactive' : 'active'
});
if (updated) {
setModules(prev => prev.map(m =>
m.id === module.id ? { ...m, status: updated.status } : m
));
}
} catch (error) {
console.error("Toggle failed:", error);
} finally {
setTogglingId(null);
}
}}
>
{module.status === 'active' ? (
<Eye className="w-5 h-5 text-green-600" />
) : (
<EyeOff className="w-5 h-5 text-gray-600" />
)}
</div>
</div>
{module.description && (
<p className="text-sm text-(--text-secondary) line-clamp-2">{module.description}</p>
)}
<div className="flex items-center gap-2 pt-4 border-t border-(--card-border)">
<button
onClick={() => navigate(`/admin/modules/${module.id}/environments`)}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm text-(--text-primary)"
>
<Settings size={16} />
{t('registry.card.environments')}
</button>
<button
onClick={() => navigate(`/admin/modules/${module.id}/permissions`)}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm text-(--text-primary)"
>
<Shield size={16} />
{t('registry.card.permissions')}
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(module)}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg transition-colors text-sm"
>
<Edit size={16} />
{t('common:actions.edit')}
</button>
<button
onClick={() => confirmDelete(module)}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg transition-colors text-sm"
>
<Trash2 size={16} />
{t('common:actions.delete')}
</button>
</div>
</div>
))}
</div>
{modules.length === 0 && !loading && (
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
<Settings className="w-16 h-16 text-(--text-secondary) mx-auto mb-4" />
<h3 className="text-lg font-semibold text-(--text-primary) mb-2">{t('registry.empty_state.title')}</h3>
<p className="text-(--text-secondary) mb-4">{t('registry.empty_state.subtitle')}</p>
<CustomButton onClick={handleCreate}>
{t('registry.create_button')}
</CustomButton>
</div>
)}
{showForm && (
<ModuleForm
module={selectedModule}
onClose={handleFormClose}
/>
)}
<CustomConfirmationModal
isOpen={!!moduleToDelete}
onClose={() => setModuleToDelete(null)}
onConfirm={handleDelete}
title={t('delete_modal.title')}
description={t('delete_modal.description', { name: moduleToDelete?.module_name })}
confirmText={t('delete_modal.confirm')}
cancelText={t('delete_modal.cancel')}
variant="danger"
/>
</div>
);
};
export default ModuleList;
@@ -1,102 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, RefreshCw, Shield } from 'lucide-react';
import { useModuleApi } from '../hooks/useModuleApi';
import Loader from '../../../../components/custom/CustomLoader';
import type { ModulePermission, Module } from '../AdminModuleTypes';
import CustomButton from '../../../../components/custom/CustomButton';
import { NodeGroupAccessViewer } from '../../../roles/components/NodeGroupAccessViewer';
import type { RoleAccess } from '../../../roles/RolesTypes';
const ModulePermissions = () => {
const { moduleId } = useParams<{ moduleId: string }>();
const navigate = useNavigate();
const { getModule, getPermissions, syncPermissions, loading } = useModuleApi();
const [module, setModule] = useState<Module | null>(null);
const [permissions, setPermissions] = useState<ModulePermission[]>([]);
const [syncing, setSyncing] = useState(false);
const fetchData = async () => {
if (!moduleId) return;
const [moduleData, permsData] = await Promise.all([
getModule(moduleId),
getPermissions(moduleId)
]);
if (moduleData) setModule(moduleData);
if (permsData) setPermissions(permsData);
};
useEffect(() => {
fetchData();
}, [moduleId]);
const handleSync = async () => {
if (!moduleId) return;
setSyncing(true);
const result = await syncPermissions(moduleId);
if (result) {
fetchData();
}
setSyncing(false);
};
const viewerAccesses: RoleAccess[] = permissions.map(p => ({
id: p.id,
access_code: p.access_code,
name: p.name,
category: p.category || 'General',
parent_id: p.parent_id
}));
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/modules')}
className="p-2 hover:bg-(--background) rounded-lg transition-colors text-(--text-secondary) hover:text-(--text-primary)"
>
<ArrowLeft size={20} />
</button>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-(--text-primary)">
{module?.module_name || 'Loading...'} - Permissions
</h1>
<p className="text-sm text-(--text-secondary)">View and sync permissions from module</p>
</div>
<div className="ml-auto">
<CustomButton
onClick={handleSync}
loading={syncing}
leftIcon={!syncing && <RefreshCw size={20} />}
>
{syncing ? 'Syncing...' : 'Sync Permissions'}
</CustomButton>
</div>
</div>
{loading ? (
<div className=" p-12 flex flex-col items-center justify-center min-h-[300px]">
<Loader size="md" />
</div>
) : permissions.length > 0 ? (
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6">
<NodeGroupAccessViewer accesses={viewerAccesses} />
</div>
) : (
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
<Shield className="w-16 h-16 text-(--text-secondary) mx-auto mb-4" />
<h3 className="text-lg font-semibold text-(--text-primary) mb-2">No Permissions Synced</h3>
<p className="text-(--text-secondary) mb-4">Click "Sync Permissions" to fetch from the module</p>
</div>
)}
</div>
);
};
export default ModulePermissions;
@@ -1,208 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { adminModuleApi } from '../AdminModuleApi';
import type { TenantModule, Module } from '../AdminModuleTypes';
import { Plus, Trash2 } from 'lucide-react';
import Loader from '../../../../components/custom/CustomLoader';
const TenantModuleAssignment = () => {
const { tenantId } = useParams<{ tenantId: string }>();
const [assignedModules, setAssignedModules] = useState<TenantModule[]>([]);
const [availableModules, setAvailableModules] = useState<Module[]>([]);
const [loading, setLoading] = useState(true);
const [showAssignDialog, setShowAssignDialog] = useState(false);
const [selectedModule, setSelectedModule] = useState('');
const [selectedEnv, setSelectedEnv] = useState('prod');
const fetchData = async () => {
if (!tenantId) return;
try {
const [assigned, all] = await Promise.all([
adminModuleApi.listTenantModules(tenantId),
adminModuleApi.listModules()
]);
setAssignedModules(assigned);
const assignedIds = assigned.map(a => a.module_id);
setAvailableModules(all.filter(m => !assignedIds.includes(m.id)));
} catch (error) {
console.error('Failed to fetch data', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [tenantId]);
const handleAssign = async () => {
if (!tenantId || !selectedModule) return;
try {
await adminModuleApi.assignModuleToTenant(tenantId, {
module_id: selectedModule,
assigned_environment_slug: selectedEnv,
is_active: true
});
setShowAssignDialog(false);
setSelectedModule('');
fetchData();
} catch (error: any) {
alert(error.response?.data?.detail || 'Failed to assign module');
}
};
const handleToggleActive = async (tm: TenantModule) => {
if (!tenantId) return;
try {
await adminModuleApi.updateTenantModule(tenantId, tm.id, { is_active: !tm.is_active });
fetchData();
} catch (error) {
console.error('Failed to toggle', error);
}
};
const handleChangeEnv = async (tm: TenantModule, newSlug: string) => {
if (!tenantId) return;
try {
await adminModuleApi.updateTenantModule(tenantId, tm.id, { assigned_environment_slug: newSlug });
fetchData();
} catch (error) {
console.error('Failed to update', error);
}
};
const handleRemove = async (tm: TenantModule) => {
if (!tenantId || !confirm(`Remove ${tm.module_name}?`)) return;
try {
await adminModuleApi.removeTenantModule(tenantId, tm.id);
fetchData();
} catch (error) {
console.error('Failed to remove', error);
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-(--text-primary)">Tenant Modules</h1>
<p className="text-sm text-(--text-secondary)">Manage module assignments for this tenant</p>
</div>
<button
onClick={() => setShowAssignDialog(true)}
disabled={availableModules.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
<Plus size={20} />
Assign Module
</button>
</div>
{loading ? (
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 flex flex-col items-center justify-center min-h-[300px]">
<Loader size="lg" />
<p className="mt-4 text-(--text-secondary)">Loading assignments...</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{assignedModules.map((tm) => (
<div key={tm.id} className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{tm.module_icon_url && (
<img src={tm.module_icon_url} alt={tm.module_name} className="w-10 h-10 rounded" />
)}
<div>
<h3 className="font-semibold text-(--text-primary)">{tm.module_name}</h3>
<p className="text-xs text-(--text-secondary)">Environment: {tm.assigned_environment_slug}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<select
value={tm.assigned_environment_slug}
onChange={(e) => handleChangeEnv(tm, e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg text-sm"
>
<option value="prod">Production</option>
<option value="staging">Staging</option>
</select>
<button
onClick={() => handleToggleActive(tm)}
className={`px-3 py-2 rounded-lg text-sm ${tm.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}
>
{tm.is_active ? 'Active' : 'Inactive'}
</button>
<button
onClick={() => handleRemove(tm)}
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
{showAssignDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-semibold mb-4">Assign Module</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Module</label>
<select
value={selectedModule}
onChange={(e) => setSelectedModule(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="">Select Module</option>
{availableModules.map(m => (
<option key={m.id} value={m.id}>{m.module_name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Environment</label>
<select
value={selectedEnv}
onChange={(e) => setSelectedEnv(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="prod">Production</option>
<option value="staging">Staging</option>
</select>
</div>
<div className="flex gap-2">
<button
onClick={handleAssign}
disabled={!selectedModule}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Assign
</button>
<button
onClick={() => setShowAssignDialog(false)}
className="flex-1 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default TenantModuleAssignment;
@@ -1,156 +0,0 @@
import { useState, useCallback } from 'react';
import { toast } from 'react-toastify';
import { adminModuleApi } from '../AdminModuleApi';
import type {
Module,
ModuleCreate,
ModuleUpdate,
ModuleEnvironment,
EnvironmentCreate,
EnvironmentUpdate,
ModulePermission,
TenantModule,
TenantModuleCreate,
TenantModuleUpdate
} from '../AdminModuleTypes';
interface UseModuleApiResult {
loading: boolean;
listModules: () => Promise<Module[] | undefined>;
getModule: (id: string) => Promise<Module | undefined>;
createModule: (data: ModuleCreate) => Promise<Module | undefined>;
updateModule: (id: string, data: ModuleUpdate) => Promise<Module | undefined>;
deleteModule: (id: string) => Promise<boolean>;
listEnvironments: (moduleId: string) => Promise<ModuleEnvironment[] | undefined>;
createEnvironment: (moduleId: string, data: EnvironmentCreate) => Promise<ModuleEnvironment | undefined>;
updateEnvironment: (moduleId: string, envId: string, data: EnvironmentUpdate) => Promise<ModuleEnvironment | undefined>;
setDefaultEnvironment: (moduleId: string, envId: string) => Promise<boolean>;
deleteEnvironment: (moduleId: string, envId: string) => Promise<boolean>;
getPermissions: (moduleId: string) => Promise<ModulePermission[] | undefined>;
syncPermissions: (moduleId: string) => Promise<{ message: string; synced_count: number } | undefined>;
listTenantModules: (tenantId: string) => Promise<TenantModule[] | undefined>;
assignTenantModule: (tenantId: string, data: TenantModuleCreate) => Promise<TenantModule | undefined>;
updateTenantModule: (tenantId: string, assignmentId: string, data: TenantModuleUpdate) => Promise<TenantModule | undefined>;
removeTenantModule: (tenantId: string, assignmentId: string) => Promise<boolean>;
}
export const useModuleApi = (): UseModuleApiResult => {
const [loading, setLoading] = useState(false);
const handleError = (error: unknown, action: string) => {
console.error(`Failed to ${action}`, error);
const message = error instanceof Error ? error.message : `Failed to ${action}`;
toast.error(message);
};
const wrapRequest = useCallback(async <T>(
request: () => Promise<T>,
actionName: string,
successMessage?: string
): Promise<T | undefined> => {
setLoading(true);
try {
const result = await request();
if (successMessage) {
toast.success(successMessage);
}
return result;
} catch (error) {
handleError(error, actionName);
return undefined;
} finally {
setLoading(false);
}
}, []);
const listModules = useCallback(() =>
wrapRequest(() => adminModuleApi.listModules(), 'fetch modules'),
[]);
const getModule = useCallback((id: string) =>
wrapRequest(() => adminModuleApi.getModule(id), 'fetch module details'),
[]);
const createModule = useCallback((data: ModuleCreate) =>
wrapRequest(() => adminModuleApi.createModule(data), 'create module', 'Module created successfully'),
[]);
const updateModule = useCallback((id: string, data: ModuleUpdate) =>
wrapRequest(() => adminModuleApi.updateModule(id, data), 'update module'),
[]);
const deleteModule = useCallback(async (id: string) => {
const result = await wrapRequest(() => adminModuleApi.deleteModule(id), 'delete module', 'Module deleted successfully');
return result !== undefined;
}, []);
const listEnvironments = useCallback((moduleId: string) =>
wrapRequest(() => adminModuleApi.listEnvironments(moduleId), 'fetch environments'),
[]);
const createEnvironment = useCallback((moduleId: string, data: EnvironmentCreate) =>
wrapRequest(() => adminModuleApi.createEnvironment(moduleId, data), 'create environment', 'Environment created'),
[]);
const updateEnvironment = useCallback((moduleId: string, envId: string, data: EnvironmentUpdate) =>
wrapRequest(() => adminModuleApi.updateEnvironment(moduleId, envId, data), 'update environment', 'Environment updated'),
[]);
const setDefaultEnvironment = useCallback(async (moduleId: string, envId: string) => {
const result = await wrapRequest(() => adminModuleApi.setDefaultEnvironment(moduleId, envId), 'set default environment', 'Default environment updated');
return result !== undefined;
}, []);
const deleteEnvironment = useCallback(async (moduleId: string, envId: string) => {
const result = await wrapRequest(() => adminModuleApi.deleteEnvironment(moduleId, envId), 'delete environment', 'Environment deleted');
return result !== undefined;
}, []);
const getPermissions = useCallback((moduleId: string) =>
wrapRequest(() => adminModuleApi.getModulePermissions(moduleId), 'fetch permissions'),
[]);
const syncPermissions = useCallback((moduleId: string) =>
wrapRequest(() => adminModuleApi.syncModulePermissions(moduleId), 'sync permissions'),
[]);
const listTenantModules = useCallback((tenantId: string) =>
wrapRequest(() => adminModuleApi.listTenantModules(tenantId), 'fetch tenant modules'),
[]);
const assignTenantModule = useCallback((tenantId: string, data: TenantModuleCreate) =>
wrapRequest(() => adminModuleApi.assignModuleToTenant(tenantId, data), 'assign module', 'Module assigned to tenant'),
[]);
const updateTenantModule = useCallback((tenantId: string, assignmentId: string, data: TenantModuleUpdate) =>
wrapRequest(() => adminModuleApi.updateTenantModule(tenantId, assignmentId, data), 'update assignment', 'Assignment updated'),
[]);
const removeTenantModule = useCallback(async (tenantId: string, assignmentId: string) => {
const result = await wrapRequest(() => adminModuleApi.removeTenantModule(tenantId, assignmentId), 'remove assignment', 'Module unassigned from tenant');
return result !== undefined;
}, []);
return {
loading,
listModules,
getModule,
createModule,
updateModule,
deleteModule,
listEnvironments,
createEnvironment,
updateEnvironment,
setDefaultEnvironment,
deleteEnvironment,
getPermissions,
syncPermissions,
listTenantModules,
assignTenantModule,
updateTenantModule,
removeTenantModule
};
};
-16
View File
@@ -1,16 +0,0 @@
import { Route, Routes } from "react-router-dom";
import ModuleList from "./components/ModuleList";
import ModuleEnvironments from "./components/ModuleEnvironments";
import ModulePermissions from "./components/ModulePermissions";
const Modules: React.FC = () => {
return (
<Routes>
<Route index element={<ModuleList />} />
<Route path=":moduleId/environments" element={<ModuleEnvironments />} />
<Route path=":moduleId/permissions" element={<ModulePermissions />} />
</Routes>
);
};
export default Modules;
@@ -1,26 +0,0 @@
import { apiClient } from "../../../lib/apiClient";
export interface Module {
module_id: string;
module_name: string;
description: string | null;
icon_url: string | null;
display_order: number;
category: string | null;
is_active: boolean;
}
export interface LaunchResponse {
target_url: string;
payload: Record<string, any>;
headers: Record<string, string>;
redirect_url: string;
}
export const moduleApi = {
getAvailableModules: (): Promise<Module[]> =>
apiClient.get<Module[]>("/api/modules/available", { toast: false }),
launchModule: (moduleId: string): Promise<LaunchResponse> =>
apiClient.post<LaunchResponse>("/api/sso/initiate", { module_id: moduleId }, { toast: false }),
};
@@ -1,39 +0,0 @@
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 }
),
};
@@ -1,199 +0,0 @@
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();
});
});
@@ -1,271 +0,0 @@
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;
@@ -1,133 +0,0 @@
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();
});
});
@@ -1,143 +0,0 @@
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;
@@ -1,28 +0,0 @@
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;
};
@@ -1,33 +0,0 @@
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 }
),
};
@@ -1,238 +0,0 @@
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();
});
});
@@ -1,373 +0,0 @@
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;
@@ -1,54 +0,0 @@
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
@@ -1,70 +0,0 @@
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
@@ -1,24 +0,0 @@
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;
};
@@ -1,500 +0,0 @@
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;
@@ -1,195 +0,0 @@
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();
});
});
@@ -1,102 +0,0 @@
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;
@@ -1,46 +0,0 @@
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;
+27 -13
View File
@@ -7,9 +7,6 @@ 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 = {
@@ -66,6 +63,7 @@ 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);
@@ -120,6 +118,7 @@ const ProfilePage: React.FC = () => {
setPasswordError("");
setPasswordSuccess("");
// Client-side validation
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
setPasswordError(t('messages.requiredFields'));
return;
@@ -130,8 +129,8 @@ const ProfilePage: React.FC = () => {
return;
}
if (passwordForm.newPassword === passwordForm.confirmPassword) {
setPasswordError("New password must be different from your current password");
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setPasswordError(t('messages.passwordMismatch'));
return;
}
@@ -149,6 +148,7 @@ const ProfilePage: React.FC = () => {
confirmPassword: "",
});
// Close modal after 1.5 seconds
setTimeout(() => {
setIsPasswordModalOpen(false);
setPasswordSuccess("");
@@ -172,6 +172,7 @@ const ProfilePage: React.FC = () => {
setPasswordSuccess("");
};
// Edit Profile handlers
const handleOpenEditModal = () => {
if (user) {
setProfileForm({
@@ -211,10 +212,12 @@ 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("");
@@ -235,16 +238,20 @@ const ProfilePage: React.FC = () => {
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{t('title')}</h1>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
<p className="text-sm text-[var(--text-secondary)]">
{t('subTitle')}
</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}
@@ -275,11 +282,13 @@ 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')}
@@ -289,6 +298,7 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Last Name */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.lastName')}
@@ -298,6 +308,7 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Email */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.email')}
@@ -307,6 +318,7 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Phone Number */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.phone')}
@@ -316,6 +328,7 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Tenant */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.tenant')}
@@ -325,6 +338,7 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Role */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.role')}
@@ -334,6 +348,7 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Account Created */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.accountCreated')}
@@ -343,6 +358,7 @@ const ProfilePage: React.FC = () => {
</p>
</div>
{/* Last Updated */}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
{t('fields.lastUpdated')}
@@ -355,12 +371,7 @@ const ProfilePage: React.FC = () => {
</div>
</div>
<SecurityPanel />
<SessionsPanel />
<NotificationPreferencesPanel />
{/* Appearance / Theme Section */}
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6">
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
{t('sections.appearance')}
@@ -398,6 +409,7 @@ 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 }} />
@@ -417,6 +429,7 @@ const ProfilePage: React.FC = () => {
)}
</div>
{/* Change Password Modal */}
<CustomModal
isOpen={isPasswordModalOpen}
onClose={handlePasswordModalClose}
@@ -485,6 +498,7 @@ const ProfilePage: React.FC = () => {
</form>
</CustomModal>
{/* Edit Profile Modal */}
<CustomModal
isOpen={isEditModalOpen}
onClose={handleEditModalClose}
-10
View File
@@ -11,13 +11,3 @@ 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;
}
@@ -1,137 +0,0 @@
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
@@ -1,192 +0,0 @@
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
@@ -1,74 +0,0 @@
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
@@ -1,410 +0,0 @@
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;
+12 -11
View File
@@ -1,5 +1,4 @@
import { apiClient } from "../../lib/apiClient";
import { buildQueryString } from "../../lib/queryParams";
import type {
Role,
RoleCreateRequest,
@@ -10,14 +9,24 @@ import type {
type RoleWithAccesses = Role & { accesses: RoleAccess[] };
const buildQueryString = (params: Record<string, any>): string => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
searchParams.append(key, String(value));
}
});
const query = searchParams.toString();
return query ? `?${query}` : "";
};
export const rolesApi = {
getAll: () => apiClient.get<Role[]>("/api/role/get"),
getById: (roleId: string) =>
apiClient.get<RoleWithAccesses>(`/api/role/get/${roleId}`),
getAccesses: (tenantId?: string) =>
apiClient.get<RoleAccess[]>(`/api/access/get${tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : ""}`),
getAccesses: () => apiClient.get<RoleAccess[]>("/api/access/get"),
create: (payload: RoleCreateRequest) =>
apiClient.post<Role>("/api/role/create", payload, { successMessage: "Role created successfully", errorMessage: "Failed to create role" }),
@@ -32,19 +41,11 @@ export const rolesApi = {
page?: number;
page_size?: number;
search?: string;
filter_role_names?: string[];
filter_tenant_ids?: string[];
sort_by?: "name" | "tenant";
sort_order?: "asc" | "desc";
}) => {
const queryString = buildQueryString({
page: params.page,
page_size: params.page_size,
search: params.search,
filter_role_names: params.filter_role_names ?? undefined,
filter_tenant_ids: params.filter_tenant_ids ?? undefined,
sort_by: params.sort_by ?? undefined,
sort_order: params.sort_order ?? undefined,
});
return apiClient.get<RolePaginatedResponse>(`/api/role/list${queryString}`);
},
-2
View File
@@ -14,8 +14,6 @@ export type RoleAccess = {
category: string;
name: string;
parent_id?: string | null;
module_id?: string;
module_name?: string;
};
export type RoleCreateRequest = {
+36 -44
View File
@@ -40,6 +40,27 @@ 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) {
@@ -107,55 +128,26 @@ 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 }));
};
// Filter accessOptions to only show what the current user has access to
const availableAccessOptions = useMemo(() => {
return accessOptions;
}, [accessOptions]);
if (!user?.role?.accesses) return [];
// If the user has access codes in their token, filter the list
return accessOptions.filter((option) =>
user.role?.accesses.includes(option.access_code)
);
}, [accessOptions, user]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
@@ -186,12 +178,12 @@ const AddRoles = () => {
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<CustomBackButton to="/roles" tooltip={t('backToRoles')} />
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{t('add')}</h1>
<p className="text-sm text-[var(--text-secondary)]">
{t('subTitle')}
</p>
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">{t('add')}</h1>
<p className="text-sm text-gray-500">
{t('subTitle')}
</p>
</div>
</div>
</div>
@@ -285,4 +277,4 @@ const AddRoles = () => {
);
};
export default AddRoles;
export default AddRoles;
+63 -139
View File
@@ -4,7 +4,6 @@ import { Edit2, Eye, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
CustomButton,
CustomColumnFilter,
CustomConfirmationModal,
CustomInput,
CustomModal,
@@ -16,12 +15,6 @@ import {
import { GroupedAccessSelector } from "./GroupedAccessSelector";
import { GroupedAccessViewer } from "./GroupedAccessViewer";
import type { ColumnDef } from "../../../components/custom/CustomTable";
import {
buildColumnFilterOptions,
resolveColumnSortState,
} from "../../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
import { formatDate } from "../../../lib/dateFormat";
import type {
Role,
RoleAccess,
@@ -34,11 +27,31 @@ import type { Tenant } from "../../tenants/TenantsTypes";
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
import { useAuth } from "../../../context/AuthContext";
import { useDebounce } from "../../../components/hooks/useDebounce";
import {
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
resolveStoredTablePageSize,
} from "../../../lib/tablePageSize";
const formatDate = (dateString?: string | null, language: string = 'en') => {
if (!dateString) return "";
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return dateString;
const hasTime = dateString.includes("T") || dateString.includes(":");
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
year: "numeric",
};
if (hasTime) {
options.hour = "2-digit";
options.minute = "2-digit";
options.hour12 = false;
}
return date.toLocaleString(language === 'ar' ? 'ar-EG' : 'en-GB', options);
} catch {
return dateString;
}
};
const AllRoles = () => {
const { t, i18n } = useTranslation(['roles', 'common']);
@@ -51,27 +64,14 @@ const AllRoles = () => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() =>
resolveStoredTablePageSize({
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
defaultPageSize: 10,
})
);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [roleNameFilter, setRoleNameFilter] = useState<string[]>([]);
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
const [totalRows, setTotalRows] = useState(0);
const [, setTotalPages] = useState(0);
const [activeSort, setActiveSort] = useState<{
column: "role_name" | "tenant_id" | null;
direction: ColumnSortDirection;
}>({ column: null, direction: null });
const debouncedSearch = useDebounce(search, 500);
const searchInputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const latestRolesRequestRef = useRef(0);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [selectedRoleAccesses, setSelectedRoleAccesses] = useState<RoleAccess[] | null>(null);
@@ -83,7 +83,6 @@ const AllRoles = () => {
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
const [isAccessLoading, setIsAccessLoading] = useState(false);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [allRolesForCounts, setAllRolesForCounts] = useState<Role[]>([]);
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
const [editForm, setEditForm] = useState<{ role_name: string; is_default?: boolean }>({ role_name: "" });
@@ -93,9 +92,21 @@ const AllRoles = () => {
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Filter access options based on current user's permissions
const availableAccessOptions = useMemo(() => {
return accessOptions;
}, [accessOptions]);
if (
!currentUser ||
!currentUser.role ||
!currentUser.role.accesses ||
!Array.isArray(currentUser.role.accesses)
) {
return [];
}
return accessOptions.filter((opt) =>
currentUser.role!.accesses.includes(opt.access_code)
);
}, [accessOptions, currentUser]);
useEffect(() => {
let isMounted = true;
@@ -106,9 +117,6 @@ const AllRoles = () => {
const accesses = await rolesApi.getAccesses();
if (isMounted) setAccessOptions(accesses);
const allRoles = await rolesApi.getAll();
if (isMounted) setAllRolesForCounts(allRoles);
if (canReadAllTenants) {
const tenantsData = await tenantsApi.getAll();
if (isMounted) setTenants(tenantsData);
@@ -124,7 +132,6 @@ const AllRoles = () => {
};
const loadRoles = async () => {
const requestId = ++latestRolesRequestRef.current;
setIsLoading(true);
setErrorMessage("");
@@ -133,29 +140,20 @@ const AllRoles = () => {
page,
page_size: pageSize,
search: debouncedSearch || undefined,
filter_role_names: roleNameFilter,
filter_tenant_ids: tenantFilter,
sort_by:
activeSort.column === "role_name"
? "name"
: activeSort.column === "tenant_id"
? "tenant"
: undefined,
sort_order: activeSort.direction ?? undefined,
});
if (isMounted && requestId === latestRolesRequestRef.current) {
if (isMounted) {
setRoles(response.items);
setTotalRows(response.total);
setTotalPages(response.total_pages);
}
} catch (err) {
if (isMounted && requestId === latestRolesRequestRef.current) {
if (isMounted) {
const msg = err instanceof Error ? err.message : t('messages.error');
setErrorMessage(msg);
}
} finally {
if (isMounted && requestId === latestRolesRequestRef.current) setIsLoading(false);
if (isMounted) setIsLoading(false);
}
};
@@ -174,16 +172,15 @@ const AllRoles = () => {
page,
pageSize,
debouncedSearch,
roleNameFilter,
tenantFilter,
activeSort,
t
]);
// Reset page when search changes
useEffect(() => {
setPage(1);
}, [debouncedSearch, roleNameFilter, tenantFilter, activeSort]);
}, [debouncedSearch]);
// Restore search focus after loading
useEffect(() => {
if (prevLoadingRef.current && !isLoading && search.trim()) {
searchInputRef.current?.focus({ preventScroll: true });
@@ -205,23 +202,6 @@ const AllRoles = () => {
[tenants, currentTenant, currentUser]
);
const roleNameCounts = useMemo(() => {
const counts = new Map<string, number>();
allRolesForCounts.forEach((role) => {
counts.set(role.role_name, (counts.get(role.role_name) ?? 0) + 1);
});
return counts;
}, [allRolesForCounts]);
const tenantCounts = useMemo(() => {
const counts = new Map<string, number>();
allRolesForCounts.forEach((role) => {
if (!role.tenant_id) return;
counts.set(role.tenant_id, (counts.get(role.tenant_id) ?? 0) + 1);
});
return counts;
}, [allRolesForCounts]);
const openView = useCallback(async (role: Role) => {
setSelectedRole(role);
setSelectedRoleAccesses(null);
@@ -244,11 +224,7 @@ const AllRoles = () => {
setEditError("");
setIsEditOpen(true);
try {
const [details, tenantAccesses] = await Promise.all([
rolesApi.getById(role.id),
rolesApi.getAccesses(role.tenant_id || undefined),
]);
setAccessOptions(tenantAccesses);
const details = await rolesApi.getById(role.id);
setEditAccessIds(details.accesses?.map((a) => a.id) || []);
} catch (err) {
setEditError(err instanceof Error ? err.message : t('messages.error'));
@@ -330,50 +306,10 @@ const AllRoles = () => {
const columns = useMemo<Array<ColumnDef<Role>>>(
() => [
{
key: "role_name",
visibilityLabel: t('columns.roleName'),
header: (
<div className="flex items-center">
{t('columns.roleName')}
<CustomColumnFilter
title={t('columns.roleName')}
options={buildColumnFilterOptions(roleNameCounts.entries())}
selectedValues={roleNameFilter}
sortDirection={activeSort.column === "role_name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setRoleNameFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "role_name", direction));
}}
/>
</div>
),
},
{ key: "role_name", header: t('columns.roleName') },
{
key: "tenant_id",
visibilityLabel: t('columns.tenant'),
header: (
<div className="flex items-center">
{t('columns.tenant')}
{canReadAllTenants ? (
<CustomColumnFilter
title={t('columns.tenant')}
options={tenants.map((tenant) => ({
label: `${tenant.tenant_name} (${tenantCounts.get(tenant.id) ?? 0})`,
value: tenant.id,
}))}
selectedValues={tenantFilter}
sortDirection={activeSort.column === "tenant_id" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setTenantFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "tenant_id", direction));
}}
/>
) : null}
</div>
),
header: t('columns.tenant'),
render: (row) => getTenantName(row.tenant_id),
},
{
@@ -425,29 +361,14 @@ const AllRoles = () => {
),
},
],
[
activeSort,
canReadAllTenants,
getTenantName,
i18n.language,
openDelete,
openEdit,
openView,
roleNameCounts,
roleNameFilter,
t,
tenantCounts,
tenantFilter,
tenants,
]
[getTenantName, openView, openEdit, openDelete, t, i18n.language]
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<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)]">Define and manage user roles and their associated permissions.</p>
<div>
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
</div>
<ProtectedComponent requiredAccess="admin.role.create">
<Link to="/roles/add">
@@ -457,19 +378,19 @@ const AllRoles = () => {
</div>
{errorMessage ? (
{isLoading ? (
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
<CustomLoader />
</div>
) : errorMessage ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
{errorMessage}
</div>
) : (
<CustomTable
isLoading={isLoading}
data={roles}
columns={columns}
columnVisibilityEnabled
columnVisibilityStorageKey="roles-table-columns"
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
exportEnabled={false}
getRowId={(row) => row.id}
manualPagination
manualFiltering
@@ -484,6 +405,7 @@ const AllRoles = () => {
/>
)}
{/* View Modal */}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
@@ -527,6 +449,7 @@ const AllRoles = () => {
)}
</CustomModal>
{/* Edit Modal */}
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
@@ -535,7 +458,7 @@ const AllRoles = () => {
footer={
<>
<CustomButton variant="outlined" onClick={closeEdit} disabled={isSaving}>{t('actions.cancel')}</CustomButton>
<CustomButton type="submit" form="edit-role-form" variant="primary" loading={isSaving}>{t('actions.update')}</CustomButton>
<CustomButton type="submit" form="edit-role-form" variant="primary" loading={isSaving}>{t('update')}</CustomButton>
</>
}
>
@@ -578,6 +501,7 @@ const AllRoles = () => {
</form>
</CustomModal>
{/* Delete Modal */}
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
@@ -592,4 +516,4 @@ const AllRoles = () => {
);
};
export default AllRoles;
export default AllRoles;
@@ -1,14 +1,18 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import type { RoleAccess } from "../RolesTypes";
import { CustomLoader } from "../../../components/custom";
import { ChevronDown, ChevronRight } from "lucide-react";
interface GroupedAccessSelectorProps {
allAccesses: RoleAccess[];
selectedIds: string[];
onChange: (ids: string[]) => void;
isLoading?: boolean;
title?: string;
}
interface CategoryGroup {
category: string;
accesses: RoleAccess[];
children: Record<string, CategoryGroup>;
}
export const GroupedAccessSelector = ({
@@ -16,281 +20,163 @@ export const GroupedAccessSelector = ({
selectedIds = [],
onChange,
isLoading = false,
title = "Role Permissions",
}: GroupedAccessSelectorProps) => {
const { moduleGroups, childMap, allIds } = useMemo(() => {
const ids = new Set(allAccesses.map((a) => a.id));
const cMap: Record<string, RoleAccess[]> = {};
const roots: RoleAccess[] = [];
const hierarchicalGroups = useMemo(() => {
const parents: RoleAccess[] = [];
const children: Record<string, RoleAccess[]> = {};
allAccesses.forEach((access) => {
const hasParent = access.parent_id && ids.has(access.parent_id);
if (hasParent) {
if (!cMap[access.parent_id!]) {
cMap[access.parent_id!] = [];
}
cMap[access.parent_id!].push(access);
allAccesses.forEach(access => {
if (!access.parent_id) {
parents.push(access);
} else {
roots.push(access);
if (!children[access.parent_id]) {
children[access.parent_id] = [];
}
children[access.parent_id].push(access);
}
});
const modGroups: Record<string, Record<string, RoleAccess[]>> = {};
roots.forEach((root) => {
const moduleName = root.module_name || "SaaS (Internal)";
const cat = root.category || "General";
if (!modGroups[moduleName]) {
modGroups[moduleName] = {};
const categoryGroups: Record<string, CategoryGroup> = {};
parents.forEach(parent => {
if (!categoryGroups[parent.category]) {
categoryGroups[parent.category] = {
category: parent.category,
accesses: [],
children: {}
};
}
if (!modGroups[moduleName][cat]) {
modGroups[moduleName][cat] = [];
}
modGroups[moduleName][cat].push(root);
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);
});
});
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 => ({
name,
categories: modGroups[name]
}));
return { moduleGroups: orderedGroups, childMap: cMap, allIds: ids };
return categoryGroups;
}, [allAccesses]);
const [expandedModules, setExpandedModules] = useState<Record<string, boolean>>({});
const toggleModuleExpansion = (moduleName: string) => {
setExpandedModules(prev => ({
...prev,
[moduleName]: !prev[moduleName]
}));
};
const getBranchIds = (id: string): string[] => {
const ids = [id];
const children = childMap[id] || [];
children.forEach((c) => {
ids.push(...getBranchIds(c.id));
const getCategoryAccesses = (group: CategoryGroup): RoleAccess[] => {
const accesses = [...group.accesses];
Object.values(group.children).forEach(child => {
accesses.push(...child.accesses);
});
return ids;
return accesses;
};
const isBranchSelected = (id: string) => {
const ids = getBranchIds(id);
return ids.every((i) => selectedIds.includes(i));
const isCategorySelected = (group: CategoryGroup) => {
const categoryAccesses = getCategoryAccesses(group);
return (
categoryAccesses.length > 0 &&
categoryAccesses.every((access) => selectedIds.includes(access.id))
);
};
const isBranchIndeterminate = (id: string) => {
const ids = getBranchIds(id);
const selectedCount = ids.filter((i) => selectedIds.includes(i)).length;
return selectedCount > 0 && selectedCount < ids.length;
const isCategoryIndeterminate = (group: CategoryGroup) => {
const categoryAccesses = getCategoryAccesses(group);
const selectedCount = categoryAccesses.filter((access) =>
selectedIds.includes(access.id)
).length;
return selectedCount > 0 && selectedCount < categoryAccesses.length;
};
const toggleBranch = (id: string) => {
const ids = getBranchIds(id);
const allSelected = ids.every((i) => selectedIds.includes(i));
const toggleCategory = (group: CategoryGroup) => {
const categoryAccesses = getCategoryAccesses(group);
const allSelected = isCategorySelected(group);
const categoryIds = categoryAccesses.map((a) => a.id);
let newIds: string[];
if (allSelected) {
newIds = selectedIds.filter((i) => !ids.includes(i));
newIds = selectedIds.filter((id) => !categoryIds.includes(id));
} else {
const unique = new Set([...selectedIds, ...ids]);
newIds = Array.from(unique);
const uniqueIds = new Set([...selectedIds, ...categoryIds]);
newIds = Array.from(uniqueIds);
}
onChange(newIds);
};
const toggleSingle = (id: string) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((sid) => sid !== id));
} else {
onChange([...selectedIds, id]);
}
// --- Subcategory Helpers ---
const isSubcategorySelected = (accesses: RoleAccess[]) => {
return (
accesses.length > 0 &&
accesses.every((access) => selectedIds.includes(access.id))
);
};
const getCategoryIds = (roots: RoleAccess[]) => {
const ids: string[] = [];
roots.forEach((root) => {
ids.push(...getBranchIds(root.id));
});
return ids;
const isSubcategoryIndeterminate = (accesses: RoleAccess[]) => {
const selectedCount = accesses.filter((access) =>
selectedIds.includes(access.id)
).length;
return selectedCount > 0 && selectedCount < accesses.length;
};
const isCategorySelected = (roots: RoleAccess[]) => {
const ids = getCategoryIds(roots);
return ids.length > 0 && ids.every((id) => selectedIds.includes(id));
};
const toggleSubcategory = (accesses: RoleAccess[]) => {
const allSelected = isSubcategorySelected(accesses);
const accessIds = accesses.map((a) => a.id);
const isCategoryIndeterminate = (roots: RoleAccess[]) => {
const ids = getCategoryIds(roots);
const count = ids.filter((id) => selectedIds.includes(id)).length;
return count > 0 && count < ids.length;
};
const toggleCategory = (roots: RoleAccess[]) => {
const ids = getCategoryIds(roots);
const allSelected = ids.every((id) => selectedIds.includes(id));
let newIds: string[];
if (allSelected) {
onChange(selectedIds.filter((id) => !ids.includes(id)));
newIds = selectedIds.filter((id) => !accessIds.includes(id));
} else {
const unique = new Set([...selectedIds, ...ids]);
onChange(Array.from(unique));
const uniqueIds = new Set([...selectedIds, ...accessIds]);
newIds = Array.from(uniqueIds);
}
onChange(newIds);
};
const getModuleIds = (categories: Record<string, RoleAccess[]>) => {
return Object.values(categories).flatMap(roots => getCategoryIds(roots));
// --- Global Helpers ---
const isAllSelected = () => {
return (
allAccesses.length > 0 &&
allAccesses.every((access) => selectedIds.includes(access.id))
);
};
const isModuleSelected = (categories: Record<string, RoleAccess[]>) => {
const ids = getModuleIds(categories);
return ids.length > 0 && ids.every(id => selectedIds.includes(id));
const isAllIndeterminate = () => {
const selectedCount = selectedIds.length;
return selectedCount > 0 && selectedCount < allAccesses.length;
};
const isModuleIndeterminate = (categories: Record<string, RoleAccess[]>) => {
const ids = getModuleIds(categories);
const count = ids.filter(id => selectedIds.includes(id)).length;
return count > 0 && count < ids.length;
};
const toggleModule = (categories: Record<string, RoleAccess[]>) => {
const ids = getModuleIds(categories);
const allSelected = ids.every(id => selectedIds.includes(id));
if (allSelected) {
onChange(selectedIds.filter(id => !ids.includes(id)));
} else {
const unique = new Set([...selectedIds, ...ids]);
onChange(Array.from(unique));
}
};
const isAllSelected = () =>
allIds.size > 0 && Array.from(allIds).every((id) => selectedIds.includes(id));
const isAllIndeterminate = () =>
selectedIds.length > 0 && selectedIds.length < allIds.size;
const toggleAll = () => {
if (isAllSelected()) {
onChange([]);
} else {
onChange(Array.from(allIds));
const allIds = allAccesses.map((a) => a.id);
onChange(allIds);
}
};
const renderNode = (node: RoleAccess, depth: number = 0) => {
const children = childMap[node.id];
const hasChildren = children && children.length > 0;
const isRoot = depth === 0;
const renderChildrenList = (items: RoleAccess[]) => {
if (items.some(c => childMap[c.id])) {
return (
<div className="flex flex-col gap-3">
{items.map(child => renderNode(child, depth + 1))}
</div>
);
} else {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{items.map(child => renderNode(child, depth + 1))}
</div>
);
}
};
if (hasChildren) {
if (isRoot) {
const childGroups: Record<string, RoleAccess[]> = {};
let hasMultipleGroups = false;
children.forEach(c => {
const cat = c.category || 'General';
if (!childGroups[cat]) childGroups[cat] = [];
childGroups[cat].push(c);
});
hasMultipleGroups = Object.keys(childGroups).length > 1;
return (
<div key={node.id} className="rounded-md border border-gray-200 bg-white p-3 mb-3 break-inside-avoid shadow-xs">
<label className="flex items-center gap-2 mb-3 cursor-pointer group pb-2 border-b border-gray-50/50 select-none">
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isBranchSelected(node.id)}
ref={(el) => { if (el) el.indeterminate = isBranchIndeterminate(node.id); }}
onChange={() => toggleBranch(node.id)}
/>
<span className="font-semibold text-gray-800">{node.name}</span>
</label>
<div className="pl-2">
{hasMultipleGroups ? (
<div className="space-y-4">
{Object.entries(childGroups).map(([groupName, groupItems]) => (
<div key={groupName} className="bg-gray-50/50 rounded-md border border-gray-200/50 p-3">
<h5 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 border-b border-gray-100 pb-1">
{groupName}
</h5>
{renderChildrenList(groupItems)}
</div>
))}
</div>
) : (
renderChildrenList(children)
)}
</div>
</div>
);
} else {
return (
<div key={node.id} className="rounded-md border border-gray-100 bg-white p-3">
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-gray-50 select-none">
<input
type="checkbox"
className="size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isBranchSelected(node.id)}
ref={(el) => { if (el) el.indeterminate = isBranchIndeterminate(node.id); }}
onChange={() => toggleBranch(node.id)}
/>
<span className="font-medium text-sm text-gray-700">{node.name}</span>
</div>
{renderChildrenList(children)}
</div>
);
}
} else {
return (
<label
key={node.id}
className="flex items-start gap-2 cursor-pointer p-1.5 hover:bg-gray-50/50 rounded-md transition-colors select-none"
>
<input
type="checkbox"
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={selectedIds.includes(node.id)}
onChange={() => toggleSingle(node.id)}
/>
<span className="text-sm font-medium text-gray-700">{node.name}</span>
</label>
);
}
// --- Individual Helper ---
const toggleAccess = (accessId: string) => {
const newIds = selectedIds.includes(accessId)
? selectedIds.filter((id) => id !== accessId)
: [...selectedIds, accessId];
onChange(newIds);
};
if (isLoading) return <CustomLoader />;
if (allAccesses.length === 0)
if (isLoading) {
return <CustomLoader />;
}
if (allAccesses.length === 0) {
return <p className="text-sm text-gray-500">No permissions available.</p>;
}
return (
<div className="space-y-4 pt-2">
<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 justify-between">
<h3 className="text-base font-medium text-gray-900">Permissions</h3>
{/* Global Select All */}
<div className="flex items-center gap-2">
<input
type="checkbox"
@@ -302,88 +188,109 @@ export const GroupedAccessSelector = ({
}}
onChange={toggleAll}
/>
<label
htmlFor="select-all"
<label
htmlFor="select-all"
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
>
Select All
Select All Permissions
</label>
</div>
</div>
<div className="space-y-6">
{moduleGroups.map((moduleGroup) => {
const isExpanded = expandedModules[moduleGroup.name];
return (
<div key={moduleGroup.name} className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden">
<div
className="flex items-center justify-between p-4 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => toggleModuleExpansion(moduleGroup.name)}
{Object.entries(hierarchicalGroups).map(([category, group]) => (
<div
key={category}
className="rounded-lg border border-gray-200 bg-gray-50/50 p-4"
>
{/* Category Header */}
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isCategorySelected(group)}
ref={(el) => {
if (el) el.indeterminate = isCategoryIndeterminate(group);
}}
onChange={() => toggleCategory(group)}
/>
<span className="text-sm font-semibold text-gray-800 uppercase tracking-wide">
{category}
</span>
</div>
{/* Parent Accesses */}
{group.accesses.length > 0 && (
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{group.accesses.map((access) => (
<label
key={access.id}
className="flex items-start gap-3 cursor-pointer group"
>
<div className="flex items-center gap-3">
{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()}
>
<input
type="checkbox"
className="size-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isModuleSelected(moduleGroup.categories)}
ref={(el) => {
if (el) el.indeterminate = isModuleIndeterminate(moduleGroup.categories);
}}
onChange={() => toggleModule(moduleGroup.categories)}
/>
<h2 className="text-base font-bold text-gray-900 select-none">{moduleGroup.name}</h2>
</div>
</div>
</div>
{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">
{Object.entries(moduleGroup.categories).map(([category, roots]) => (
<div
key={category}
className="rounded-lg border border-gray-200 bg-gray-50/10 p-4"
>
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isCategorySelected(roots)}
ref={(el) => {
if (el) el.indeterminate = isCategoryIndeterminate(roots);
}}
onChange={() => toggleCategory(roots)}
/>
<span className="text-sm font-bold text-gray-700 uppercase tracking-widest select-none">
{category}
</span>
</div>
<div className="space-y-4">
{roots
.filter((r) => childMap[r.id]?.length > 0)
.map((root) => renderNode(root, 0))}
{roots.some((r) => !childMap[r.id]?.length) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 mt-4 ml-1">
{roots
.filter((r) => !childMap[r.id]?.length)
.map((root) => renderNode(root, 0))}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
<input
type="checkbox"
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
checked={selectedIds.includes(access.id)}
onChange={() => toggleAccess(access.id)}
/>
<span className="text-sm text-gray-600 group-hover:text-gray-900">
{access.name}
</span>
</label>
))}
</div>
)})}
)}
{/* Child Categories */}
{Object.keys(group.children).length > 0 && (
<div className="space-y-4 mt-4">
{Object.entries(group.children).map(([childCategory, childGroup]) => (
<div
key={childCategory}
className="rounded-md border border-gray-300 bg-white p-3 ml-4"
>
{/* Subcategory Header */}
<div className="mb-3 flex items-center gap-2 border-b border-gray-200 pb-2">
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isSubcategorySelected(childGroup.accesses)}
ref={(el) => {
if (el) el.indeterminate = isSubcategoryIndeterminate(childGroup.accesses);
}}
onChange={() => toggleSubcategory(childGroup.accesses)}
/>
<span className="text-xs font-semibold text-gray-700 uppercase tracking-wide">
{childCategory}
</span>
</div>
{/* Subcategory Accesses */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{childGroup.accesses.map((access) => (
<label
key={access.id}
className="flex items-start gap-2 cursor-pointer group"
>
<input
type="checkbox"
className="mt-0.5 size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
checked={selectedIds.includes(access.id)}
onChange={() => toggleAccess(access.id)}
/>
<span className="text-xs text-gray-600 group-hover:text-gray-900">
{access.name}
</span>
</label>
))}
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
};
};
@@ -1,188 +1,105 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Layers, ShieldCheck } from "lucide-react";
import { useMemo } from "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 { 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);
const hierarchicalGroups = useMemo(() => {
const parents: RoleAccess[] = [];
const children: Record<string, RoleAccess[]> = {};
accesses.forEach(access => {
if (!access.parent_id) {
parents.push(access);
} else {
roots.push(access);
if (!children[access.parent_id]) {
children[access.parent_id] = [];
}
children[access.parent_id].push(access);
}
});
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] = {};
const categoryGroups: Record<string, CategoryGroup> = {};
parents.forEach(parent => {
if (!categoryGroups[parent.category]) {
categoryGroups[parent.category] = {
category: parent.category,
accesses: [],
children: {}
};
}
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);
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);
});
});
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 };
return categoryGroups;
}, [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 (
<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 <p className="text-sm text-gray-500">No permissions assigned.</p>;
}
return (
<div className="space-y-4">
<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.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="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>
{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>
<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>
);
};
@@ -1,145 +0,0 @@
import { useMemo } from "react";
import type { RoleAccess } from "../RolesTypes";
interface NodeGroupAccessViewerProps {
accesses: RoleAccess[];
}
export const NodeGroupAccessViewer = ({ accesses }: NodeGroupAccessViewerProps) => {
const { categoryGroups, childMap } = useMemo(() => {
const childMap: Record<string, RoleAccess[]> = {};
const categoryGroups: Record<string, RoleAccess[]> = {};
const idMap = new Set(accesses.map(a => a.id));
accesses.forEach(node => {
if (!node.parent_id || !idMap.has(node.parent_id)) {
const category = node.category || 'General';
if (!categoryGroups[category]) {
categoryGroups[category] = [];
}
categoryGroups[category].push(node);
} else {
if (!childMap[node.parent_id]) {
childMap[node.parent_id] = [];
}
childMap[node.parent_id].push(node);
}
});
return { categoryGroups, childMap };
}, [accesses]);
const renderNode = (node: RoleAccess, depth: number = 0) => {
const children = childMap[node.id];
const hasChildren = children && children.length > 0;
const renderChildrenList = (items: RoleAccess[]) => {
if (items.some(c => childMap[c.id])) {
return (
<div className="flex flex-col gap-3">
{items.map(child => renderNode(child, depth + 1))}
</div>
);
} else {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{items.map(child => renderNode(child, depth + 1))}
</div>
);
}
};
if (depth === 0) {
if (hasChildren) {
const childGroups: Record<string, RoleAccess[]> = {};
let hasMultipleGroups = false;
children.forEach(c => {
const cat = c.category || 'General';
if (!childGroups[cat]) childGroups[cat] = [];
childGroups[cat].push(c);
});
hasMultipleGroups = Object.keys(childGroups).length > 1;
return (
<div key={node.id} className="col-span-1 sm:col-span-2 rounded-lg border border-gray-200 bg-gray-50/50 p-4 break-inside-avoid">
<div className="flex items-center gap-2 mb-3 border-b border-gray-200 pb-2">
<span className="size-2 rounded-full bg-blue-600 shrink-0" />
<span className="text-base font-semibold text-gray-800 tracking-wide">
{node.name}
</span>
</div>
<div className="pl-2">
{hasMultipleGroups ? (
<div className="space-y-4">
{Object.entries(childGroups).map(([groupName, groupItems]) => (
<div key={groupName} className="bg-white/50 rounded-md border border-gray-200/50 p-3">
<h5 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 border-b border-gray-100 pb-1">
{groupName}
</h5>
{renderChildrenList(groupItems)}
</div>
))}
</div>
) : (
renderChildrenList(children)
)}
</div>
</div>
);
} else {
return (
<div key={node.id} className="flex items-center gap-2 p-3 bg-white border border-gray-200 rounded-lg shadow-sm">
<span className="size-2 rounded-full bg-blue-500 shrink-0" />
<span className="font-medium text-gray-700">{node.name}</span>
</div>
);
}
}
if (hasChildren) {
return (
<div key={node.id} className="rounded-md border border-gray-100 bg-white p-3">
<div className="mb-2 pb-2 border-b border-gray-50">
<span className="font-medium text-sm text-gray-700 block">{node.name}</span>
</div>
{renderChildrenList(children)}
</div>
);
}
return (
<div key={node.id} className="flex items-center gap-2 text-sm text-gray-600 bg-white p-2 rounded-md border border-transparent hover:border-gray-100 shadow-sm hover:shadow-md transition-all">
<span className="size-1.5 rounded-full bg-green-500 shrink-0" />
<span className="font-medium leading-tight">{node.name}</span>
</div>
);
};
if (!accesses || accesses.length === 0) {
return (
<div className="p-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-300">
<p className="text-gray-500">No permissions found in this module.</p>
</div>
);
}
return (
<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">
<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>
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
{roots.map(root => renderNode(root, 0))}
</div>
</div>
))}
</div>
);
};
-45
View File
@@ -1,45 +0,0 @@
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",
}
),
};
@@ -1,196 +0,0 @@
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
@@ -1,414 +0,0 @@
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
@@ -1,14 +0,0 @@
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
@@ -1,8 +0,0 @@
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;
}
};
-105
View File
@@ -1,105 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { CustomDropdown, CustomConfirmationModal } from '../../components/custom';
import { useAuth } from '../../context/AuthContext';
import { languages, loadLanguage } from '../../i18n/config';
import type { SupportedLanguage } from '../../i18n/config';
import { Globe } from 'lucide-react';
const SettingsPage: React.FC = () => {
const { t, i18n } = useTranslation('common');
const { user, updateLanguage } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [selectedLanguage] = useState<SupportedLanguage>(
(i18n.language as SupportedLanguage) || 'en'
);
const [pendingLanguage, setPendingLanguage] = useState<SupportedLanguage | null>(null);
const [showConfirmation, setShowConfirmation] = useState(false);
const handleLanguageChange = (newLanguage: SupportedLanguage) => {
if (newLanguage === selectedLanguage) return;
setPendingLanguage(newLanguage);
setShowConfirmation(true);
};
const handleConfirmLanguageChange = async () => {
if (!pendingLanguage) return;
setIsLoading(true);
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);
toast.error(t('messages.error'));
setIsLoading(false);
setPendingLanguage(null);
}
};
const handleCancelLanguageChange = () => {
setPendingLanguage(null);
setShowConfirmation(false);
};
return (
<div className="max-w-4xl mx-auto">
<div className="flex flex-col gap-1 mb-6">
<h1 className="text-2xl font-bold text-(--text-primary)">
{t('settings.title')}
</h1>
<p className="text-sm text-[var(--text-secondary)]">Manage your account settings and application preferences.</p>
</div>
<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)" />
<h2 className="text-lg font-semibold text-(--text-primary)">
{t('settings.language.title')}
</h2>
</div>
<p className="text-sm text-(--text-secondary) mb-6">
{t('settings.language.description')}
</p>
<div className="max-w-md">
<CustomDropdown
label={t('settings.language.selectLabel') || "Select Language"}
options={(Object.keys(languages) as SupportedLanguage[]).map(lang => ({
label: languages[lang].name,
value: lang
}))}
value={selectedLanguage}
onChange={(e) => handleLanguageChange(e.target.value as SupportedLanguage)}
disabled={isLoading}
leftIcon={<Globe size={18} />}
/>
</div>
</div>
<CustomConfirmationModal
isOpen={showConfirmation}
onClose={handleCancelLanguageChange}
onConfirm={handleConfirmLanguageChange}
title={t('settings.language.confirmTitle') || 'Change Language?'}
description={t('settings.language.confirmMessage') || 'The page will reload to apply the new language settings. Continue?'}
confirmText={t('actions.confirm')}
cancelText={t('actions.cancel')}
/>
</div>
);
};
export default SettingsPage;
@@ -1,9 +0,0 @@
export interface LanguageSettings {
code: string;
name: string;
dir: 'ltr' | 'rtl';
}
export interface SettingsFormData {
language: string;
}
-34
View File
@@ -1,34 +0,0 @@
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
@@ -1,397 +0,0 @@
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
@@ -1,40 +0,0 @@
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;
};
@@ -1,52 +0,0 @@
export type SubscriptionPlan = {
id: string;
name: string;
description?: string | null;
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;
updated_at: string;
};
export type SubscriptionPlanDetail = SubscriptionPlan & {
access_ids: string[];
module_access_ids: string[];
};
export type SubscriptionPlanCreateRequest = {
name: string;
description?: string;
price?: number;
duration_days?: number;
max_users_allowed?: number;
grace_period_days?: number;
is_public?: boolean;
status?: string;
access_ids?: string[];
module_access_ids?: string[];
};
export type SubscriptionPlanUpdateRequest = {
name?: string;
description?: string;
price?: number;
duration_days?: number;
max_users_allowed?: number;
grace_period_days?: number;
is_public?: boolean;
status?: string;
access_ids?: string[];
module_access_ids?: string[];
};
export type SubscriptionPlanPaginatedResponse = {
items: SubscriptionPlan[];
total: number;
page: number;
page_size: number;
total_pages: number;
};
@@ -1,76 +0,0 @@
import { apiClient } from "../../lib/apiClient";
import { buildQueryString } from "../../lib/queryParams";
import type {
SubscriptionPlan,
SubscriptionPlanDetail,
SubscriptionPlanCreateRequest,
SubscriptionPlanUpdateRequest,
SubscriptionPlanPaginatedResponse,
} from "./SubscriptionTypes";
import type { RoleAccess } from "../roles/RolesTypes";
export const subscriptionsApi = {
getAll: (params?: { is_public?: boolean; status?: string }) => {
const queryString = params ? buildQueryString(params) : "";
return apiClient.get<SubscriptionPlan[]>(
`/api/subscription-plan/all${queryString}`
);
},
getById: (planId: string) =>
apiClient.get<SubscriptionPlanDetail>(
`/api/subscription-plan/get/${planId}`
),
create: (payload: SubscriptionPlanCreateRequest) =>
apiClient.post<SubscriptionPlan>("/api/subscription-plan/create", payload, {
successMessage: "Subscription plan created successfully",
errorMessage: "Failed to create subscription plan",
}),
update: (planId: string, payload: SubscriptionPlanUpdateRequest) =>
apiClient.put<SubscriptionPlan>(
`/api/subscription-plan/update/${planId}`,
payload,
{
successMessage: "Subscription plan updated successfully",
errorMessage: "Failed to update subscription plan",
}
),
remove: (planId: string) =>
apiClient.delete<{ message: string }>(
`/api/subscription-plan/delete/${planId}`,
{
successMessage: "Subscription plan deleted",
errorMessage: "Failed to delete subscription plan",
}
),
getPaginated: (params: {
page?: number;
page_size?: number;
search?: string;
filter_names?: string[];
statuses?: string[];
visibility?: boolean[];
sort_by?: "name" | "price" | "status" | "visibility";
sort_order?: "asc" | "desc";
}) => {
const queryString = buildQueryString({
page: params.page,
page_size: params.page_size,
search: params.search,
filter_names: params.filter_names ?? undefined,
statuses: params.statuses ?? undefined,
visibility: params.visibility ?? undefined,
sort_by: params.sort_by ?? undefined,
sort_order: params.sort_order ?? undefined,
});
return apiClient.get<SubscriptionPlanPaginatedResponse>(
`/api/subscription-plan/list${queryString}`
);
},
getAccesses: () => apiClient.get<RoleAccess[]>("/api/access/get"),
};
@@ -1,126 +0,0 @@
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);
});
});
@@ -1,56 +0,0 @@
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);
@@ -1,251 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CustomButton,
CustomCheckBox,
CustomDropdown,
CustomInput,
CustomBackButton,
} from "../../../components/custom";
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();
const [formData, setFormData] = useState<SubscriptionPlanCreateRequest>({
name: "",
description: "",
price: undefined,
duration_days: undefined,
max_users_allowed: undefined,
grace_period_days: 0,
is_public: true,
status: "active",
access_ids: [],
module_access_ids: [],
});
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
const [selectedAccessIds, setSelectedAccessIds] = useState<string[]>([]);
const [isAccessLoading, setIsAccessLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
let isMounted = true;
const loadAccesses = async () => {
setIsAccessLoading(true);
try {
const data = await subscriptionsApi.getAccesses();
if (isMounted) {
setAllAccesses(data);
}
} 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;
};
}, []);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setErrorMessage("");
setIsLoading(true);
try {
const payload = buildPlanCreatePayload(
formData,
selectedAccessIds,
allAccesses
);
await subscriptionsApi.create(payload);
navigate("/subscriptions");
} catch (error) {
const message =
error instanceof Error
? error.message
: "Unable to create subscription plan.";
setErrorMessage(message);
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<CustomBackButton to="/subscriptions" tooltip="Back to Subscriptions" />
<div>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
Add Subscription Plan
</h1>
<p className="text-sm text-[var(--text-secondary)]">
Create a new plan with pricing and access permissions.
</p>
</div>
</div>
</div>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-lg border border-gray-200 bg-white p-6"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Plan Name"
name="name"
placeholder="Enter plan name"
value={formData.name}
onChange={handleChange}
required
/>
<CustomInput
label="Price"
name="price"
type="number"
placeholder="0.00"
value={formData.price ?? ""}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
<CustomInput
label="Duration (Days)"
name="duration_days"
type="number"
placeholder="30"
value={formData.duration_days ?? ""}
onChange={(e) =>
setFormData((prev) => ({
...prev,
duration_days: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
<CustomInput
label="Max Allowed Users"
name="max_users_allowed"
type="number"
placeholder="Unlimited"
value={formData.max_users_allowed ?? ""}
onChange={(e) =>
setFormData((prev) => ({
...prev,
max_users_allowed: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
<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
label="Description"
name="description"
placeholder="Enter plan description"
value={formData.description ?? ""}
onChange={handleChange}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomDropdown
label="Status"
name="status"
value={formData.status ?? "active"}
onChange={(e) =>
setFormData((prev) => ({ ...prev, status: e.target.value }))
}
options={[
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
]}
/>
<div className="flex items-end pb-1">
<CustomCheckBox
label="Publicly Visible"
name="is_public"
checked={formData.is_public ?? true}
onChange={(e) =>
setFormData((prev) => ({
...prev,
is_public: e.target.checked,
}))
}
/>
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<GroupedAccessSelector
allAccesses={allAccesses}
selectedIds={selectedAccessIds}
onChange={setSelectedAccessIds}
isLoading={isAccessLoading}
title="Plan Permissions"
/>
</div>
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{errorMessage}
</div>
)}
<div className="flex justify-end pt-4">
<CustomButton
type="submit"
variant="primary"
disabled={isLoading}
loading={isLoading}
>
Create Plan
</CustomButton>
</div>
</form>
</div>
);
};
export default AddSubscriptions;
@@ -1,831 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Edit2, Eye, Trash2 } from "lucide-react";
import {
CustomButton,
CustomCheckBox,
CustomColumnFilter,
CustomConfirmationModal,
CustomDropdown,
CustomInput,
CustomModal,
CustomTable,
CustomStatus,
CustomLoader,
CustomActionMenu,
CustomActionItem,
} from "../../../components/custom";
import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector";
import { GroupedAccessViewer } from "../../roles/components/GroupedAccessViewer";
import type { ColumnDef } from "../../../components/custom/CustomTable";
import {
buildColumnFilterOptions,
resolveColumnSortState,
} from "../../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
import { formatDate } from "../../../lib/dateFormat";
import type { SubscriptionPlan } from "../SubscriptionTypes";
import type { RoleAccess } from "../../roles/RolesTypes";
import { subscriptionsApi } from "../SubscriptionsApi";
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
import { useAuth } from "../../../context/AuthContext";
import { useDebounce } from "../../../components/hooks/useDebounce";
import {
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
resolveStoredTablePageSize,
} from "../../../lib/tablePageSize";
import { buildPlanUpdatePayload } from "../buildPlanPayload";
const formatPrice = (price?: number | null) => {
if (price == null) return "-";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
};
interface EditFormState {
name: string;
description: string;
price: number | undefined;
duration_days: number | undefined;
max_users_allowed: number | undefined;
grace_period_days: number | undefined;
is_public: boolean;
status: string;
}
const AllSubscriptions = () => {
const { isLoading: isAuthLoading } = useAuth();
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() =>
resolveStoredTablePageSize({
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
defaultPageSize: 10,
})
);
const [search, setSearch] = useState("");
const [nameFilter, setNameFilter] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [visibilityFilter, setVisibilityFilter] = useState<boolean[]>([]);
const [totalRows, setTotalRows] = useState(0);
const [, setTotalPages] = useState(0);
const [activeSort, setActiveSort] = useState<{
column: "name" | "status" | "is_public" | null;
direction: ColumnSortDirection;
}>({ column: null, direction: null });
const [allPlansForCounts, setAllPlansForCounts] = useState<SubscriptionPlan[]>([]);
const debouncedSearch = useDebounce(search, 500);
const searchInputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const latestPlansRequestRef = useRef(0);
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
const [isAccessLoading, setIsAccessLoading] = useState(false);
const [viewAccessIds, setViewAccessIds] = useState<string[]>([]);
const [editAccessIds, setEditAccessIds] = useState<string[]>([]);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [editForm, setEditForm] = useState<EditFormState>({
name: "",
description: "",
price: undefined,
duration_days: undefined,
max_users_allowed: undefined,
grace_period_days: 0,
is_public: true,
status: "active",
});
const [editError, setEditError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
let isMounted = true;
const loadAccesses = async () => {
setIsAccessLoading(true);
try {
const data = await subscriptionsApi.getAccesses();
if (isMounted) setAllAccesses(data);
const allPlans = await subscriptionsApi.getAll();
if (isMounted) setAllPlansForCounts(allPlans);
} catch (err) {
console.error("Failed to load accesses:", err);
} finally {
if (isMounted) setIsAccessLoading(false);
}
};
loadAccesses();
return () => { isMounted = false; };
}, []);
useEffect(() => {
let isMounted = true;
const loadPlans = async () => {
if (isAuthLoading) return;
const requestId = ++latestPlansRequestRef.current;
setIsLoading(true);
setErrorMessage("");
try {
const response = await subscriptionsApi.getPaginated({
page,
page_size: pageSize,
search: debouncedSearch || undefined,
filter_names: nameFilter,
statuses: statusFilter,
visibility: visibilityFilter,
sort_by:
activeSort.column === "name"
? "name"
: activeSort.column === "status"
? "status"
: activeSort.column === "is_public"
? "visibility"
: undefined,
sort_order: activeSort.direction ?? undefined,
});
if (isMounted && requestId === latestPlansRequestRef.current) {
setPlans(response.items);
setTotalRows(response.total);
setTotalPages(response.total_pages);
}
} catch (error) {
if (isMounted && requestId === latestPlansRequestRef.current) {
const message =
error instanceof Error ? error.message : "Unable to load plans.";
setErrorMessage(message);
}
} finally {
if (isMounted && requestId === latestPlansRequestRef.current) setIsLoading(false);
}
};
loadPlans();
return () => { isMounted = false; };
}, [activeSort, debouncedSearch, isAuthLoading, nameFilter, page, pageSize, statusFilter, visibilityFilter]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, nameFilter, statusFilter, visibilityFilter, activeSort]);
useEffect(() => {
if (prevLoadingRef.current && !isLoading && search.trim()) {
searchInputRef.current?.focus({ preventScroll: true });
}
prevLoadingRef.current = isLoading;
}, [isLoading, search]);
const viewAccesses = useMemo(() => {
if (!viewAccessIds.length || !allAccesses.length) return [];
const idSet = new Set(viewAccessIds);
return allAccesses.filter((a) => idSet.has(a.id));
}, [viewAccessIds, allAccesses]);
const nameCounts = useMemo(() => {
const counts = new Map<string, number>();
allPlansForCounts.forEach((plan) => {
counts.set(plan.name, (counts.get(plan.name) ?? 0) + 1);
});
return counts;
}, [allPlansForCounts]);
const planStatusCounts = useMemo(() => {
const counts = new Map<string, number>();
allPlansForCounts.forEach((plan) => {
counts.set(plan.status, (counts.get(plan.status) ?? 0) + 1);
});
return counts;
}, [allPlansForCounts]);
const visibilityCounts = useMemo(() => {
const counts = new Map<string, number>();
allPlansForCounts.forEach((plan) => {
const key = plan.is_public ? "true" : "false";
counts.set(key, (counts.get(key) ?? 0) + 1);
});
return counts;
}, [allPlansForCounts]);
const openView = useCallback(async (plan: SubscriptionPlan) => {
setSelectedPlan(plan);
setViewAccessIds([]);
setIsViewOpen(true);
setIsDetailLoading(true);
try {
const detail = await subscriptionsApi.getById(plan.id);
setViewAccessIds([...detail.access_ids, ...detail.module_access_ids]);
} catch {
setViewAccessIds([]);
} finally {
setIsDetailLoading(false);
}
}, []);
const openEdit = useCallback(async (plan: SubscriptionPlan) => {
setSelectedPlan(plan);
setEditForm({
name: plan.name,
description: plan.description ?? "",
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,
});
setEditAccessIds([]);
setEditError("");
setIsEditOpen(true);
try {
const detail = await subscriptionsApi.getById(plan.id);
setEditAccessIds([...detail.access_ids, ...detail.module_access_ids]);
} catch (err) {
setEditError(
err instanceof Error ? err.message : "Failed to load plan details."
);
}
}, []);
const openDelete = useCallback((plan: SubscriptionPlan) => {
setSelectedPlan(plan);
setDeleteError("");
setIsDeleteOpen(true);
}, []);
const closeView = useCallback(() => {
setIsViewOpen(false);
setSelectedPlan(null);
setViewAccessIds([]);
}, []);
const closeEdit = useCallback(() => {
setIsEditOpen(false);
setSelectedPlan(null);
setEditError("");
}, []);
const closeDelete = useCallback(() => {
setIsDeleteOpen(false);
setSelectedPlan(null);
setDeleteError("");
}, []);
const handleEditChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setEditForm((prev) => ({ ...prev, [name]: value }));
},
[]
);
const handleUpdate = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedPlan) return;
setIsSaving(true);
setEditError("");
try {
const payload = buildPlanUpdatePayload(editForm, editAccessIds, allAccesses);
const updated = await subscriptionsApi.update(selectedPlan.id, payload);
setPlans((prev) =>
prev.map((p) => (p.id === updated.id ? updated : p))
);
closeEdit();
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to update plan.";
setEditError(message);
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!selectedPlan) return;
setIsDeleting(true);
setDeleteError("");
try {
await subscriptionsApi.remove(selectedPlan.id);
setPlans((prev) => prev.filter((p) => p.id !== selectedPlan.id));
closeDelete();
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to delete plan.";
setDeleteError(message);
} finally {
setIsDeleting(false);
}
};
const columns = useMemo<Array<ColumnDef<SubscriptionPlan>>>(
() => [
{
key: "name",
visibilityLabel: "Plan Name",
header: (
<div className="flex items-center">
Plan Name
<CustomColumnFilter
title="Plan Name"
options={buildColumnFilterOptions(nameCounts.entries())}
selectedValues={nameFilter}
sortDirection={activeSort.column === "name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setNameFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "name", direction));
}}
/>
</div>
),
},
{
key: "price",
header: "Price",
searchable: false,
render: (row) => (
<span className="text-sm font-medium text-[var(--text-primary)]">
{formatPrice(row.price)}
</span>
),
},
{
key: "duration_days",
header: "Duration",
searchable: false,
render: (row) => (
<span className="text-sm font-medium text-[var(--text-primary)]">
{row.duration_days ? `${row.duration_days} days` : "-"}
</span>
),
},
{
key: "max_users_allowed",
header: "User Limit",
searchable: false,
render: (row) => (
<span className="text-sm font-medium text-[var(--text-primary)]">
{row.max_users_allowed == null ? "Unlimited" : row.max_users_allowed}
</span>
),
},
{
key: "status",
visibilityLabel: "Status",
header: (
<div className="flex items-center">
Status
<CustomColumnFilter
title="Status"
options={[
{ label: `Active (${planStatusCounts.get("active") ?? 0})`, value: "active" },
{ label: `Inactive (${planStatusCounts.get("inactive") ?? 0})`, value: "inactive" },
]}
selectedValues={statusFilter}
sortDirection={activeSort.column === "status" ? activeSort.direction : null}
enableSearch={false}
onApply={(values, direction) => {
setPage(1);
setStatusFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "status", direction));
}}
/>
</div>
),
searchable: false,
render: (row) => (
<CustomStatus
status={row.status === "active" ? "Active" : "Inactive"}
/>
),
},
{
key: "is_public",
visibilityLabel: "Visibility",
header: (
<div className="flex items-center">
Visibility
<CustomColumnFilter
title="Visibility"
options={[
{ label: `Public (${visibilityCounts.get("true") ?? 0})`, value: "true" },
{ label: `Private (${visibilityCounts.get("false") ?? 0})`, value: "false" },
]}
selectedValues={visibilityFilter.map((value) => String(value))}
sortDirection={activeSort.column === "is_public" ? activeSort.direction : null}
enableSearch={false}
onApply={(values, direction) => {
setPage(1);
setVisibilityFilter(values.map((value) => value === "true"));
setActiveSort(resolveColumnSortState(activeSort, "is_public", direction));
}}
/>
</div>
),
searchable: false,
render: (row) => (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
row.is_public
? "bg-blue-50 text-blue-700"
: "bg-gray-100 text-gray-600"
}`}
>
{row.is_public ? "Public" : "Private"}
</span>
),
},
{
key: "created_at",
header: "Created",
render: (row) => (
<div className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(row.created_at)}
</div>
),
},
{
key: "id",
header: "Action",
searchable: false,
render: (row) => (
<CustomActionMenu>
<ProtectedComponent requiredAccess="superadmin.plan.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> View Details
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="superadmin.plan.update">
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> Edit Plan
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="superadmin.plan.delete">
<CustomActionItem
onClick={() => openDelete(row)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 size={16} className="mr-2" /> Delete Plan
</CustomActionItem>
</ProtectedComponent>
</CustomActionMenu>
),
},
],
[
activeSort,
nameCounts,
nameFilter,
openDelete,
openEdit,
openView,
planStatusCounts,
statusFilter,
visibilityCounts,
visibilityFilter,
]
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">Subscription Plans</h1>
<p className="text-sm text-[var(--text-secondary)]">Create and manage different subscription tiers and their permissions.</p>
</div>
<ProtectedComponent requiredAccess="superadmin.plan.create">
<Link to="/subscriptions/add">
<CustomButton variant="primary">+ Add Plan</CustomButton>
</Link>
</ProtectedComponent>
</div>
{errorMessage ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
{errorMessage}
</div>
) : (
<CustomTable
isLoading={isLoading}
data={plans}
columns={columns}
columnVisibilityEnabled
columnVisibilityStorageKey="subscriptions-table-columns"
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
exportEnabled={false}
getRowId={(row) => row.id}
manualPagination
manualFiltering
totalRows={totalRows}
page={page}
pageSize={pageSize}
search={search}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
/>
)}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
title="Plan Details"
size="lg"
footer={
<CustomButton type="button" variant="outlined" onClick={closeView}>
Close
</CustomButton>
}
>
{selectedPlan ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Name
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.name}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Price
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatPrice(selectedPlan.price)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Duration
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.duration_days ? `${selectedPlan.duration_days} days` : "-"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
User Limit
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.max_users_allowed == null ? "Unlimited" : selectedPlan.max_users_allowed}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Status
</p>
<CustomStatus
status={
selectedPlan.status === "active" ? "Active" : "Inactive"
}
/>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Visibility
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.is_public ? "Public" : "Private"}
</p>
</div>
{selectedPlan.description && (
<div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Description
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.description}
</p>
</div>
)}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Created
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(selectedPlan.created_at)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Updated
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(selectedPlan.updated_at)}
</p>
</div>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)] mb-2">
Plan Permissions
</p>
{isDetailLoading ? (
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3">
<CustomLoader />
</div>
) : (
<GroupedAccessViewer accesses={viewAccesses} />
)}
</div>
</div>
) : (
<p className="text-sm text-[var(--text-secondary)]">
No plan selected.
</p>
)}
</CustomModal>
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
title="Edit Subscription Plan"
size="lg"
footer={
<>
<CustomButton
type="button"
variant="outlined"
onClick={closeEdit}
disabled={isSaving}
>
Cancel
</CustomButton>
<CustomButton
type="submit"
form="edit-plan-form"
variant="primary"
loading={isSaving}
>
Save Changes
</CustomButton>
</>
}
>
<form
id="edit-plan-form"
onSubmit={handleUpdate}
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Plan Name"
name="name"
placeholder="Enter plan name"
value={editForm.name}
onChange={handleEditChange}
required
/>
<CustomInput
label="Price"
name="price"
type="number"
placeholder="0.00"
value={editForm.price ?? ""}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
price: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
<CustomInput
label="Duration (Days)"
name="duration_days"
type="number"
placeholder="30"
value={editForm.duration_days ?? ""}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
duration_days: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
<CustomInput
label="Max Allowed Users"
name="max_users_allowed"
type="number"
placeholder="Unlimited"
value={editForm.max_users_allowed ?? ""}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
max_users_allowed: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
<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
label="Description"
name="description"
placeholder="Enter plan description"
value={editForm.description}
onChange={handleEditChange}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomDropdown
label="Status"
name="status"
value={editForm.status}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, status: e.target.value }))
}
options={[
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
]}
/>
<div className="flex items-end pb-1">
<CustomCheckBox
label="Publicly Visible"
name="is_public"
checked={editForm.is_public}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
is_public: e.target.checked,
}))
}
/>
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<GroupedAccessSelector
allAccesses={allAccesses}
selectedIds={editAccessIds}
onChange={setEditAccessIds}
isLoading={isAccessLoading}
title="Plan Permissions"
/>
</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>
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
onConfirm={handleDelete}
title="Delete subscription plan?"
description={
deleteError || "This plan will be permanently removed."
}
confirmText="Delete Plan"
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default AllSubscriptions;
-13
View File
@@ -1,13 +0,0 @@
import { Route, Routes } from "react-router-dom";
import AllSubscriptions from "./components/AllSubscriptions";
import AddSubscriptions from "./components/AddSubscriptions";
const SubscriptionsRoutes: React.FC = () => {
return (
<Routes>
<Route index element={<AllSubscriptions />} />
<Route path="add" element={<AddSubscriptions />} />
</Routes>
);
};
export default SubscriptionsRoutes;
@@ -1,69 +0,0 @@
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([]);
});
});
@@ -1,29 +0,0 @@
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 };
};
+15 -14
View File
@@ -1,5 +1,5 @@
// src/features/Tenants/TenantsApi.ts
import { apiClient } from "../../lib/apiClient";
import { buildQueryString } from "../../lib/queryParams";
import type {
ApiMessage,
Tenant,
@@ -8,6 +8,18 @@ import type {
TenantUpdateRequest,
} from "./TenantsTypes";
// Helper to build URL with query params
const buildQueryString = (params: Record<string, any>): string => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const query = searchParams.toString();
return query ? `?${query}` : "";
};
export const tenantsApi = {
getAll: () => apiClient.get<Tenant[]>("/api/tenant/get"),
@@ -25,32 +37,21 @@ 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;
search?: string;
is_active?: boolean | null;
filter_tenant_names?: string[];
filter_tenant_domains?: string[];
filter_plan_ids?: string[];
statuses?: boolean[];
sort_by?: "name" | "domain" | "status" | "plan";
sort_order?: "asc" | "desc";
}) => {
const queryString = buildQueryString({
page: params.page,
page_size: params.page_size,
search: params.search,
is_active: params.is_active,
filter_tenant_names: params.filter_tenant_names ?? undefined,
filter_tenant_domains: params.filter_tenant_domains ?? undefined,
filter_plan_ids: params.filter_plan_ids ?? undefined,
statuses: params.statuses ?? undefined,
sort_by: params.sort_by ?? undefined,
sort_order: params.sort_order ?? undefined,
});
return apiClient.get<TenantPaginatedResponse>(
`/api/tenant/list${queryString}`
);
},
};
};
+1 -24
View File
@@ -1,48 +1,25 @@
export type TenantStatus = "ACTIVE" | "INACTIVE" | "EXPIRED";
export type Tenant = {
id: string;
tenant_id: string;
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;
end_date?: string | null;
status: TenantStatus;
created_at: string;
updated_at: string;
};
export type ModuleEnvironmentAssignment = {
module_id: string;
environment_slug: string;
};
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;
status?: TenantStatus;
module_environments?: ModuleEnvironmentAssignment[];
is_active?: boolean;
};
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;
end_date?: string | null;
status?: TenantStatus;
};
export type TenantPaginatedResponse = {
@@ -1,129 +0,0 @@
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");
});
});
@@ -1,66 +0,0 @@
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,
});
+48 -310
View File
@@ -1,72 +1,27 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { CustomButton, CustomDatePicker, CustomInput, CustomDropdown } from "../../../components/custom";
import { CustomButton, CustomInput } from "../../../components/custom";
import CustomBackButton from "../../../components/custom/CustomBackButton";
import { CustomLoader } from "../../../components/custom";
import { tenantsApi } from "../TenantsApi";
import type { Tenant, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes";
import type { Tenant, TenantCreateRequest } from "../TenantsTypes";
import { useAuth } from "../../../context/AuthContext";
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
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);
const addDays = (dateValue: string, days?: number | null) => {
if (!dateValue || !days) return "";
const nextDate = new Date(`${dateValue}T00:00:00`);
nextDate.setDate(nextDate.getDate() + days);
return toIsoDate(nextDate);
};
const AddTenants = () => {
const today = new Date().toISOString().slice(0, 10);
const { hasAccess, isLoading: isAuthLoading } = useAuth();
const canCreateTenant = hasAccess("superadmin.tenant.create");
const navigate = useNavigate();
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("");
const [tenantStatus, setTenantStatus] = useState<TenantStatus>("ACTIVE");
const [moduleEnvAssignments, setModuleEnvAssignments] = useState<ModuleEnvironmentAssignment[]>([]);
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [isPlansLoading, setIsPlansLoading] = useState(false);
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
const [allModules, setAllModules] = useState<Module[]>([]);
const [moduleEnvironments, setModuleEnvironments] = useState<Record<string, ModuleEnvironment[]>>({});
const [loadingEnvironments, setLoadingEnvironments] = useState<Record<string, boolean>>({});
const [planModules, setPlanModules] = useState<{ module_id: string; module_name: string }[]>([]);
const [isPlanModulesLoading, setIsPlanModulesLoading] = useState(false);
const [formData, setFormData] = useState<TenantCreateRequest>({
tenant_name: "",
tenant_domain: "",
tenant_logo_url: "",
});
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
const [isTenantLoading, setIsTenantLoading] = useState(true);
const [tenantError, setTenantError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
if (!selectedPlanId) return;
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
if (!selectedPlan) return;
setStartDate((prev) => prev || today);
setEndDate(addDays(startDate || today, selectedPlan.duration_days));
}, [plans, selectedPlanId, startDate, today]);
useEffect(() => {
if (isAuthLoading || canCreateTenant) {
setIsTenantLoading(false);
@@ -79,133 +34,34 @@ const AddTenants = () => {
setIsTenantLoading(true);
try {
const data = await tenantsApi.getMine();
if (isMounted) setCurrentTenant(data);
if (isMounted) {
setCurrentTenant(data);
}
} catch (error) {
if (isMounted) {
setTenantError(
error instanceof Error ? error.message : "Unable to load tenant."
);
const message =
error instanceof Error
? error.message
: "Unable to load tenant.";
setTenantError(message);
}
} finally {
if (isMounted) setIsTenantLoading(false);
if (isMounted) {
setIsTenantLoading(false);
}
}
};
loadTenant();
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, [canCreateTenant, isAuthLoading]);
useEffect(() => {
if (!canCreateTenant) return;
let isMounted = true;
const loadData = async () => {
setIsPlansLoading(true);
try {
const [plansData, accessesData, modulesData] = await Promise.all([
subscriptionsApi.getAll({ status: "active" }),
subscriptionsApi.getAccesses(),
adminModuleApi.listModules(),
]);
if (isMounted) {
setPlans(plansData);
setAllAccesses(accessesData);
setAllModules(modulesData);
}
} catch (error) {
console.error("Failed to load data:", error);
} finally {
if (isMounted) setIsPlansLoading(false);
}
};
loadData();
return () => { isMounted = false; };
}, [canCreateTenant]);
useEffect(() => {
if (!selectedPlanId || allAccesses.length === 0 || allModules.length === 0) {
setPlanModules([]);
setModuleEnvAssignments([]);
return;
}
let isMounted = true;
const resolvePlanModules = async () => {
setIsPlanModulesLoading(true);
try {
const planDetail = await subscriptionsApi.getById(selectedPlanId);
const moduleAccessIdSet = new Set(planDetail.module_access_ids);
const moduleIdsFromPlan = new Set<string>();
allAccesses.forEach((access) => {
if (access.module_id && moduleAccessIdSet.has(access.id)) {
moduleIdsFromPlan.add(access.module_id);
}
});
const moduleMap = new Map(allModules.map((m) => [m.id, m]));
const resolvedModules: { module_id: string; module_name: string }[] = [];
moduleIdsFromPlan.forEach((modId) => {
const mod = moduleMap.get(modId);
if (mod) {
resolvedModules.push({ module_id: mod.id, module_name: mod.module_name });
}
});
if (isMounted) {
setPlanModules(resolvedModules);
const newAssignments: ModuleEnvironmentAssignment[] = [];
await Promise.all(
resolvedModules.map(async (mod) => {
if (!moduleEnvironments[mod.module_id]) {
setLoadingEnvironments((prev) => ({ ...prev, [mod.module_id]: true }));
try {
const envs = await adminModuleApi.listEnvironments(mod.module_id);
if (isMounted) {
setModuleEnvironments((prev) => ({ ...prev, [mod.module_id]: envs }));
const defaultEnv = envs.find((e) => e.is_default)?.slug || envs[0]?.slug || "";
newAssignments.push({ module_id: mod.module_id, environment_slug: defaultEnv });
}
} catch {
if (isMounted) {
setModuleEnvironments((prev) => ({ ...prev, [mod.module_id]: [] }));
newAssignments.push({ module_id: mod.module_id, environment_slug: "" });
}
} finally {
if (isMounted) setLoadingEnvironments((prev) => ({ ...prev, [mod.module_id]: false }));
}
} else {
const envs = moduleEnvironments[mod.module_id];
const defaultEnv = envs.find((e) => e.is_default)?.slug || envs[0]?.slug || "";
newAssignments.push({ module_id: mod.module_id, environment_slug: defaultEnv });
}
})
);
if (isMounted) setModuleEnvAssignments(newAssignments);
}
} catch (error) {
console.error("Failed to resolve plan modules:", error);
} finally {
if (isMounted) setIsPlanModulesLoading(false);
}
};
resolvePlanModules();
return () => { isMounted = false; };
}, [selectedPlanId, allAccesses, allModules]);
const handleEnvironmentChange = (moduleId: string, slug: string) => {
setModuleEnvAssignments((prev) =>
prev.map((a) => (a.module_id === moduleId ? { ...a, environment_slug: slug } : a))
);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (event: React.FormEvent) => {
@@ -214,17 +70,11 @@ const AddTenants = () => {
setIsLoading(true);
try {
const payload = buildTenantCreatePayload({
tenantName,
tenantDomain,
tenantLogoUrl,
billingEmail,
planId: selectedPlanId,
startDate,
endDate,
status: tenantStatus,
moduleEnvironments: moduleEnvAssignments,
});
const payload: TenantCreateRequest = {
tenant_name: formData.tenant_name.trim(),
tenant_domain: formData.tenant_domain.trim(),
tenant_logo_url: formData.tenant_logo_url?.trim() || undefined,
};
await tenantsApi.create(payload);
navigate("/tenants");
@@ -242,16 +92,16 @@ const AddTenants = () => {
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<CustomBackButton to="/tenants" tooltip="Back to Tenants" />
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
{canCreateTenant ? "Add Tenant" : "Tenant Details"}
</h1>
<p className="text-sm text-[var(--text-secondary)]">
{canCreateTenant
? "Create a new tenant with domain and subscription plan."
: "Your account is assigned to this tenant."}
</p>
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">
{canCreateTenant ? "Add Tenant" : "Tenant Details"}
</h1>
<p className="text-sm text-gray-500">
{canCreateTenant
? "Create a new tenant with domain and logo details."
: "Your account is assigned to this tenant."}
</p>
</div>
</div>
</div>
@@ -302,16 +152,16 @@ const AddTenants = () => {
label="Tenant Name"
name="tenant_name"
placeholder="Enter tenant name"
value={tenantName}
onChange={(e) => setTenantName(e.target.value)}
value={formData.tenant_name}
onChange={handleChange}
required
/>
<CustomInput
label="Tenant Domain"
name="tenant_domain"
placeholder="example.com"
value={tenantDomain}
onChange={(e) => setTenantDomain(e.target.value)}
value={formData.tenant_domain}
onChange={handleChange}
required
/>
</div>
@@ -320,131 +170,19 @@ const AddTenants = () => {
label="Tenant Logo URL"
name="tenant_logo_url"
placeholder="https://"
value={tenantLogoUrl}
onChange={(e) => setTenantLogoUrl(e.target.value)}
value={formData.tenant_logo_url}
onChange={handleChange}
/>
<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
label="Select Plan"
name="plan_id"
value={selectedPlanId}
onChange={(e) => setSelectedPlanId(e.target.value)}
options={plans.map((plan) => ({
label: `${plan.name}${plan.price != null ? `${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(plan.price)}` : ""}`,
value: plan.id,
}))}
placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"}
disabled={isPlansLoading}
required
/>
</div>
<div className="grid grid-cols-1 gap-6 border-t border-gray-200 pt-6 md:grid-cols-3">
<CustomDatePicker
label="Start Date"
value={startDate}
onChange={(e) => {
const nextStartDate = e.target.value;
setStartDate(nextStartDate);
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
setEndDate(addDays(nextStartDate, selectedPlan?.duration_days));
}}
/>
<CustomDatePicker
label="End Date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
min={startDate || undefined}
/>
<CustomDropdown
label="Status"
value={tenantStatus}
onChange={(e) => setTenantStatus(e.target.value as TenantStatus)}
options={[
{ label: "Active", value: "ACTIVE" },
{ label: "Inactive", value: "INACTIVE" },
{ label: "Expired", value: "EXPIRED" },
]}
/>
</div>
{selectedPlanId && (
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-1">Module Environments</h3>
<p className="text-sm text-gray-500 mb-4">
Select the environment for each module included in this plan.
</p>
{isPlanModulesLoading ? (
<div className="text-sm text-gray-500">Resolving plan modules...</div>
) : planModules.length === 0 ? (
<div className="text-sm text-gray-500 flex items-center gap-2">
<AlertCircle size={16} />
This plan has no module-level accesses configured.
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{planModules.map((mod) => {
const envs = moduleEnvironments[mod.module_id] || [];
const isLoadingEnv = loadingEnvironments[mod.module_id];
const assignment = moduleEnvAssignments.find((a) => a.module_id === mod.module_id);
return (
<div
key={mod.module_id}
className="p-4 rounded-lg border border-primary-200 bg-primary-50"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div>
<div className="font-medium text-gray-900">{mod.module_name}</div>
</div>
</div>
<div className="w-48">
<CustomDropdown
label=""
value={assignment?.environment_slug || ""}
onChange={(e) => handleEnvironmentChange(mod.module_id, e.target.value)}
options={envs.map((env) => ({
label: `${env.slug}${env.is_default ? " (default)" : ""}`,
value: env.slug,
}))}
placeholder={isLoadingEnv ? "Loading..." : "Select Environment"}
disabled={isLoadingEnv || envs.length === 0}
/>
{envs.length === 0 && !isLoadingEnv && (
<div className="text-xs text-red-500 mt-1">No environments found</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{errorMessage}
</div>
)}
<div className="flex justify-end pt-4">
<div className="flex justify-end">
<CustomButton type="submit" variant="primary" disabled={isLoading} loading={isLoading}>
Create & Provision Tenant
Create Tenant
</CustomButton>
</div>
</form>

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