Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4a3e666d9 |
@@ -1,2 +0,0 @@
|
||||
# Development environment
|
||||
VITE_API_BASE_URL=https://saas-dev.maskantech.in
|
||||
@@ -1,2 +0,0 @@
|
||||
# Local environment
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
@@ -1,2 +0,0 @@
|
||||
# Production environment
|
||||
VITE_API_BASE_URL=https://api.yourdomain.com
|
||||
@@ -1,4 +1,6 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
-255
@@ -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.
|
||||
@@ -19,8 +19,5 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Generated
+9
-1594
File diff suppressed because it is too large
Load Diff
+3
-20
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+5
-22
@@ -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",
|
||||
}
|
||||
),
|
||||
};
|
||||
};
|
||||
+2
-25
@@ -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
-1
@@ -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";
|
||||
+58
-31
@@ -2,7 +2,6 @@ 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";
|
||||
|
||||
@@ -14,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();
|
||||
@@ -27,25 +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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -65,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
|
||||
@@ -87,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}
|
||||
@@ -135,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't have an account? {""}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+4
-1
@@ -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 =
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,203 +1,28 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ExternalLink, Box, Search, Rocket } 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 { 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) => {
|
||||
try {
|
||||
const response = await moduleApi.launchModule(moduleId);
|
||||
|
||||
await fetch(response.target_url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...response.headers },
|
||||
body: JSON.stringify(response.payload),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
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"
|
||||
>
|
||||
<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;
|
||||
@@ -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;
|
||||
@@ -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,216 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } 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 { 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();
|
||||
}, []);
|
||||
|
||||
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 (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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 }
|
||||
),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import { buildQueryString } from "../../lib/queryParams";
|
||||
import type {
|
||||
Role,
|
||||
RoleCreateRequest,
|
||||
@@ -10,6 +9,17 @@ 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"),
|
||||
|
||||
@@ -31,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}`);
|
||||
},
|
||||
|
||||
@@ -14,8 +14,6 @@ export type RoleAccess = {
|
||||
category: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
module_id?: string;
|
||||
module_name?: string;
|
||||
};
|
||||
|
||||
export type RoleCreateRequest = {
|
||||
|
||||
@@ -140,32 +140,14 @@ const AddRoles = () => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Filter accessOptions to only show what the current user has access to
|
||||
const availableAccessOptions = useMemo(() => {
|
||||
if (canReadAllTenants) return accessOptions;
|
||||
|
||||
if (!user?.role?.accesses) return [];
|
||||
|
||||
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
accessOptions.forEach((option) => {
|
||||
if (user.role?.accesses.includes(option.access_code)) {
|
||||
let current: RoleAccess | undefined = option;
|
||||
while (current) {
|
||||
if (includedIds.has(current.id)) break;
|
||||
includedIds.add(current.id);
|
||||
|
||||
if (current.parent_id) {
|
||||
current = accessMap.get(current.parent_id);
|
||||
} else {
|
||||
current = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return accessOptions.filter((option) => includedIds.has(option.id));
|
||||
}, [accessOptions, user, canReadAllTenants]);
|
||||
// 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();
|
||||
@@ -196,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>
|
||||
|
||||
@@ -295,4 +277,4 @@ const AddRoles = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AddRoles;
|
||||
export default AddRoles;
|
||||
|
||||
@@ -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,32 +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(() => {
|
||||
if (canReadAllTenants) return accessOptions;
|
||||
if (
|
||||
!currentUser ||
|
||||
!currentUser.role ||
|
||||
!currentUser.role.accesses ||
|
||||
!Array.isArray(currentUser.role.accesses)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!currentUser?.role?.accesses) return [];
|
||||
|
||||
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
accessOptions.forEach((option) => {
|
||||
if (currentUser.role?.accesses.includes(option.access_code)) {
|
||||
let current: RoleAccess | undefined = option;
|
||||
while (current) {
|
||||
if (includedIds.has(current.id)) break;
|
||||
includedIds.add(current.id);
|
||||
|
||||
if (current.parent_id) {
|
||||
current = accessMap.get(current.parent_id);
|
||||
} else {
|
||||
current = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return accessOptions.filter((option) => includedIds.has(option.id));
|
||||
}, [accessOptions, currentUser, canReadAllTenants]);
|
||||
return accessOptions.filter((opt) =>
|
||||
currentUser.role!.accesses.includes(opt.access_code)
|
||||
);
|
||||
}, [accessOptions, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -129,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);
|
||||
@@ -147,7 +132,6 @@ const AllRoles = () => {
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
const requestId = ++latestRolesRequestRef.current;
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
@@ -156,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,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 });
|
||||
@@ -228,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);
|
||||
@@ -349,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),
|
||||
},
|
||||
{
|
||||
@@ -444,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">
|
||||
@@ -476,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
|
||||
@@ -503,6 +405,7 @@ const AllRoles = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Modal */}
|
||||
<CustomModal
|
||||
isOpen={isViewOpen}
|
||||
onClose={closeView}
|
||||
@@ -546,6 +449,7 @@ const AllRoles = () => {
|
||||
)}
|
||||
</CustomModal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<CustomModal
|
||||
isOpen={isEditOpen}
|
||||
onClose={closeEdit}
|
||||
@@ -554,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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -597,6 +501,7 @@ const AllRoles = () => {
|
||||
</form>
|
||||
</CustomModal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={closeDelete}
|
||||
@@ -611,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,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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,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,
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -4,45 +4,21 @@ import { Edit2, Eye, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomColumnFilter,
|
||||
CustomConfirmationModal,
|
||||
CustomDatePicker,
|
||||
CustomDropdown,
|
||||
CustomInput,
|
||||
CustomModal,
|
||||
CustomTable,
|
||||
CustomStatus,
|
||||
CustomLoader,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
} from "../../../components/custom";
|
||||
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 { Tenant, TenantStatus } from "../TenantsTypes";
|
||||
import type { Tenant, TenantUpdateRequest } from "../TenantsTypes";
|
||||
import { tenantsApi } from "../TenantsApi";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
||||
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
||||
import { buildTenantUpdatePayload } from "../buildTenantPayload";
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
resolveStoredTablePageSize,
|
||||
} from "../../../lib/tablePageSize";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// Local implementation of useDebounce
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
@@ -56,6 +32,7 @@ function useDebounce<T>(value: T, delay: number): T {
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// Local implementation of ProtectedComponent
|
||||
const ProtectedComponent: React.FC<{
|
||||
requiredAccess: string;
|
||||
children: React.ReactNode;
|
||||
@@ -67,17 +44,32 @@ const ProtectedComponent: React.FC<{
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
interface EditFormState {
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url: string;
|
||||
billing_email: string;
|
||||
is_active: boolean;
|
||||
plan_id: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: TenantStatus;
|
||||
}
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString;
|
||||
|
||||
// Check if the input likely contains specific time (ISO with T or explicit time chars)
|
||||
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("en-GB", options);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const AllTenants = () => {
|
||||
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
@@ -89,52 +81,34 @@ const AllTenants = () => {
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
const [editForm, setEditForm] = useState<EditFormState>({
|
||||
const [editForm, setEditForm] = useState({
|
||||
tenant_name: "",
|
||||
tenant_domain: "",
|
||||
tenant_logo_url: "",
|
||||
billing_email: "",
|
||||
is_active: true,
|
||||
plan_id: "",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
status: "ACTIVE",
|
||||
});
|
||||
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
|
||||
const [editError, setEditError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
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 [tenantNameFilter, setTenantNameFilter] = useState<string[]>([]);
|
||||
const [tenantDomainFilter, setTenantDomainFilter] = useState<string[]>([]);
|
||||
const [planFilter, setPlanFilter] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<boolean[]>([]);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [, setTotalPages] = useState(0);
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: "tenant_name" | "tenant_domain" | "plan_id" | "is_active" | null;
|
||||
direction: ColumnSortDirection;
|
||||
}>({ column: null, direction: null });
|
||||
const [allTenantsForCounts, setAllTenantsForCounts] = useState<Tenant[]>([]);
|
||||
|
||||
// Status filter state
|
||||
const [statusFilter, setStatusFilter] = useState<boolean | null>(null);
|
||||
|
||||
// Ref for the search input to restore focus
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track previous loading state to detect transition from loading → idle
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
const latestTenantsRequestRef = useRef(0);
|
||||
|
||||
// Debounce search to avoid excessive API calls
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -150,34 +124,21 @@ const AllTenants = () => {
|
||||
|
||||
try {
|
||||
if (canReadAll) {
|
||||
const requestId = ++latestTenantsRequestRef.current;
|
||||
// Use paginated API for server-side pagination
|
||||
const response = await tenantsApi.getPaginated({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: debouncedSearch || undefined,
|
||||
filter_tenant_names: tenantNameFilter,
|
||||
filter_tenant_domains: tenantDomainFilter,
|
||||
filter_plan_ids: planFilter,
|
||||
statuses: statusFilter,
|
||||
sort_by:
|
||||
activeSort.column === "tenant_name"
|
||||
? "name"
|
||||
: activeSort.column === "tenant_domain"
|
||||
? "domain"
|
||||
: activeSort.column === "plan_id"
|
||||
? "plan"
|
||||
: activeSort.column === "is_active"
|
||||
? "status"
|
||||
: undefined,
|
||||
sort_order: activeSort.direction ?? undefined,
|
||||
is_active: statusFilter,
|
||||
});
|
||||
|
||||
if (isMounted && requestId === latestTenantsRequestRef.current) {
|
||||
if (isMounted) {
|
||||
setTenants(response.items);
|
||||
setTotalRows(response.total);
|
||||
setTotalPages(response.total_pages);
|
||||
}
|
||||
} else {
|
||||
// For non-superadmin, show only their tenant
|
||||
const data = await tenantsApi.getMine();
|
||||
if (isMounted) {
|
||||
setTenants([data]);
|
||||
@@ -205,19 +166,9 @@ const AllTenants = () => {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [
|
||||
activeSort,
|
||||
canReadAll,
|
||||
debouncedSearch,
|
||||
isAuthLoading,
|
||||
page,
|
||||
pageSize,
|
||||
planFilter,
|
||||
statusFilter,
|
||||
tenantDomainFilter,
|
||||
tenantNameFilter,
|
||||
]);
|
||||
}, [canReadAll, isAuthLoading, page, pageSize, debouncedSearch, statusFilter]);
|
||||
|
||||
// Restore focus to search input after loading completes (only if user was searching)
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim() !== "") {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
@@ -225,86 +176,10 @@ const AllTenants = () => {
|
||||
prevLoadingRef.current = isLoading;
|
||||
}, [isLoading, search]);
|
||||
|
||||
// Reset to page 1 when search or status filter changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, tenantNameFilter, tenantDomainFilter, planFilter, statusFilter, activeSort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReadAll) return;
|
||||
|
||||
let isMounted = true;
|
||||
const loadAllTenants = async () => {
|
||||
try {
|
||||
const data = await tenantsApi.getAll();
|
||||
if (isMounted) setAllTenantsForCounts(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tenants for filters:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllTenants();
|
||||
return () => { isMounted = false; };
|
||||
}, [canReadAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReadAll && !hasAccess("superadmin.tenant.update")) return;
|
||||
|
||||
let isMounted = true;
|
||||
const loadPlans = async () => {
|
||||
try {
|
||||
const data = await subscriptionsApi.getAll();
|
||||
if (isMounted) setPlans(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load subscription plans:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadPlans();
|
||||
return () => { isMounted = false; };
|
||||
}, [canReadAll, hasAccess]);
|
||||
|
||||
const getPlanName = useCallback(
|
||||
(planId?: string | null) => {
|
||||
if (!planId) return "-";
|
||||
const found = plans.find((p) => p.id === planId);
|
||||
return found ? found.name : "-";
|
||||
},
|
||||
[plans]
|
||||
);
|
||||
|
||||
const tenantNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allTenantsForCounts.forEach((tenant) => {
|
||||
counts.set(tenant.tenant_name, (counts.get(tenant.tenant_name) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allTenantsForCounts]);
|
||||
|
||||
const tenantDomainCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allTenantsForCounts.forEach((tenant) => {
|
||||
counts.set(tenant.tenant_domain, (counts.get(tenant.tenant_domain) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allTenantsForCounts]);
|
||||
|
||||
const planCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allTenantsForCounts.forEach((tenant) => {
|
||||
if (!tenant.plan_id) return;
|
||||
counts.set(tenant.plan_id, (counts.get(tenant.plan_id) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allTenantsForCounts]);
|
||||
|
||||
const tenantStatusCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allTenantsForCounts.forEach((tenant) => {
|
||||
const key = tenant.is_active ? "true" : "false";
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allTenantsForCounts]);
|
||||
}, [debouncedSearch, statusFilter]);
|
||||
|
||||
const openView = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
@@ -313,20 +188,14 @@ const AllTenants = () => {
|
||||
|
||||
const openEdit = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setIsEditOpen(true);
|
||||
setEditError("");
|
||||
|
||||
setEditForm({
|
||||
tenant_name: tenant.tenant_name,
|
||||
tenant_domain: tenant.tenant_domain,
|
||||
tenant_logo_url: tenant.tenant_logo_url ?? "",
|
||||
billing_email: tenant.billing_email ?? "",
|
||||
is_active: tenant.is_active,
|
||||
plan_id: tenant.plan_id ?? "",
|
||||
start_date: tenant.start_date ?? "",
|
||||
end_date: tenant.end_date ?? "",
|
||||
status: tenant.status,
|
||||
});
|
||||
setEditError("");
|
||||
setIsEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const openDelete = useCallback((tenant: Tenant) => {
|
||||
@@ -371,39 +240,26 @@ const AllTenants = () => {
|
||||
event.preventDefault();
|
||||
if (!selectedTenant) return;
|
||||
|
||||
const tenantId = selectedTenant.id;
|
||||
|
||||
setEditError("");
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const payload = buildTenantUpdatePayload({
|
||||
tenantName: editForm.tenant_name,
|
||||
tenantDomain: editForm.tenant_domain,
|
||||
tenantLogoUrl: editForm.tenant_logo_url,
|
||||
billingEmail: editForm.billing_email,
|
||||
isActive: editForm.is_active,
|
||||
planId: editForm.plan_id,
|
||||
startDate: editForm.start_date,
|
||||
endDate: editForm.end_date,
|
||||
status: editForm.status,
|
||||
});
|
||||
|
||||
const updatedTenant = await tenantsApi.update(tenantId, payload);
|
||||
|
||||
if (!updatedTenant || !updatedTenant.id) {
|
||||
throw new Error("Invalid response from server");
|
||||
}
|
||||
const payload: TenantUpdateRequest = {
|
||||
tenant_name: editForm.tenant_name.trim(),
|
||||
tenant_domain: editForm.tenant_domain.trim(),
|
||||
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
|
||||
is_active: editForm.is_active,
|
||||
};
|
||||
|
||||
const updatedTenant = await tenantsApi.update(selectedTenant.id, payload);
|
||||
setTenants((prev) =>
|
||||
prev.map((tenant) =>
|
||||
tenant.id === tenantId ? updatedTenant : tenant
|
||||
tenant.id === updatedTenant.id ? updatedTenant : tenant
|
||||
)
|
||||
);
|
||||
setIsEditOpen(false);
|
||||
setSelectedTenant(null);
|
||||
} catch (error) {
|
||||
console.error("Update failed:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to update tenant.";
|
||||
setEditError(message);
|
||||
@@ -415,20 +271,17 @@ const AllTenants = () => {
|
||||
const handleDelete = async () => {
|
||||
if (!selectedTenant) return;
|
||||
|
||||
const tenantId = selectedTenant.id;
|
||||
|
||||
setDeleteError("");
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await tenantsApi.remove(tenantId);
|
||||
await tenantsApi.remove(selectedTenant.id);
|
||||
setTenants((prev) =>
|
||||
prev.filter((tenant) => tenant.id !== tenantId)
|
||||
prev.filter((tenant) => tenant.id !== selectedTenant.id)
|
||||
);
|
||||
setIsDeleteOpen(false);
|
||||
setSelectedTenant(null);
|
||||
} catch (error) {
|
||||
console.error("Delete failed:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to delete tenant.";
|
||||
setDeleteError(message);
|
||||
@@ -439,50 +292,8 @@ const AllTenants = () => {
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<Tenant>>>(
|
||||
() => [
|
||||
{
|
||||
key: "tenant_name",
|
||||
visibilityLabel: "Tenant Name",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Tenant Name
|
||||
{canReadAll ? (
|
||||
<CustomColumnFilter
|
||||
title="Tenant Name"
|
||||
options={buildColumnFilterOptions(tenantNameCounts.entries())}
|
||||
selectedValues={tenantNameFilter}
|
||||
sortDirection={activeSort.column === "tenant_name" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setTenantNameFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "tenant_name", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tenant_domain",
|
||||
visibilityLabel: "Domain",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Domain
|
||||
{canReadAll ? (
|
||||
<CustomColumnFilter
|
||||
title="Domain"
|
||||
options={buildColumnFilterOptions(tenantDomainCounts.entries())}
|
||||
selectedValues={tenantDomainFilter}
|
||||
sortDirection={activeSort.column === "tenant_domain" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setTenantDomainFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "tenant_domain", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "tenant_name", header: "Tenant Name" },
|
||||
{ key: "tenant_domain", header: "Domain" },
|
||||
{
|
||||
key: "tenant_logo_url",
|
||||
header: "Logo",
|
||||
@@ -498,85 +309,12 @@ const AllTenants = () => {
|
||||
<span className="text-(--text-secondary)"></span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "plan_id",
|
||||
visibilityLabel: "Plan",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Plan
|
||||
{canReadAll && plans.length > 0 ? (
|
||||
<CustomColumnFilter
|
||||
title="Plan"
|
||||
options={plans.map((plan) => ({
|
||||
label: `${plan.name} (${planCounts.get(plan.id) ?? 0})`,
|
||||
value: plan.id,
|
||||
}))}
|
||||
selectedValues={planFilter}
|
||||
sortDirection={activeSort.column === "plan_id" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setPlanFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "plan_id", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{getPlanName(row.plan_id)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
visibilityLabel: "Status",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
{canReadAll ? (
|
||||
<CustomColumnFilter
|
||||
title="Status"
|
||||
options={[
|
||||
{ label: `Active (${tenantStatusCounts.get("true") ?? 0})`, value: "true" },
|
||||
{ label: `Inactive (${tenantStatusCounts.get("false") ?? 0})`, value: "false" },
|
||||
]}
|
||||
selectedValues={statusFilter.map((value) => String(value))}
|
||||
sortDirection={activeSort.column === "is_active" ? activeSort.direction : null}
|
||||
enableSearch={false}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setStatusFilter(values.map((value) => value === "true"));
|
||||
setActiveSort(resolveColumnSortState(activeSort, "is_active", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
header: "Status",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomStatus status={row.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "start_date",
|
||||
header: "Start Date",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{row.start_date ? formatDate(row.start_date) : "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "end_date",
|
||||
header: "End Date",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{row.end_date ? formatDate(row.end_date) : "-"}
|
||||
</div>
|
||||
<CustomStatus status={row.is_active ? "Active" : "Inactive"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -624,31 +362,35 @@ const AllTenants = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
activeSort,
|
||||
canReadAll,
|
||||
getPlanName,
|
||||
openDelete,
|
||||
openEdit,
|
||||
openView,
|
||||
planCounts,
|
||||
planFilter,
|
||||
plans,
|
||||
statusFilter,
|
||||
tenantDomainCounts,
|
||||
tenantDomainFilter,
|
||||
tenantNameCounts,
|
||||
tenantNameFilter,
|
||||
tenantStatusCounts,
|
||||
]
|
||||
[openDelete, openEdit, openView]
|
||||
);
|
||||
|
||||
// Status filter control
|
||||
const filterControls = canReadAll ? (
|
||||
<select
|
||||
value={statusFilter === null ? "all" : statusFilter ? "active" : "inactive"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setStatusFilter(
|
||||
value === "all" ? null : value === "active" ? true : false
|
||||
);
|
||||
}}
|
||||
className="rounded-md border border-(--card-border) bg-(--card-bg) text-(--text-primary) text-sm py-1.5 px-2 focus:outline-none focus:ring focus:ring-blue-600"
|
||||
>
|
||||
<option value="all" className="bg-(--card-bg) text-(--text-primary)">All Statuses</option>
|
||||
<option value="active" className="bg-(--card-bg) text-(--text-primary)">Active</option>
|
||||
<option value="inactive" className="bg-(--card-bg) text-(--text-primary)">Inactive</option>
|
||||
</select>
|
||||
) : null;
|
||||
|
||||
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)]">Tenants</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Manage and monitor all tenant accounts in the system.</p>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">Tenants</h1>
|
||||
{/* <p className="text-sm text-[var(--text-secondary)]">
|
||||
{totalRows} Tenant{totalRows === 1 ? "" : "s"} in total
|
||||
</p> */}
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="superadmin.tenant.create">
|
||||
<Link to="/tenants/add">
|
||||
@@ -657,19 +399,18 @@ const AllTenants = () => {
|
||||
</ProtectedComponent>
|
||||
</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={tenants}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="tenants-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination={canReadAll}
|
||||
manualFiltering={canReadAll}
|
||||
@@ -681,6 +422,7 @@ const AllTenants = () => {
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
filterControls={filterControls}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -727,42 +469,12 @@ const AllTenants = () => {
|
||||
<p className="text-sm text-[var(--text-secondary)]"></p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Tenant ID
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] break-all">
|
||||
{selectedTenant.tenant_id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Status
|
||||
</p>
|
||||
<CustomStatus status={selectedTenant.status} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Subscription Plan
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{getPlanName(selectedTenant.plan_id)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Start Date
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.start_date ? formatDate(selectedTenant.start_date) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
End Date
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.end_date ? formatDate(selectedTenant.end_date) : "-"}
|
||||
{selectedTenant.is_active ? "Active" : "Inactive"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -845,16 +557,13 @@ const AllTenants = () => {
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Billing Email"
|
||||
name="billing_email"
|
||||
type="email"
|
||||
placeholder="finance@example.com"
|
||||
value={editForm.billing_email}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
|
||||
<div className="flex mt-6 items-center">
|
||||
{/* Checkbox component not found in standard custom, using explicit input or different component if needed,
|
||||
but keeping CustomSwitch or similar logic is safer. User had CustomCheckBox.
|
||||
I'll assume I need to replace it with simple input or use CustomSwitch if I saw it.
|
||||
I saw CustomSwitch in my files but not necessarily in the user's previous code unless I missed it.
|
||||
Actually, CustomCheckBox was imported from "Custom". I have "CustomSwitch".
|
||||
Let's use CustomSwitch for "is_active" as it's cleaner. */}
|
||||
<CustomCheckBox
|
||||
label="Active"
|
||||
name="is_active"
|
||||
@@ -863,73 +572,6 @@ const AllTenants = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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={editForm.plan_id}
|
||||
onChange={(e) => {
|
||||
const nextPlanId = e.target.value;
|
||||
const selectedPlan = plans.find((plan) => plan.id === nextPlanId);
|
||||
const nextStartDate = editForm.start_date || toIsoDate(new Date());
|
||||
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
plan_id: nextPlanId,
|
||||
start_date: nextStartDate,
|
||||
end_date: addDays(nextStartDate, selectedPlan?.duration_days),
|
||||
}));
|
||||
}}
|
||||
options={plans.map((plan) => ({
|
||||
label: plan.duration_days
|
||||
? `${plan.name} (${plan.duration_days} days)`
|
||||
: plan.name,
|
||||
value: plan.id,
|
||||
}))}
|
||||
placeholder="Select a subscription plan"
|
||||
/>
|
||||
</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={editForm.start_date}
|
||||
onChange={(e) => {
|
||||
const nextStartDate = e.target.value;
|
||||
const selectedPlan = plans.find((plan) => plan.id === editForm.plan_id);
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
start_date: nextStartDate,
|
||||
end_date: addDays(nextStartDate, selectedPlan?.duration_days),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<CustomDatePicker
|
||||
label="End Date"
|
||||
value={editForm.end_date}
|
||||
min={editForm.start_date || undefined}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, end_date: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
value={editForm.status}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
status: e.target.value as TenantStatus,
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ label: "Active", value: "ACTIVE" },
|
||||
{ label: "Inactive", value: "INACTIVE" },
|
||||
{ label: "Expired", value: "EXPIRED" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{editError}
|
||||
@@ -954,4 +596,4 @@ const AllTenants = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AllTenants;
|
||||
export default AllTenants;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user