diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..963a98f --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,255 @@ +# Console handover + +For the next developer. What this console was, what it is now, what changed, and +what is still waiting for somebody. + +Two companion documents sit one directory up: + +- `../REVIEW.md` — the assessment across both halves. §9 covers the console. +- `../SAAS_HARDENING.md` — the chronological log, Part 4 onwards. + +The API this talks to has its own `../backend/HANDOVER.md`. **Read that one +first** — most of what this console does is expose something the server enforces, +and the reasoning lives on the server side. + +--- + +## 1. In one paragraph + +React 19 + Vite + TypeScript, react-router, i18next with English and Arabic +(including RTL), Tailwind with CSS custom properties for theming. It was a +working admin console covering roughly a third of what the API could do, with no +tests. It now covers all of it, has 194 tests, and paints its first screen in +**507 kB against a 550 kB budget** — *lower* than before this work began, despite +eleven more screens. + +--- + +## 2. What was here before + +| Screen | State | +|---|---| +| Sign in / sign up / reset password | Present | +| Dashboard | Present | +| Profile | Present, without sessions or security | +| Tenants (workspaces) | Present | +| Subscriptions / plans | Present | +| Roles | Present | +| Users | Present | +| Theme | Present | +| Settings | A shell | +| Modules (admin) | Present | +| Logs | Present | + +**No tests at all.** No bundle budget. Both languages' translations were loaded +eagerly at startup. + +Everything in this repository is uncommitted on branch `furqan`. `git diff` shows +every change to a pre-existing file; `git ls-files --others --exclude-standard` +shows the 83 new ones. + +--- + +## 3. Screens added + +Each one exposes a capability the API gained. All are lazy-loaded. + +| Route | What it is | +|---|---| +| `/settings/api-keys` | Issue and revoke keys, with scopes. The secret is shown once. | +| `/settings/webhooks` | Endpoints, delivery history, HMAC secret rotation. | +| `/settings/sign-in` | Inbound SSO — connect the customer's Azure AD / Okta / Google. | +| `/settings/email` | Send from the workspace's own address. | +| `/settings/reference` | Reference lists — the things dropdowns are made of. | +| `/settings/organisation` | Org units, membership, scoped administration, **seats**. | +| `/documents` | The workspace's file library. | +| `/users/invitations` | Invite somebody rather than choosing their password. | +| `/operations` | What the background jobs have been doing. Superadmin only. | + +Added to existing screens: + +- **Profile** — `SessionsPanel` (see and end your sessions), `SecurityPanel` + (MFA enrolment), and `NotificationPreferencesPanel`. +- **Users** — `DeletedUsersPanel`, since deletion is now soft. +- **Header** — `NotificationBell`. +- **Layout** — `SubscriptionBanner`, warning before a subscription lapses. + +--- + +## 4. Conventions worth knowing before you change anything + +### `apiClient` is the only way to reach the API + +`src/lib/apiClient.ts`. It refreshes an expired access token and retries once. Do +not use `fetch` directly — a request that goes around it is the one request that +fails on an expired token, for a reason nobody can see. + +That includes file downloads: use `apiClient.blob`, which exists precisely so a +download is not the exception. + +Toast options: `toast: false` suppresses **both** success and error toasts; +there is no error-only mode. Where a silent failure would be dangerous, the +component reports it inline instead — see `NotificationPreferencesPanel`. + +### On create, absent; on edit, null + +The rule that turns form state into a request, stated once because getting it +wrong is invisible: + +- **Create** — "not set" is `undefined`, and the key is dropped. +- **Edit** — clearing a field means `null`, because `undefined` leaves the old + value in place and looks like the save did not work. + +This is not theoretical. The workspace edit form sent `undefined` for a cleared +billing address while sending `null` for a cleared logo two lines above, so **a +billing address could be set and never removed**. The rule now lives in +`buildTenantPayload` / `buildPlanPayload`, extracted from the components and +tested. + +### Translations: English is bundled, Arabic is fetched + +`src/i18n/config.ts`. Both languages used to be imported statically — 81 kB of +JSON, half of it in a language the visitor had not chosen. English is now the +bundled fallback and Arabic is fetched on demand, **before** switching rather +than after, because changing to a language whose bundles have not arrived renders +a screen of raw keys. + +**When you add a screen, add both `en/` and `ar/` files.** There is no test that +catches a missing Arabic key. + +### The bundle has a budget, and it ratchets + +```bash +npm run build && npm run check:size # fails over 550 kB first paint +``` + +It has fired once, at 536 kB, and the cause was the translations above rather +than any screen. It is there to make the cost visible at the moment it becomes +worth paying attention to, rather than in six months. + +--- + +## 5. Running it + +```bash +npm install +npm run dev # NOT `npm test` — that is the dev server in test mode +npm run test:unit # 194 tests +npm run lint +npm run build && npm run check:size +``` + +`npm test` runs Vite against the `test` environment. The unit suite is +`test:unit`. This is the repository's existing naming and I left it alone. + +--- + +## 6. Testing approach, and its limit + +194 tests across 20 files. They cover the api client, the route guard, the +permission gate, the payload every form builds, and the components where being +wrong is expensive — sessions, MFA enrolment, sign-in, notifications, documents, +API keys, invitations, operations, seats. + +**They are rule-level and component-level, not screen-level.** No screen is +rendered end to end against a real API. That was a deliberate trade: the payload +each form builds was the actual risk and is now tested, and rendering tests on +top would add little. + +Three things learned the hard way, in case you hit them: + +- `vi.fn().mockResolvedValue()` inside a hoisted `vi.mock` factory returns + `undefined`. Use a plain `async () => …`. +- Required fields render as `Label *`, so exact-string label queries miss. Use a + regex. +- Running this suite concurrently with the backend suite produces timeout + failures that are not real. Run them one at a time. + +--- + +## 7. A defect worth knowing about + +`CustomInput` had `htmlFor={props.id}` and **no caller passed an id**, so every +label in the product was decoration — not associated with its input, unusable +with a screen reader, and not clickable. Found while writing a test that could +not find a field by its label. + +Fixed with a `useId()` fallback. If you write a new input component, this is the +mistake to not repeat. + +--- + +## 8. Pending + +### 8.1 Nothing is blocked on the console + +Every API capability now has a screen. The check that found the last gap is worth +re-running whenever the API grows — it compares what the server exposes against +what the console actually calls: + +```bash +grep -rho '"/api/[a-z0-9/-]*' src/ | sort -u +``` + +Three capabilities were built on the server and had no screen for a while +precisely because nobody ran that comparison. It should run at the end of each +feature, not at the end of a batch. + +### 8.2 No screen is rendered end to end + +Stated above as a trade rather than an omission, but it is the honest next step +if you want more confidence than the current suite gives. + +### 8.3 Arabic has no parity check + +Every new key must be added to `en/` and `ar/` by hand. A test asserting the two +key sets match would catch what review does not. Not written. + +### 8.4 CI has never been run by GitHub Actions + +The repository has no remote. The workflow has been run locally step by step, +which is most of the value, but the YAML has only been parsed. + +### 8.5 Depends on the backend's pending items + +The console will not behave correctly until the server-side steps in +`../backend/HANDOVER.md` §7 are done — in particular the RLS role, without which +either everything or nothing is visible depending on which role the API connects +as. The operations page's audit-retention panel will read "not reporting" until +`AUDIT_RETENTION_DATABASE_URL` is set; that is the panel working, not failing. + +--- + +## 9. Where to look + +``` +src/ + lib/ + apiClient.ts Read first. Every request goes through here. + queryParams.ts Table filters ↔ URL. + tablePageSize.ts + routes/ + index.tsx All routes. Lazy imports at the top. + ProtectedRoutes.tsx The auth guard. + context/ + AuthContext.tsx Session, permissions, superadmin flag. + ThemeContext.tsx + i18n/ + config.ts English bundled, Arabic fetched. See §4. + locales/en/, ar/ + application/ + / + Page.tsx The screen. + Api.ts Its API calls. + 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//` 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. diff --git a/package-lock.json b/package-lock.json index 4ec5556..3e68743 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "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", @@ -16,6 +17,7 @@ "leaflet-control-geocoder": "^3.3.1", "leaflet.fullscreen": "^5.3.0", "lucide-react": "^0.562.0", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.50.0", @@ -27,6 +29,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -35,11 +40,71 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.1.1", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^3.2.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -71,6 +136,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -289,6 +355,161 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", + "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -862,6 +1083,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1774,6 +2013,130 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1791,18 +2154,27 @@ "version": "24.10.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1813,6 +2185,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1862,6 +2235,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -2103,12 +2477,128 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2143,11 +2633,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2166,6 +2664,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2200,6 +2718,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2231,6 +2759,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2245,6 +2774,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2268,6 +2807,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001764", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", @@ -2289,6 +2837,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2306,12 +2871,33 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2325,7 +2911,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2338,7 +2923,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2401,6 +2985,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2408,6 +3013,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2426,6 +3045,32 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2442,6 +3087,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2451,6 +3106,19 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2472,6 +3140,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -2485,6 +3159,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2503,6 +3190,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2600,6 +3294,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2768,6 +3463,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2778,6 +3483,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2936,6 +3651,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3083,6 +3807,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3111,6 +3848,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3169,6 +3907,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/input-format": { "version": "0.3.14", "resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz", @@ -3200,6 +3948,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3213,6 +3970,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3248,6 +4012,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3309,7 +4124,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/leaflet-control-geocoder": { "version": "3.3.1", @@ -3636,6 +4452,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3655,6 +4478,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3673,6 +4506,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3694,6 +4534,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3812,6 +4662,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3825,11 +4684,23 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3845,6 +4716,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3856,6 +4744,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3863,6 +4752,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3901,6 +4799,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3928,11 +4861,29 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3942,6 +4893,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4066,6 +5018,45 @@ "react-dom": "^18 || ^19" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4120,6 +5111,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4136,6 +5140,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -4165,6 +5175,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4174,6 +5191,59 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4187,6 +5257,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4200,6 +5290,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -4219,6 +5316,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4235,6 +5346,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -4267,6 +5454,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4299,11 +5487,20 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4361,6 +5558,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4430,6 +5628,103 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -4439,6 +5734,54 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4455,6 +5798,29 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4465,6 +5831,43 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4472,6 +5875,93 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4491,6 +5981,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 55e3a2b..990aeda 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,14 @@ "build:test": "tsc -b && vite build --mode test", "build:prod": "tsc -b && vite build --mode production", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:unit": "vitest run", + "test:watch": "vitest", + "check:size": "node scripts/check-bundle-size.mjs" }, "dependencies": { "@tailwindcss/vite": "^4.1.18", + "@types/qrcode": "^1.5.6", "axios": "^1.13.2", "i18next": "^25.7.4", "i18next-browser-languagedetector": "^8.2.0", @@ -24,6 +28,7 @@ "leaflet-control-geocoder": "^3.3.1", "leaflet.fullscreen": "^5.3.0", "lucide-react": "^0.562.0", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.50.0", @@ -35,6 +40,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -43,8 +51,10 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.1.1", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^3.2.7" } -} \ No newline at end of file +} diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs new file mode 100644 index 0000000..8528a46 --- /dev/null +++ b/scripts/check-bundle-size.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Fail the build if first paint grows past its budget. + * + * Route splitting is pinned by a test that reads the source, which catches a + * `lazy()` reverted to a plain import. It cannot catch the other way in: a + * dependency added to a shared component — a date library in a table cell, an + * icon pack in a layout — lands in the entry chunk and puts hundreds of + * kilobytes back into first paint with every test still green. + * + * `react-phone-number-input` is the cautionary example already in this + * repository: 459 kB of country metadata, more than the React runtime, for a + * component two admin screens use. It sits in its own chunk today. One import + * from a shared component is all it would take to move it. + * + * node scripts/check-bundle-size.mjs # check against the budget + * node scripts/check-bundle-size.mjs --report # print only, never fail + * + * Measures only what `index.html` asks for on load. Lazy chunks are the point of + * the exercise and are deliberately not counted. + */ + +import fs from "node:fs"; +import path from "node:path"; +import zlib from "node:zlib"; + +// First paint is 480 kB today (398 kB of JS, 82 kB of CSS). The budget leaves +// about 70 kB of headroom: enough that ordinary work — a component, a few +// strings, an icon — never trips it, tight enough that any library worth +// worrying about does. A budget with no headroom cries wolf, and a check people +// have learned to ignore is worse than no check. +// +// Raise it deliberately, and say here what grew, so the next person can tell a +// decision from a drift. +const BUDGET_KB = 550; + +const DIST = path.join(process.cwd(), "dist"); +const reportOnly = process.argv.includes("--report"); + +if (!fs.existsSync(DIST)) { + console.error("No dist/ — run `npm run build` first."); + process.exit(1); +} + +const html = fs.readFileSync(path.join(DIST, "index.html"), "utf8"); + +// Everything the document pulls in before anything is interactive: the entry +// script, its stylesheet, and whatever it preloads. +const assets = [ + ...html.matchAll(/(?:src|href)="\/?(assets\/[^"]+\.(?:js|css))"/g), +].map((match) => match[1]); + +if (assets.length === 0) { + console.error("Found no assets in dist/index.html — has the build changed?"); + process.exit(1); +} + +let raw = 0; +let gzipped = 0; +const rows = []; + +for (const asset of [...new Set(assets)]) { + const file = path.join(DIST, asset); + if (!fs.existsSync(file)) continue; + const contents = fs.readFileSync(file); + const gz = zlib.gzipSync(contents).length; + raw += contents.length; + gzipped += gz; + rows.push({ asset, kb: contents.length / 1024, gzipKb: gz / 1024 }); +} + +rows.sort((a, b) => b.kb - a.kb); + +console.log("First paint:"); +for (const row of rows) { + console.log( + ` ${row.asset.padEnd(44)} ${row.kb.toFixed(1).padStart(8)} kB` + + ` (gzip ${row.gzipKb.toFixed(1)} kB)` + ); +} + +const totalKb = raw / 1024; +console.log( + ` ${"total".padEnd(44)} ${totalKb.toFixed(1).padStart(8)} kB` + + ` (gzip ${(gzipped / 1024).toFixed(1)} kB)` +); + +if (reportOnly) process.exit(0); + +if (totalKb > BUDGET_KB) { + console.error( + `\nFirst paint is ${totalKb.toFixed(1)} kB, over the ${BUDGET_KB} kB budget.\n\n` + + "Something large has entered the initial payload. Usually that is a\n" + + "dependency imported by a shared component rather than by the screen that\n" + + "needs it — check the largest chunk above.\n\n" + + "If the growth is deliberate, raise BUDGET_KB in this file and say what\n" + + "grew, so the next person can tell a decision from a drift." + ); + process.exit(1); +} + +console.log(`\nWithin budget (${BUDGET_KB} kB).`); diff --git a/src/application/apikeys/ApiKeyApi.ts b/src/application/apikeys/ApiKeyApi.ts new file mode 100644 index 0000000..2d9e927 --- /dev/null +++ b/src/application/apikeys/ApiKeyApi.ts @@ -0,0 +1,27 @@ +import { apiClient } from "../../lib/apiClient"; +import type { ApiKeyCreateRequest, ApiKeyCreated, ApiKeyList } from "./ApiKeyTypes"; + +/** + * Keys the workspace automates with. + * + * There is no "get one" and no "show the key again": only a hash is stored, so + * the raw key exists in exactly one response and nowhere else. A convenience + * endpoint that returned it would undo the reason for hashing it. + */ +export const apiKeyApi = { + list: () => apiClient.get("/api/api-keys"), + + issue: (payload: ApiKeyCreateRequest) => + apiClient.post("/api/api-keys", payload, { + // No success toast: the response is a secret the person has to act on, and + // a cheerful "Created successfully" beside it invites dismissing the one + // dialog they must not dismiss. + toast: false, + }), + + revoke: (id: string) => + apiClient.delete(`/api/api-keys/${id}`, { + successMessage: "Key revoked", + errorMessage: "Could not revoke the key", + }), +}; diff --git a/src/application/apikeys/ApiKeyTypes.ts b/src/application/apikeys/ApiKeyTypes.ts new file mode 100644 index 0000000..38c89de --- /dev/null +++ b/src/application/apikeys/ApiKeyTypes.ts @@ -0,0 +1,36 @@ +export type ApiKeyState = "active" | "expired" | "revoked"; + +export type ApiKey = { + id: string; + name: string; + /** The visible half. Safe to show in a list or paste into a support ticket; + * useless as a credential on its own. */ + prefix: string; + /** Empty means "whatever the person who issued it can do" — the honest + * default for a first key, and exactly what pasting a password would give. */ + scopes: string[]; + user_id: string; + last_used_at?: string | null; + expires_at?: string | null; + revoked_at?: string | null; + created_at: string; + state: ApiKeyState; +}; + +export type ApiKeyCreated = { + api_key: ApiKey; + /** Shown once. Only a hash is stored, so there is no endpoint that can return + * it again — which is why the screen makes this hard to dismiss. */ + key: string; +}; + +export type ApiKeyList = { + items: ApiKey[]; + total: number; +}; + +export type ApiKeyCreateRequest = { + name: string; + scopes: string[]; + expires_in_days?: number | null; +}; diff --git a/src/application/apikeys/ApiKeysPage.test.tsx b/src/application/apikeys/ApiKeysPage.test.tsx new file mode 100644 index 0000000..86a48ee --- /dev/null +++ b/src/application/apikeys/ApiKeysPage.test.tsx @@ -0,0 +1,154 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import ApiKeysPage from "./ApiKeysPage"; + +/** + * The API keys screen. + * + * One behaviour matters more than the rest: **the key is shown once**. Only a + * hash is stored, so a dialog somebody closes by reflex is a credential they + * have to revoke and reissue — which is why the "Done" button is gated behind an + * acknowledgement rather than being a plain close. + * + * The other two are about the list telling the truth: revoked keys stay on it, + * because "this key was revoked in March" is the question asked after an + * incident; and an empty scope list means "everything its owner can do", which + * is a real default and not the same as nothing. + */ + +const list = vi.fn(); +const issue = vi.fn(); +const revoke = vi.fn(); + +vi.mock("./ApiKeyApi", () => ({ + apiKeyApi: { + list: () => list(), + issue: (payload: unknown) => issue(payload), + revoke: (id: string) => revoke(id), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options && typeof options.count === "number" + ? `${key}:${options.count}` + : key, + i18n: { language: "en" }, + }), +})); + +const key = (overrides: Record = {}) => ({ + id: "k1", + name: "nightly-import", + prefix: "a1b2c3d4e5f6", + scopes: [] as string[], + user_id: "u1", + last_used_at: null, + expires_at: null, + revoked_at: null, + created_at: "2026-01-01T00:00:00Z", + state: "active" as const, + ...overrides, +}); + +beforeEach(() => { + list.mockReset().mockResolvedValue({ items: [], total: 0 }); + issue.mockReset(); + revoke.mockReset().mockResolvedValue(null); +}); + +describe("the list", () => { + it("says what an empty scope list actually means", async () => { + // "Full access — whatever you can do" is a real default. An empty cell + // would read as "no permissions", which is the opposite. + list.mockResolvedValue({ items: [key()], total: 1 }); + render(); + + 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(); + + expect(await screen.findByText("nightly-import")).toBeTruthy(); + expect(screen.getByText("state.revoked")).toBeTruthy(); + // Nothing left to revoke, so the button goes rather than the row. + expect(screen.queryByText("revoke")).toBeNull(); + }); + + it("admits when it could not load", async () => { + list.mockRejectedValue(new Error("network")); + render(); + + 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(); + + await user.click(await screen.findByText("issue")); + await user.type(await screen.findByLabelText(/form\.name/), "nightly"); + await user.click(screen.getByText("form.submit")); + + const shown = await screen.findByText("sk_a1b2c3d4e5f6_secret"); + expect(shown).toBeTruthy(); + + const done = screen.getByText("created.done"); + expect(done.closest("button")).toBeDisabled(); + + await user.click(screen.getByLabelText("created.acknowledge")); + expect(done.closest("button")).not.toBeDisabled(); + }); + + it("cannot be submitted without a name", async () => { + // A key nobody can identify is one nobody dares revoke. + const user = userEvent.setup({ delay: null }); + render(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/application/apikeys/ApiKeysPage.tsx b/src/application/apikeys/ApiKeysPage.tsx new file mode 100644 index 0000000..9fb3f0f --- /dev/null +++ b/src/application/apikeys/ApiKeysPage.tsx @@ -0,0 +1,317 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Copy, KeyRound, Plus, Trash2 } from "lucide-react"; + +import { + CustomButton, + CustomConfirmationModal, + CustomInput, + CustomLoader, + CustomModal, +} from "../../components/custom"; +import { formatDate } from "../../lib/dateFormat"; +import { apiKeyApi } from "./ApiKeyApi"; +import type { ApiKey } from "./ApiKeyTypes"; + +/** + * The keys a workspace automates with. + * + * Three things this screen is responsible for that the API cannot enforce: + * + * - **The key is shown once.** Only a hash is stored, so a dialog somebody + * closes by reflex is a key they have to revoke and reissue. It is gated + * behind an acknowledgement for the same reason recovery codes are. + * - **Revoked keys stay listed.** "This key was revoked in March" is the + * question asked after an incident, and a list that quietly drops them cannot + * answer it. + * - **What a key can do is visible.** An empty scope list means "everything its + * owner can do", which is a real and reasonable default — but it is not + * *nothing*, and showing an empty cell would read as if it were. + */ + +const NewKeyDialog: React.FC<{ value: string; onDone: () => void }> = ({ + value, + onDone, +}) => { + const { t } = useTranslation(["apikeys", "common"]); + const [acknowledged, setAcknowledged] = useState(false); + + return ( +
+

{t("created.explain")}

+ +
+ + {value} + + void navigator.clipboard?.writeText(value)} + > + + +
+ + + + + {t("created.done")} + +
+ ); +}; + +const ApiKeysPage: React.FC = () => { + const { t, i18n } = useTranslation(["apikeys", "common"]); + + const [keys, setKeys] = useState([]); + 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(null); + const [pendingRevoke, setPendingRevoke] = useState(null); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + const list = await apiKeyApi.list(); + setKeys(list.items); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const issue = async () => { + setError(""); + setIsBusy(true); + try { + const created = await apiKeyApi.issue({ + name: name.trim(), + // Empty on purpose: a first key that can do what its owner can do is + // what a customer would otherwise achieve by pasting a password. + // Narrowing is a later, deliberate step. + scopes: [], + expires_in_days: expiresInDays ? Number(expiresInDays) : null, + }); + setIsCreateOpen(false); + setName(""); + setExpiresInDays(""); + setIssued(created.key); + await load(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const revoke = async () => { + if (!pendingRevoke) return; + const target = pendingRevoke; + setPendingRevoke(null); + try { + await apiKeyApi.revoke(target.id); + await load(); + } catch { + // The toast has already said so; the list is reloaded either way so the + // screen never disagrees with the server about what is live. + await load(); + } + }; + + const stateBadge = (key: ApiKey) => { + const styles: Record = { + 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 ( + + {t(`state.${key.state}`)} + + ); + }; + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + setIsCreateOpen(true)}> + + {t("issue")} + +
+ +
+ {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : keys.length === 0 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
    + {keys.map((key) => ( +
  • +
    +
    + + {key.name} + + {stateBadge(key)} +
    +

    + sk_{key.prefix}… +

    +

    + {[ + 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(" · ")} +

    +
    + + {/* Revoked keys keep their row — see the file comment — so the + button goes rather than the entry. */} + {key.state !== "revoked" && ( + setPendingRevoke(key)} + > + + {t("revoke")} + + )} +
  • + ))} +
+ )} +
+ + { + setIsCreateOpen(false); + setError(""); + }} + title={t("issue")} + > +
+ setName(event.target.value)} + /> + + setExpiresInDays(event.target.value)} + /> + +

+ {t("form.scopeNote")} +

+ + {error &&

{error}

} + + + {t("form.submit")} + +
+
+ + setIssued(null)} + title={t("created.title")} + > + {issued && setIssued(null)} />} + + + setPendingRevoke(null)} + onConfirm={revoke} + title={t("confirmRevoke.title")} + description={t("confirmRevoke.message", { name: pendingRevoke?.name ?? "" })} + confirmText={t("revoke")} + /> +
+ ); +}; + +export default ApiKeysPage; diff --git a/src/application/authentication/AuthApi.ts b/src/application/authentication/AuthApi.ts index 3d6560f..041faa6 100644 --- a/src/application/authentication/AuthApi.ts +++ b/src/application/authentication/AuthApi.ts @@ -1,5 +1,6 @@ import { apiClient } from "../../lib/apiClient"; import type { AuthUser, SigninRequest, SignupRequest, TokenResponse } from "./AuthTypes"; +import type { UserSession } from "../profile/ProfileTypes"; type AuthApiOptions = { tenantId?: string; @@ -71,4 +72,22 @@ export const authApi = { refresh: () => apiClient.post("/api/auth/refresh", {}, { silent: true }), + + listSessions: () => apiClient.get("/api/auth/sessions"), + + endSession: (sessionId: string) => + apiClient.delete<{ message: string }>(`/api/auth/sessions/${sessionId}`, { + successMessage: "Session ended", + errorMessage: "Failed to end session", + }), + + endOtherSessions: () => + apiClient.post<{ message: string; ended: number }>( + "/api/auth/sessions/revoke-others", + null, + { + successMessage: "Signed out everywhere else", + errorMessage: "Failed to sign out other sessions", + } + ), }; \ No newline at end of file diff --git a/src/application/authentication/AuthTypes.ts b/src/application/authentication/AuthTypes.ts index b0282f8..2deecc7 100644 --- a/src/application/authentication/AuthTypes.ts +++ b/src/application/authentication/AuthTypes.ts @@ -16,8 +16,24 @@ export type SubscriptionDetails = { plan_name?: string | null; start_date?: string | null; end_date?: string | null; + /** The *stored* status: an administrative decision. */ status?: string | null; is_active?: boolean | null; + + /** + * The *derived* state, from the single lifecycle authority on the server. + * ACTIVE | GRACE | EXPIRED | CANCELLED | SUSPENDED | NONE. + */ + state?: string | null; + can_sign_in?: boolean | null; + /** False during grace: the workspace is read-only, not locked out. */ + can_write?: boolean | null; + grace_until?: string | null; + grace_period_days?: number | null; + + seats_used?: number | null; + seats_remaining?: number | null; + seats_over_limit?: boolean | null; }; export type AuthUser = { @@ -27,6 +43,12 @@ export type AuthUser = { last_name?: string | null; phone_number?: string | null; status?: string; + /** + * Platform superadmin — an explicit property of the account, never inferred + * from a missing tenant_id. The backend has returned this since finding S-1; + * the frontend had no field for it, so the console could not tell. + */ + is_superadmin?: boolean; tenant_id?: string | null; tenant_name?: string | null; tenant_logo_url?: string | null; @@ -41,6 +63,9 @@ export type SigninRequest = { email: string; password: string; remember_me?: boolean; + // Absent on the first attempt: a client cannot know a factor is required + // until the server says so, which is what the 401 with X-MFA-Required is for. + mfa_code?: string; }; export type SignupRequest = { diff --git a/src/application/authentication/Components/SignInForm.test.tsx b/src/application/authentication/Components/SignInForm.test.tsx new file mode 100644 index 0000000..ec95f1d --- /dev/null +++ b/src/application/authentication/Components/SignInForm.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SignInForm from "./SignInForm"; +import { ApiError } from "../../../lib/apiClient"; + +/** + * The second-factor step on the sign-in form. + * + * The interesting behaviour is that the code field is *not* there until the + * server asks for it: prompting everybody for something almost none of them have + * is how a sign-in page teaches people to ignore it. And the trigger is a + * header, not the wording of an error — matching on English prose sent over a + * network boundary breaks the day somebody improves the message. + */ + +const login = vi.fn(); +const navigate = vi.fn(); + +vi.mock("../../../context/AuthContext", () => ({ + useAuth: () => ({ login }), +})); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual( + "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( + + + + ); + +const signIn = async (user: ReturnType) => { + await user.type(screen.getByLabelText(/email/i), "person@example.com"); + await user.type(screen.getByLabelText(/^password/i), "CorrectHorse!9"); + await user.click(screen.getByRole("button", { name: /sign in/i })); +}; + +beforeEach(() => { + login.mockReset(); + navigate.mockReset(); +}); + +describe("the second-factor step", () => { + it("does not ask for a code until the server does", async () => { + const user = userEvent.setup({ delay: null }); + login.mockResolvedValue(undefined); + renderForm(); + + expect(screen.queryByLabelText(/verification code/i)).toBeNull(); + + await signIn(user); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith("/dashboard")); + expect(screen.queryByLabelText(/verification code/i)).toBeNull(); + }); + + it("reveals the code field when the server asks for one", async () => { + const user = userEvent.setup({ delay: null }); + login.mockRejectedValueOnce(mfaRequired()); + renderForm(); + + await signIn(user); + + expect(await screen.findByLabelText(/verification code/i)).toBeTruthy(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("does not show being asked for a code as an error", async () => { + // The password was right. Telling somebody "Invalid credentials" while + // showing them a code field is a contradiction they cannot resolve. + const user = userEvent.setup({ delay: null }); + login.mockRejectedValueOnce(mfaRequired()); + renderForm(); + + await signIn(user); + + await screen.findByLabelText(/verification code/i); + expect( + screen.queryByText(/A verification code is required/i) + ).toBeNull(); + }); + + it("sends the code on the second attempt", async () => { + const user = userEvent.setup({ delay: null }); + login.mockRejectedValueOnce(mfaRequired()); + login.mockResolvedValueOnce(undefined); + renderForm(); + + await signIn(user); + await user.type(await screen.findByLabelText(/verification code/i), "123456"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith("/dashboard")); + expect(login).toHaveBeenLastCalledWith( + expect.objectContaining({ + email: "person@example.com", + password: "CorrectHorse!9", + mfa_code: "123456", + }), + false + ); + }); + + it("clears a spent code so the next attempt is not confusing", async () => { + // Leaving a wrong code in the field means the next attempt fails for a + // reason the person cannot see. + const user = userEvent.setup({ delay: null }); + login.mockRejectedValueOnce(mfaRequired()); + login.mockRejectedValueOnce(refused()); + renderForm(); + + await signIn(user); + const field = await screen.findByLabelText(/verification code/i); + await user.type(field, "000000"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => + expect(screen.getByLabelText(/verification code/i)).toHaveValue("") + ); + expect(screen.getByText(/invalid credentials/i)).toBeTruthy(); + }); + + it("shows an ordinary failure as an error and asks for nothing", async () => { + const user = userEvent.setup({ delay: null }); + login.mockRejectedValueOnce(refused()); + renderForm(); + + await signIn(user); + + expect(await screen.findByText(/invalid credentials/i)).toBeTruthy(); + expect(screen.queryByLabelText(/verification code/i)).toBeNull(); + }); +}); diff --git a/src/application/authentication/Components/SignInForm.tsx b/src/application/authentication/Components/SignInForm.tsx index 48e473f..ae5a874 100644 --- a/src/application/authentication/Components/SignInForm.tsx +++ b/src/application/authentication/Components/SignInForm.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/custom"; +import { ApiError } from "../../../lib/apiClient"; import type { SigninRequest } from "../AuthTypes"; import { useAuth } from "../../../context/AuthContext"; @@ -13,6 +14,10 @@ export default function SignInForm() { const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + // Revealed only once the server says a factor is needed. Asking for a code + // up front would prompt every person for something almost none of them have. + const [mfaRequired, setMfaRequired] = useState(false); + const [mfaCode, setMfaCode] = useState(""); const { login } = useAuth(); @@ -24,6 +29,7 @@ export default function SignInForm() { const payload: SigninRequest = { email, password, + ...(mfaCode ? { mfa_code: mfaCode } : {}), }; try { @@ -31,11 +37,22 @@ export default function SignInForm() { navigate("/dashboard"); } catch (error) { - const message = - error instanceof Error - ? error.message - : "Unable to sign in. Please try again."; - setErrorMessage(message); + // The server distinguishes "wrong credentials" from "right credentials, + // code still needed" with a header rather than with different prose, so + // this does not depend on matching an English string over the wire. + if (error instanceof ApiError && error.mfaRequired) { + setMfaRequired(true); + setErrorMessage(""); + } else { + // A wrong code clears itself: leaving a spent one in the field means + // the next attempt fails for a reason the person cannot see. + setMfaCode(""); + const message = + error instanceof Error + ? error.message + : "Unable to sign in. Please try again."; + setErrorMessage(message); + } } finally { setIsLoading(false); } @@ -77,6 +94,21 @@ export default function SignInForm() { className="!text-gray-900" /> + {mfaRequired && ( + setMfaCode(event.target.value)} + className="!text-gray-900" + /> + )} + {errorMessage && (

{errorMessage} diff --git a/src/application/documents/DocumentsPage.tsx b/src/application/documents/DocumentsPage.tsx new file mode 100644 index 0000000..cdbea3a --- /dev/null +++ b/src/application/documents/DocumentsPage.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { useAuth } from "../../context/AuthContext"; +import DocumentsPanel from "./DocumentsPanel"; + +/** + * The workspace's own document library. + * + * A place for the files that belong to the workspace rather than to any one + * record — a handbook, a policy, a signed agreement. It is the panel with the + * workspace as its subject, and the panel is the reusable part: any screen that + * decides attachments belong on its record drops the same component in with a + * different `entityType` and `entityId`. + * + * There is deliberately no "all documents everywhere" view. A listing with no + * subject is a dump, and the API refuses to serve one for that reason. + */ +const DocumentsPage: React.FC = () => { + const { t } = useTranslation(["documents", "common"]); + const { user } = useAuth(); + + if (!user?.tenant_id) { + return ( +

+ {t("noWorkspace")} +

+ ); + } + + return ( +
+
+

+ {t("pageTitle")} +

+

+ {t("pageSubtitle")} +

+
+ + +
+ ); +}; + +export default DocumentsPage; diff --git a/src/application/documents/DocumentsPanel.test.tsx b/src/application/documents/DocumentsPanel.test.tsx new file mode 100644 index 0000000..5badc2a --- /dev/null +++ b/src/application/documents/DocumentsPanel.test.tsx @@ -0,0 +1,185 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import DocumentsPanel from "./DocumentsPanel"; + +/** + * The attachments panel. + * + * The behaviour worth pinning is what it does *before* the network: an oversized + * file is refused here as well as on the server, because finding out after the + * upload has crossed the wire is a slow way to learn a number the screen already + * has. And the quota warning has to appear before somebody picks a file, not + * after. + * + * The download goes through the API client rather than a raw fetch, which is + * what keeps it on the same refresh-an-expired-token path as every other + * request — a download that alone fails on a stale token would fail for a reason + * nobody could see. + */ + +const get = vi.fn(); +const post = vi.fn(); +const del = vi.fn(); +const blob = vi.fn(); + +vi.mock("../../lib/apiClient", () => ({ + apiClient: { + get: (path: string, options?: unknown) => get(path, options), + post: (path: string, body?: unknown, options?: unknown) => + post(path, body, options), + delete: (path: string, options?: unknown) => del(path, options), + blob: (path: string) => blob(path), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options && options.limit ? `${key}:${options.limit}` : key, + i18n: { language: "en" }, + }), +})); + +const document_ = (overrides: Record = {}) => ({ + 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 = {}) => ({ + 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 = () => ; + +describe("the list", () => { + it("asks only for this record's attachments", async () => { + render(panel()); + await waitFor(() => expect(get).toHaveBeenCalled()); + + const paths = get.mock.calls.map((call) => String(call[0])); + const listPath = paths.find((path) => !path.includes("/usage")); + + expect(listPath).toBeDefined(); + expect(listPath).toContain("entity_type=workspace"); + expect(listPath).toContain("entity_id=w1"); + }); + + it("shows what is attached", async () => { + withData([document_()]); + render(panel()); + expect(await screen.findByText("contract.pdf")).toBeTruthy(); + }); + + it("says what it accepts when there is nothing yet", async () => { + render(panel()); + expect(await screen.findByText("accepted")).toBeTruthy(); + }); + + it("admits when it could not load", async () => { + get.mockRejectedValue(new Error("network")); + render(panel()); + expect(await screen.findByText("errors.loadFailed")).toBeTruthy(); + }); +}); + +describe("the quota", () => { + it("warns before somebody picks a file, not after", async () => { + withData([], usage({ used_bytes: 999_000, quota_bytes: 1_000_000 })); + render(panel()); + expect(await screen.findByText("nearlyFull")).toBeTruthy(); + }); + + it("stays quiet when there is room", async () => { + withData([], usage()); + render(panel()); + await waitFor(() => expect(get).toHaveBeenCalled()); + expect(screen.queryByText("nearlyFull")).toBeNull(); + }); +}); + +describe("uploading", () => { + it("refuses an oversized file without asking the server", async () => { + const user = userEvent.setup({ delay: null }); + withData([], usage({ max_upload_bytes: 10 })); + const { container } = render(panel()); + await waitFor(() => expect(get).toHaveBeenCalled()); + + const input = container.querySelector('input[type="file"]')!; + await user.upload( + input as HTMLInputElement, + new File(["x".repeat(500)], "big.pdf", { type: "application/pdf" }) + ); + + expect(await screen.findByText(/errors\.tooLarge/)).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); + + it("sends the file with the record it belongs to", async () => { + const user = userEvent.setup({ delay: null }); + render(panel()); + await waitFor(() => expect(get).toHaveBeenCalled()); + + const input = document.querySelector('input[type="file"]')!; + await user.upload( + input as HTMLInputElement, + new File(["x"], "note.pdf", { type: "application/pdf" }) + ); + + await waitFor(() => expect(post).toHaveBeenCalled()); + const [path, body] = post.mock.calls[0]; + expect(path).toBe("/api/documents"); + expect((body as FormData).get("entity_type")).toBe("workspace"); + expect((body as FormData).get("entity_id")).toBe("w1"); + }); +}); + +describe("downloading", () => { + it("goes through the API client rather than around it", async () => { + const user = userEvent.setup({ delay: null }); + withData([document_()]); + // jsdom has neither of these. + const createObjectURL = vi.fn().mockReturnValue("blob:x"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL }); + + render(panel()); + await screen.findByText("contract.pdf"); + + const buttons = screen.getAllByRole("button"); + // The download is the first control on the row. + await user.click(buttons[buttons.length - 2]); + + await waitFor(() => + expect(blob).toHaveBeenCalledWith("/api/documents/d1/content") + ); + // Revoked immediately: leaving these leaks on the tab nobody reloads. + expect(revokeObjectURL).toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/application/documents/DocumentsPanel.tsx b/src/application/documents/DocumentsPanel.tsx new file mode 100644 index 0000000..b49c0b4 --- /dev/null +++ b/src/application/documents/DocumentsPanel.tsx @@ -0,0 +1,285 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Download, FileText, Trash2, Upload } from "lucide-react"; + +import { + CustomButton, + CustomConfirmationModal, + CustomLoader, +} from "../../components/custom"; +import { apiClient } from "../../lib/apiClient"; +import { formatDate } from "../../lib/dateFormat"; + +/** + * Attachments on one record. + * + * Written as a panel rather than a page so any screen can drop it in with the + * record it belongs to — `entityType="tenant"`, `entityId={workspace.id}` — and + * so the one screen that exists today is not the only place it can ever live. + * + * ## Downloading + * + * A plain `` would not carry the session, so the bytes are fetched + * through the API client — which is what keeps a download on the same + * refresh-an-expired-token path as everything else — turned into an object URL, + * and handed to a click. A failure is then an error message rather than a + * browser showing its own JSON. + * + * The object URL is revoked immediately afterwards. Leaving them is a leak that + * only shows up on a long-lived tab, which is exactly the tab nobody reloads. + */ + +export type DocumentSummary = { + id: string; + filename: string; + content_type: string; + size_bytes: number; + description?: string | null; + created_at: string; +}; + +type Usage = { + used_bytes: number; + quota_bytes: number; + max_upload_bytes: number; +}; + +const readableSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + const units = ["kB", "MB", "GB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; +}; + +const DocumentsPanel: React.FC<{ + entityType: string; + entityId: string; + /** Shown above the list. Omitted, the panel is just the list. */ + title?: string; +}> = ({ entityType, entityId, title }) => { + const { t, i18n } = useTranslation(["documents", "common"]); + + const [items, setItems] = useState([]); + const [usage, setUsage] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(""); + const [pendingDelete, setPendingDelete] = useState(null); + + const fileInput = useRef(null); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + const [documents, room] = await Promise.all([ + apiClient.get( + `/api/documents?entity_type=${encodeURIComponent(entityType)}` + + `&entity_id=${encodeURIComponent(entityId)}`, + { toast: false } + ), + apiClient.get("/api/documents/usage", { toast: false }), + ]); + setItems(documents); + setUsage(room); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }, [entityType, entityId]); + + useEffect(() => { + void load(); + }, [load]); + + const upload = async (file: File) => { + setError(""); + + // Checked here as well as on the server, because finding out after the + // upload has crossed the network is a slow way to learn a number we already + // know. + if (usage && file.size > usage.max_upload_bytes) { + setError( + t("errors.tooLarge", { limit: readableSize(usage.max_upload_bytes) }) + ); + return; + } + + setIsUploading(true); + try { + const body = new FormData(); + body.append("file", file); + body.append("entity_type", entityType); + body.append("entity_id", entityId); + + await apiClient.post("/api/documents", body, { + successMessage: t("uploaded"), + errorMessage: t("errors.uploadFailed"), + }); + await load(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsUploading(false); + if (fileInput.current) fileInput.current.value = ""; + } + }; + + const download = async (document_: DocumentSummary) => { + setError(""); + try { + const blob = await apiClient.blob( + `/api/documents/${document_.id}/content` + ); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = document_.filename; + anchor.click(); + // Immediately. Leaving these is a leak that only shows on a long-lived + // tab, which is exactly the tab nobody reloads. + URL.revokeObjectURL(url); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } + }; + + const remove = async () => { + if (!pendingDelete) return; + const target = pendingDelete; + setPendingDelete(null); + try { + await apiClient.delete(`/api/documents/${target.id}`, { + successMessage: t("deleted"), + errorMessage: t("errors.generic"), + }); + } finally { + await load(); + } + }; + + const nearlyFull = + usage !== null && usage.used_bytes / usage.quota_bytes > 0.9; + + return ( +
+
+
+

+ {title ?? t("title")} +

+ {usage && ( +

+ {t("usage", { + used: readableSize(usage.used_bytes), + quota: readableSize(usage.quota_bytes), + })} +

+ )} +
+ +
+ { + const file = event.target.files?.[0]; + if (file) void upload(file); + }} + /> + fileInput.current?.click()} + disabled={isUploading} + > + + {t("upload")} + +
+
+ + {/* A running-low quota is the useful signal: somebody about to attach a + large file should know before they pick it, not after. */} + {nearlyFull && ( +
+ {t("nearlyFull")} +
+ )} + + {error &&

{error}

} + + {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : items.length === 0 ? ( +
+ +

{t("empty")}

+

+ {t("accepted")} +

+
+ ) : ( +
    + {items.map((item) => ( +
  • + +
    + + {item.filename} + +

    + {[ + readableSize(item.size_bytes), + formatDate(item.created_at, i18n.language), + ].join(" · ")} +

    +
    + + void download(item)} + > + + + setPendingDelete(item)} + > + + +
  • + ))} +
+ )} + + setPendingDelete(null)} + onConfirm={remove} + title={t("confirmDelete.title")} + description={t("confirmDelete.message", { + name: pendingDelete?.filename ?? "", + })} + confirmText={t("confirmDelete.confirm")} + /> +
+ ); +}; + +export default DocumentsPanel; diff --git a/src/application/email/EmailSettingsPage.tsx b/src/application/email/EmailSettingsPage.tsx new file mode 100644 index 0000000..2d5188f --- /dev/null +++ b/src/application/email/EmailSettingsPage.tsx @@ -0,0 +1,348 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, CheckCircle2, Mail, Send, Trash2 } from "lucide-react"; + +import { + CustomButton, + CustomCheckBox, + CustomConfirmationModal, + CustomInput, + CustomLoader, +} from "../../components/custom"; +import { apiClient } from "../../lib/apiClient"; +import { formatDate } from "../../lib/dateFormat"; + +/** + * A workspace sending from its own address. + * + * The reason this screen exists is a symptom rather than a feature: + * **invitations land in spam.** A message about a customer's own domain, + * arriving from an unfamiliar sender with no SPF or DKIM alignment, is the + * definition of what a filter is looking for. + * + * ## The ordering the screen has to make obvious + * + * Saving does **not** switch it on — a test send does. A workspace that saves a + * typo and immediately stops receiving invitations has no way to tell what + * changed, so the state after saving is "not verified", said plainly, with the + * test button as the obvious next thing. + * + * And when it is off, the platform's own account still sends everything. That is + * a fallback rather than a failure, and saying so stops somebody treating an + * unverified configuration as an outage. + */ + +type EmailSettings = { + smtp_host: string; + smtp_port: number; + smtp_user?: string | null; + use_ssl: boolean; + from_address: string; + from_name?: string | null; + is_active: boolean; + last_verified_at?: string | null; + last_error?: string | null; + password_set: boolean; +}; + +const EmailSettingsPage: React.FC = () => { + const { t, i18n } = useTranslation(["email", "common"]); + + const [settings, setSettings] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [failed, setFailed] = useState(false); + + const [host, setHost] = useState(""); + const [port, setPort] = useState("587"); + const [user, setUser] = useState(""); + const [password, setPassword] = useState(""); + const [useSsl, setUseSsl] = useState(false); + const [fromAddress, setFromAddress] = useState(""); + const [fromName, setFromName] = useState(""); + + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(""); + const [testTo, setTestTo] = useState(""); + const [pendingClear, setPendingClear] = useState(false); + + const apply = useCallback((found: EmailSettings | null) => { + setSettings(found); + setHost(found?.smtp_host ?? ""); + setPort(String(found?.smtp_port ?? 587)); + setUser(found?.smtp_user ?? ""); + // Always blank: the stored password cannot be read back, so pre-filling + // anything would be a lie that overwrites it on save. + setPassword(""); + setUseSsl(found?.use_ssl ?? false); + setFromAddress(found?.from_address ?? ""); + setFromName(found?.from_name ?? ""); + }, []); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + apply( + await apiClient.get("/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( + "/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( + "/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 ( +
+ +
+ ); + } + + return ( +
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + {failed && ( +

+ {t("errors.loadFailed")} +

+ )} + + {/* The current state, first and unambiguous. "Not verified" is not an + outage — the platform's own account is still sending — and somebody + who reads it as one will go looking for a problem that is not there. */} + {settings && ( +
+ {settings.is_active ? ( + + ) : ( + + )} +
+

+ {settings.is_active + ? t("status.active", { + when: formatDate(settings.last_verified_at, i18n.language), + }) + : t("status.unverified")} +

+ {settings.last_error && ( +

+ {settings.last_error} +

+ )} +
+
+ )} + + {!settings && ( +
+ {t("status.none")} +
+ )} + +
+
+ setHost(event.target.value)} + /> + setPort(event.target.value)} + /> +
+ +
+ setUser(event.target.value)} + /> + setPassword(event.target.value)} + /> +
+ {settings?.password_set && ( +

+ {t("form.passwordNote")} +

+ )} + + setUseSsl(event.target.checked)} + /> + +
+ setFromAddress(event.target.value)} + /> + setFromName(event.target.value)} + /> +
+ +

{t("form.spfNote")}

+ + {error &&

{error}

} + +
+ + {t("form.save")} + + {settings && ( + setPendingClear(true)} + disabled={isBusy} + > + + {t("form.clear")} + + )} +
+
+ + {settings && ( +
+
+ +

+ {t("test.title")} +

+
+

{t("test.note")}

+ +
+
+ setTestTo(event.target.value)} + /> +
+ + + {t("test.send")} + +
+
+ )} + + setPendingClear(false)} + onConfirm={clear} + title={t("confirmClear.title")} + description={t("confirmClear.message")} + confirmText={t("form.clear")} + /> +
+ ); +}; + +export default EmailSettingsPage; diff --git a/src/application/invitations/AcceptInvitationPage.test.tsx b/src/application/invitations/AcceptInvitationPage.test.tsx new file mode 100644 index 0000000..ac05963 --- /dev/null +++ b/src/application/invitations/AcceptInvitationPage.test.tsx @@ -0,0 +1,132 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AcceptInvitationPage from "./AcceptInvitationPage"; + +/** + * The one screen in the product for somebody who has no account yet. + * + * Three properties matter more than the layout: + * + * - **A bad link says nothing useful.** Expired, revoked, already used and never + * existed answer identically, because distinguishing them tells somebody + * working through guesses which of them were real. + * - **Accepting does not sign you in.** It creates the account and stops, so + * sign-in stays the single place that decides about second factors and lapsed + * subscriptions. + * - **The passwords are matched here.** It is the one check the server cannot + * do, because it only ever receives one of the two. + */ + +const preview = vi.fn(); +const accept = vi.fn(); +const navigate = vi.fn(); +let search = "?token=good-token"; + +vi.mock("./InvitationApi", () => ({ + invitationApi: { + preview: (token: string) => preview(token), + accept: (payload: unknown) => accept(payload), + }, +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => navigate, + useSearchParams: () => [new URLSearchParams(search)], + Link: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("../../components/layout/AuthLayout", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +beforeEach(() => { + search = "?token=good-token"; + preview.mockReset().mockResolvedValue({ + email: "newcomer@example.com", + workspace_name: "Contoso", + expires_at: "2030-01-01T00:00:00Z", + }); + accept.mockReset().mockResolvedValue({ + email: "newcomer@example.com", + workspace_id: "w1", + }); + navigate.mockReset(); +}); + +describe("a usable link", () => { + it("says who it is for before asking for anything", async () => { + render(); + 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(); + + await user.type(await screen.findByLabelText(/accept\.password/), "Str0ng!pass"); + await user.type(screen.getByLabelText(/accept\.confirm/), "Str0ng!pass"); + await user.click(screen.getByText("accept.submit")); + + await waitFor(() => + expect(accept).toHaveBeenCalledWith( + expect.objectContaining({ token: "good-token", password: "Str0ng!pass" }) + ) + ); + // No session is issued here, so the screen hands over rather than + // pretending to be signed in. + expect(await screen.findByText("accept.doneTitle")).toBeTruthy(); + }); + + it("refuses two passwords that do not match, without asking the server", async () => { + const user = userEvent.setup({ delay: null }); + render(); + + 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(); + + 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(); + + expect(await screen.findByText("accept.invalidTitle")).toBeTruthy(); + // Not "expired", not "revoked" — one answer for every reason. + expect(screen.queryByLabelText(/accept\.password/)).toBeNull(); + }); + + it("treats a missing token the same way, without asking", async () => { + search = ""; + render(); + + expect(await screen.findByText("accept.invalidTitle")).toBeTruthy(); + expect(preview).not.toHaveBeenCalled(); + }); +}); diff --git a/src/application/invitations/AcceptInvitationPage.tsx b/src/application/invitations/AcceptInvitationPage.tsx new file mode 100644 index 0000000..8b52a22 --- /dev/null +++ b/src/application/invitations/AcceptInvitationPage.tsx @@ -0,0 +1,211 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import AuthLayout from "../../components/layout/AuthLayout"; +import { CustomButton, CustomInput, CustomLoader } from "../../components/custom"; +import { invitationApi } from "./InvitationApi"; + +/** + * Accepting an invitation — the one screen in the product for somebody who has + * no account yet. + * + * ## What it deliberately does not do + * + * **It does not sign you in.** Acceptance creates the account and stops; you + * then sign in normally. That is one extra step for the person and removes a + * whole class of question from this page — whether a second factor applies, + * what happens if the workspace's subscription lapsed while the invitation sat + * in a mailbox, whether the session should be remembered. Sign-in answers all of + * those already, in one place. + * + * **It does not explain why a bad link is bad.** Expired, revoked, already used + * and never existed all answer the same way, because distinguishing them tells + * somebody working through guesses which of them were real. + */ + +const AcceptInvitationPage: React.FC = () => { + const { t } = useTranslation(["invitations", "common"]); + const navigate = useNavigate(); + const [params] = useSearchParams(); + const token = params.get("token") ?? ""; + + const [preview, setPreview] = useState<{ + email: string; + workspace_name: string; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [invalid, setInvalid] = useState(false); + + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(""); + const [done, setDone] = useState(false); + + const load = useCallback(async () => { + if (!token) { + setInvalid(true); + setIsLoading(false); + return; + } + try { + const found = await invitationApi.preview(token); + setPreview(found); + } catch { + setInvalid(true); + } finally { + setIsLoading(false); + } + }, [token]); + + useEffect(() => { + void load(); + }, [load]); + + const accept = async () => { + setError(""); + if (password !== confirmation) { + // Checked here rather than on the server: it is the one validation the + // server cannot do, because it only ever receives one of the two. + setError(t("accept.mismatch")); + return; + } + setIsBusy(true); + try { + await invitationApi.accept({ + token, + password, + first_name: firstName.trim() || null, + last_name: lastName.trim() || null, + }); + setDone(true); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const body = () => { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (invalid) { + return ( +
+

+ {t("accept.invalidTitle")} +

+

{t("accept.invalidBody")}

+ + + {t("accept.toSignIn")} + + +
+ ); + } + + if (done) { + return ( +
+

+ {t("accept.doneTitle")} +

+

{t("accept.doneBody")}

+ navigate("/signin")} + > + {t("accept.toSignIn")} + +
+ ); + } + + return ( +
+
+

+ {t("accept.title", { workspace: preview?.workspace_name ?? "" })} +

+

+ {t("accept.forAddress", { email: preview?.email ?? "" })} +

+
+ +
+ setFirstName(event.target.value)} + className="!text-gray-900" + /> + setLastName(event.target.value)} + className="!text-gray-900" + /> +
+ + setPassword(event.target.value)} + className="!text-gray-900" + /> + + setConfirmation(event.target.value)} + className="!text-gray-900" + /> + +

{t("accept.privacy")}

+ + {error &&

{error}

} + + + {t("accept.submit")} + +
+ ); + }; + + return ( + +
+
+ {body()} +
+
+
+ ); +}; + +export default AcceptInvitationPage; diff --git a/src/application/invitations/InvitationApi.ts b/src/application/invitations/InvitationApi.ts new file mode 100644 index 0000000..97f8e67 --- /dev/null +++ b/src/application/invitations/InvitationApi.ts @@ -0,0 +1,59 @@ +import { apiClient } from "../../lib/apiClient"; +import type { InvitationCreated, InvitationList } from "./InvitationTypes"; + +/** + * Inviting somebody, rather than choosing their password for them. + * + * `preview` and `accept` are the signed-out half: whoever is holding the link + * has no account yet, which is the whole point. They are the only two calls in + * the product that deliberately carry no session. + */ +export const invitationApi = { + list: () => apiClient.get("/api/user/invitations"), + + invite: (payload: { + email: string; + role_id?: string | null; + first_name?: string | null; + last_name?: string | null; + }) => + apiClient.post("/api/user/invitations", payload, { + // The response carries a link the administrator may need to pass on by + // hand, so the dialog is the message rather than a toast beside it. + toast: false, + }), + + resend: (id: string) => + apiClient.post(`/api/user/invitations/${id}/resend`, null, { + toast: false, + }), + + revoke: (id: string) => + apiClient.delete(`/api/user/invitations/${id}`, { + successMessage: "Invitation revoked", + errorMessage: "Could not revoke the invitation", + }), + + /** Signed out. Says only the address and the workspace name — a guessed token + * must not become a way to read a workspace's staff list. */ + preview: (token: string) => + apiClient.get<{ + email: string; + workspace_name: string; + expires_at: string; + }>(`/api/invitations/preview?token=${encodeURIComponent(token)}`, { + toast: false, + }), + + accept: (payload: { + token: string; + password: string; + first_name?: string | null; + last_name?: string | null; + }) => + apiClient.post<{ email: string; workspace_id: string }>( + "/api/invitations/accept", + payload, + { toast: false } + ), +}; diff --git a/src/application/invitations/InvitationTypes.ts b/src/application/invitations/InvitationTypes.ts new file mode 100644 index 0000000..eae37d3 --- /dev/null +++ b/src/application/invitations/InvitationTypes.ts @@ -0,0 +1,31 @@ +export type InvitationState = "pending" | "accepted" | "revoked" | "expired"; + +export type Invitation = { + id: string; + email: string; + first_name?: string | null; + last_name?: string | null; + role_id?: string | null; + invited_by_id?: string | null; + expires_at: string; + accepted_at?: string | null; + revoked_at?: string | null; + created_at: string; + /** Derived from the clock rather than stored — "expired" is a fact about + * today, and a column would go stale the moment it was written. */ + state: InvitationState; +}; + +export type InvitationCreated = { + invitation: Invitation; + /** Returned so an administrator can pass the link on by hand when mail does + * not arrive. Leaving it out means keeping a second copy of the token + * somewhere worse — in an email thread, usually. */ + acceptance_url: string; + email_sent: boolean; +}; + +export type InvitationList = { + items: Invitation[]; + total: number; +}; diff --git a/src/application/invitations/InvitationsPage.tsx b/src/application/invitations/InvitationsPage.tsx new file mode 100644 index 0000000..ec12976 --- /dev/null +++ b/src/application/invitations/InvitationsPage.tsx @@ -0,0 +1,355 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Copy, MailPlus, RotateCw, Trash2 } from "lucide-react"; + +import { + CustomButton, + CustomConfirmationModal, + CustomInput, + CustomLoader, + CustomModal, + CustomSearchableDropdown, +} from "../../components/custom"; +import { formatDate } from "../../lib/dateFormat"; +import { rolesApi } from "../roles/RolesApi"; +import type { Role } from "../roles/RolesTypes"; +import { invitationApi } from "./InvitationApi"; +import type { + Invitation, + InvitationCreated, + InvitationState, +} from "./InvitationTypes"; + +/** + * Invitations. + * + * This is the screen that replaces an administrator typing somebody else's + * password into a form. That mattered more than it sounds: the password was then + * known to two people, and the one it did not belong to was the one with + * administrative access — so "the account holder did this" was never a claim the + * audit trail could support. + * + * Two things the screen has to be honest about: + * + * - **Whether the email actually went.** `email_sent` is false when the mail + * host was down, and the invitation is still perfectly valid. Hiding that + * would leave an administrator waiting for somebody who never heard. + * - **Resending issues a new link.** The old one is not recoverable — only its + * hash was ever stored — and it is revoked, which is usually the point: the + * reason to resend is that the first message went somewhere it should not + * have. + */ + +const LinkDialog: React.FC<{ + created: InvitationCreated; + onDone: () => void; +}> = ({ created, onDone }) => { + const { t } = useTranslation(["invitations", "common"]); + + return ( +
+

+ {created.email_sent + ? t("created.sent", { email: created.invitation.email }) + : t("created.notSent", { email: created.invitation.email })} +

+ + {!created.email_sent && ( +
+ {t("created.notSentExplain")} +
+ )} + +
+ + {created.acceptance_url} + + + void navigator.clipboard?.writeText(created.acceptance_url) + } + > + + +
+ +

{t("created.warning")}

+ + + {t("created.done")} + +
+ ); +}; + +const InvitationsPage: React.FC = () => { + const { t, i18n } = useTranslation(["invitations", "common"]); + + const [items, setItems] = useState([]); + const [roles, setRoles] = useState([]); + 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(null); + const [pendingRevoke, setPendingRevoke] = useState(null); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + const list = await invitationApi.list(); + setItems(list.items); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + // Roles are optional here — an invitation without one creates an account + // with no role, which an administrator can set afterwards. A failure to + // load them must not stop somebody inviting a colleague. + void rolesApi + .getAll() + .then((loaded) => setRoles(loaded)) + .catch(() => setRoles([])); + }, [load]); + + const invite = async () => { + setError(""); + setIsBusy(true); + try { + const result = await invitationApi.invite({ + email: email.trim(), + first_name: firstName.trim() || null, + last_name: lastName.trim() || null, + role_id: roleId || null, + }); + setIsInviteOpen(false); + setEmail(""); + setFirstName(""); + setLastName(""); + setRoleId(""); + setCreated(result); + await load(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const resend = async (invitation: Invitation) => { + const result = await invitationApi.resend(invitation.id); + setCreated(result); + await load(); + }; + + const revoke = async () => { + if (!pendingRevoke) return; + const target = pendingRevoke; + setPendingRevoke(null); + try { + await invitationApi.revoke(target.id); + } finally { + await load(); + } + }; + + const stateBadge = (invitation: Invitation) => { + const styles: Record = { + 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 ( + + {t(`state.${invitation.state}`)} + + ); + }; + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + setIsInviteOpen(true)}> + + {t("invite")} + +
+ +
+ {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : items.length === 0 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
    + {items.map((invitation) => ( +
  • +
    +
    + + {invitation.email} + + {stateBadge(invitation)} +
    +

    + {invitation.state === "accepted" + ? t("acceptedOn", { + when: formatDate(invitation.accepted_at, i18n.language), + }) + : t("expiresOn", { + when: formatDate(invitation.expires_at, i18n.language), + })} +

    +
    + + {invitation.state !== "accepted" && ( +
    + void resend(invitation)} + > + + {t("resend")} + + {invitation.state === "pending" && ( + setPendingRevoke(invitation)} + > + + + )} +
    + )} +
  • + ))} +
+ )} +
+ + { + setIsInviteOpen(false); + setError(""); + }} + title={t("invite")} + > +
+ setEmail(event.target.value)} + /> + +
+ setFirstName(event.target.value)} + /> + setLastName(event.target.value)} + /> +
+ + {roles.length > 0 && ( + setRoleId(value)} + options={roles.map((role) => ({ + label: role.role_name, + value: role.id, + }))} + /> + )} + +

+ {t("form.explain")} +

+ + {error &&

{error}

} + + + {t("form.submit")} + +
+
+ + setCreated(null)} + title={t("created.title")} + > + {created && ( + setCreated(null)} /> + )} + + + setPendingRevoke(null)} + onConfirm={revoke} + title={t("confirmRevoke.title")} + description={t("confirmRevoke.message", { + email: pendingRevoke?.email ?? "", + })} + confirmText={t("confirmRevoke.confirm")} + /> +
+ ); +}; + +export default InvitationsPage; diff --git a/src/application/notifications/NotificationApi.ts b/src/application/notifications/NotificationApi.ts new file mode 100644 index 0000000..a966326 --- /dev/null +++ b/src/application/notifications/NotificationApi.ts @@ -0,0 +1,66 @@ +import { apiClient } from "../../lib/apiClient"; +import type { + NotificationList, + NotificationPreference, + UnreadCount, +} from "./NotificationTypes"; + +/** + * Your own notifications. + * + * `unreadCount` is a separate call on purpose: the bell polls it and nothing + * else, and fetching the whole list to render a number would be a query per + * poll per signed-in person. + * + * Nothing here shows a toast. A notification *is* the notice — announcing that + * we fetched your notices, or that one was marked read, is noise on top of the + * thing itself. + */ +export const notificationApi = { + unreadCount: () => + apiClient.get("/api/notifications/unread-count", { + toast: false, + }), + + list: (unreadOnly = false) => + apiClient.get( + `/api/notifications?unread_only=${unreadOnly ? "true" : "false"}&limit=50`, + { toast: false } + ), + + markRead: (id: string) => + apiClient.post(`/api/notifications/${id}/read`, null, { toast: false }), + + markAllRead: () => + apiClient.post<{ marked: number }>("/api/notifications/read-all", null, { + toast: false, + }), + + /** + * Every kind with your answer for each — not only the ones you have changed. + * + * Absence means enabled on the server, so a listing of stored rows would be + * empty for almost everybody. The catalogue arrives filled in instead. + */ + preferences: () => + apiClient.get("/api/notifications/preferences", { + toast: false, + }), + + /** + * One kind at a time, on your own account. There is no user id to pass: a + * preference somebody else set on your behalf is not a preference. + * + * No toast either way. A success toast per flip is noise on a list of seven + * switches, and the panel reports a failure better than a toast can: it puts + * the switch back where it was and says so inline. Being left believing you + * turned something off when you did not is the failure that shows up weeks + * later as silence — so it has to be visible *on the switch*. + */ + setPreference: (kind: string, enabled: boolean) => + apiClient.put( + "/api/notifications/preferences", + { kind, channel: "in_app", enabled }, + { toast: false } + ), +}; diff --git a/src/application/notifications/NotificationBell.test.tsx b/src/application/notifications/NotificationBell.test.tsx new file mode 100644 index 0000000..1d7e383 --- /dev/null +++ b/src/application/notifications/NotificationBell.test.tsx @@ -0,0 +1,222 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import NotificationBell from "./NotificationBell"; + +/** + * The bell. + * + * Two things it must not do, and both are easy to do by accident: + * + * - **Mark everything read on open.** "I glanced at the bell" is not "I dealt + * with these", and a list that empties itself as you look at it is one you + * cannot come back to. + * - **Poll a hidden tab.** A laptop left open on this page overnight would make + * several thousand pointless requests. + * + * The optimistic update also has to be reversible: showing something as read + * when the server still holds it unread means it drops out of the filtered view + * and never returns. + */ + +const unreadCount = vi.fn(); +const list = vi.fn(); +const markRead = vi.fn(); +const markAllRead = vi.fn(); +const navigate = vi.fn(); + +vi.mock("./NotificationApi", () => ({ + notificationApi: { + unreadCount: () => unreadCount(), + list: (unreadOnly?: boolean) => list(unreadOnly), + markRead: (id: string) => markRead(id), + markAllRead: () => markAllRead(), + }, +})); + +vi.mock("react-router-dom", () => ({ useNavigate: () => navigate })); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options && typeof options.count === "number" + ? `${key}:${options.count}` + : key, + i18n: { language: "en" }, + }), +})); + +const notice = (overrides: Partial> = {}) => ({ + 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(); + await waitFor(() => expect(unreadCount).toHaveBeenCalled()); + expect(screen.queryByText("0")).toBeNull(); + }); + + it("shows the count", async () => { + unreadCount.mockResolvedValue({ unread: 3 }); + render(); + expect(await screen.findByText("3")).toBeTruthy(); + }); + + it("caps a large count rather than printing it", async () => { + // A three-digit badge is unreadable, and the exact number stops meaning + // anything long before that. + unreadCount.mockResolvedValue({ unread: 250 }); + render(); + expect(await screen.findByText("99+")).toBeTruthy(); + }); + + it("stays quiet when the count cannot be fetched", async () => { + // A bell showing an error is worse than one showing nothing: the count + // is a convenience and the notices are still there. + unreadCount.mockRejectedValue(new Error("network")); + render(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await user.click(screen.getByRole("button")); + await user.click( + await screen.findByText("A webhook endpoint was switched off") + ); + + await waitFor(() => expect(markRead).toHaveBeenCalled()); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("puts it back when the server refuses", async () => { + // Showing it as read when the server still holds it unread means it + // drops out of the filtered view and never comes back. + const user = userEvent.setup({ delay: null }); + list.mockResolvedValue({ items: [notice({ link: null })], unread: 1 }); + markRead.mockRejectedValue(new Error("nope")); + render(); + + 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(); + + 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(); + await waitFor(() => expect(unreadCount).toHaveBeenCalledTimes(1)); + + const hidden = vi.spyOn(document, "hidden", "get"); + + hidden.mockReturnValue(true); + document.dispatchEvent(new Event("visibilitychange")); + expect(unreadCount).toHaveBeenCalledTimes(1); + + hidden.mockReturnValue(false); + document.dispatchEvent(new Event("visibilitychange")); + + // Immediately, rather than leaving a stale count for up to a minute + // after somebody comes back to the tab. + await waitFor(() => expect(unreadCount).toHaveBeenCalledTimes(2)); + hidden.mockRestore(); + }); +}); diff --git a/src/application/notifications/NotificationBell.tsx b/src/application/notifications/NotificationBell.tsx new file mode 100644 index 0000000..308af18 --- /dev/null +++ b/src/application/notifications/NotificationBell.tsx @@ -0,0 +1,298 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, Bell, Check, Info } from "lucide-react"; + +import { CustomLoader } from "../../components/custom"; +import { notificationApi } from "./NotificationApi"; +import type { Notification } from "./NotificationTypes"; + +/** + * The bell, and the list behind it. + * + * ## Polling, and why it is slow + * + * Sixty seconds. These are not chat messages — the things that raise one are a + * webhook endpoint being switched off, an account being locked, a key being + * issued. Minutes-late is fine for all of them, and a five-second poll would be + * a request per signed-in person every five seconds for the rest of time. + * + * The poll stops while the tab is hidden. A laptop left open on this page + * overnight would otherwise make several thousand pointless requests. + * + * ## What it does not do + * + * It does not mark everything read on open. "I glanced at the bell" is not "I + * dealt with these", and a list that empties itself as you look at it is one you + * cannot come back to. + */ + +const POLL_INTERVAL_MS = 60_000; + +const relativeTime = (value: string, locale: string) => { + const then = new Date(value).getTime(); + if (Number.isNaN(then)) return value; + + const seconds = Math.round((then - Date.now()) / 1000); + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + const steps: [Intl.RelativeTimeFormatUnit, number][] = [ + ["second", 60], + ["minute", 60], + ["hour", 24], + ["day", 7], + ["week", 4.35], + ["month", 12], + ]; + + let amount = seconds; + for (const [unit, size] of steps) { + if (Math.abs(amount) < size) return formatter.format(Math.round(amount), unit); + amount /= size; + } + return formatter.format(Math.round(amount), "year"); +}; + +const NotificationBell: React.FC = () => { + const { t, i18n } = useTranslation(["notifications", "common"]); + const navigate = useNavigate(); + + const [unread, setUnread] = useState(0); + const [isOpen, setIsOpen] = useState(false); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [failed, setFailed] = useState(false); + + const panelRef = useRef(null); + + const refreshCount = useCallback(async () => { + try { + const { unread: count } = await notificationApi.unreadCount(); + setUnread(count); + } catch { + // Silent. A bell that shows an error is worse than a bell that shows + // nothing — the count is a convenience, and the notices are still there. + } + }, []); + + useEffect(() => { + void refreshCount(); + + let timer: ReturnType | null = null; + + const start = () => { + if (timer === null) timer = setInterval(() => void refreshCount(), POLL_INTERVAL_MS); + }; + const stop = () => { + if (timer !== null) { + clearInterval(timer); + timer = null; + } + }; + + const onVisibility = () => { + if (document.hidden) { + stop(); + } else { + // Catch up immediately on return, rather than leaving a stale count for + // up to a minute after somebody comes back to the tab. + void refreshCount(); + start(); + } + }; + + if (!document.hidden) start(); + document.addEventListener("visibilitychange", onVisibility); + + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [refreshCount]); + + useEffect(() => { + const onClickOutside = (event: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", onClickOutside); + return () => document.removeEventListener("mousedown", onClickOutside); + }, []); + + const open = async () => { + setIsOpen(true); + setIsLoading(true); + setFailed(false); + try { + const list = await notificationApi.list(); + setItems(list.items); + setUnread(list.unread); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }; + + const toggle = () => { + if (isOpen) { + setIsOpen(false); + } else { + void open(); + } + }; + + const markRead = async (notification: Notification) => { + if (notification.read_at) return; + setItems((current) => + current.map((item) => + item.id === notification.id + ? { ...item, read_at: new Date().toISOString() } + : item + ) + ); + setUnread((count) => Math.max(0, count - 1)); + try { + await notificationApi.markRead(notification.id); + } catch { + // Put it back. Showing it as read when the server still has it unread + // means it disappears from the filtered view and never comes back. + setItems((current) => + current.map((item) => + item.id === notification.id ? { ...item, read_at: null } : item + ) + ); + setUnread((count) => count + 1); + } + }; + + const markAll = async () => { + const previous = items; + const stamp = new Date().toISOString(); + setItems((current) => + current.map((item) => ({ ...item, read_at: item.read_at ?? stamp })) + ); + setUnread(0); + try { + await notificationApi.markAllRead(); + } catch { + setItems(previous); + void refreshCount(); + } + }; + + const activate = (notification: Notification) => { + void markRead(notification); + if (notification.link) { + setIsOpen(false); + navigate(notification.link); + } + }; + + return ( +
+ + + {isOpen && ( +
+
+ + {t("title")} + + {items.some((item) => !item.read_at) && ( + + )} +
+ +
+ {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("loadFailed")} +

+ ) : items.length === 0 ? ( +

+ {t("empty")} +

+ ) : ( +
    + {items.map((item) => { + const Icon = item.severity === "warning" ? AlertTriangle : Info; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ )} +
+ ); +}; + +export default NotificationBell; diff --git a/src/application/notifications/NotificationPreferencesPanel.test.tsx b/src/application/notifications/NotificationPreferencesPanel.test.tsx new file mode 100644 index 0000000..9e0d6a6 --- /dev/null +++ b/src/application/notifications/NotificationPreferencesPanel.test.tsx @@ -0,0 +1,153 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import NotificationPreferencesPanel from "./NotificationPreferencesPanel"; + +/** + * The whole feature is "let somebody turn a notification off". Every test here + * is about the two ways that promise breaks: a switch that says off when the + * server says on, and a failure that looks like a success. + */ + +const preferences = vi.fn(); +const setPreference = vi.fn(); + +vi.mock("./NotificationApi", () => ({ + notificationApi: { + preferences: () => preferences(), + setPreference: (kind: string, enabled: boolean) => setPreference(kind, enabled), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => + options?.defaultValue !== undefined && options.defaultValue !== "" + ? options.defaultValue + : key, + i18n: { language: "en" }, + }), +})); + +const preference = (kind: string, enabled = true) => ({ + kind, + channel: "in_app", + enabled, +}); + +beforeEach(() => { + preferences.mockReset(); + setPreference.mockReset(); +}); + +describe("NotificationPreferencesPanel", () => { + it("shows every kind, not only the ones already changed", async () => { + // A row exists on the server only where somebody turned something off, + // so a screen listing stored rows would be empty for almost everybody. + preferences.mockResolvedValue([ + preference("security.account_locked"), + preference("webhook.disabled", false), + preference("invitation.accepted"), + ]); + + render(); + + const switches = await screen.findAllByRole("switch"); + expect(switches).toHaveLength(3); + expect(switches[0]).toBeChecked(); + expect(switches[1]).not.toBeChecked(); + }); + + it("marks the ones somebody would regret turning off", async () => { + // Marked rather than prevented: somebody who does not want mail about + // their own sign-ins is entitled not to get it. + preferences.mockResolvedValue([ + preference("security.account_locked"), + preference("invitation.accepted"), + ]); + + render(); + + 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(); + + const switches = await screen.findAllByRole("switch"); + await user.click(switches[1]); + + expect(setPreference).toHaveBeenCalledWith("webhook.disabled", false); + expect(setPreference).toHaveBeenCalledTimes(1); + }); + + it("puts the switch back when the server refuses", async () => { + // The failure this whole panel exists to prevent: somebody believes a + // notification is off, and finds out weeks later as silence. + preferences.mockResolvedValue([preference("webhook.disabled")]); + setPreference.mockRejectedValue(new Error("nope")); + + const user = userEvent.setup({ delay: null }); + render(); + + const toggle = await screen.findByRole("switch"); + expect(toggle).toBeChecked(); + + await user.click(toggle); + + await waitFor(() => expect(toggle).toBeChecked()); + expect(screen.getByText("preferences.saveFailed")).toBeInTheDocument(); + }); + + it("takes the server's answer over its own optimistic one", async () => { + // The server returns every kind after a write. If it disagreed with the + // guess made on click, its answer is the one that is true. + preferences.mockResolvedValue([preference("webhook.disabled")]); + setPreference.mockResolvedValue([preference("webhook.disabled", true)]); + + const user = userEvent.setup({ delay: null }); + render(); + + const toggle = await screen.findByRole("switch"); + await user.click(toggle); + + await waitFor(() => expect(toggle).toBeChecked()); + }); + + it("says it does not know rather than showing everything off", async () => { + // An empty list would render as "you have turned everything off" — + // both wrong and alarming. + preferences.mockRejectedValue(new Error("down")); + + render(); + + expect(await screen.findByText("preferences.loadFailed")).toBeInTheDocument(); + expect(screen.queryAllByRole("switch")).toHaveLength(0); + }); + + it("gives every switch a label that names the notification", async () => { + // Without one the row is a checkbox floating beside some text, which is + // unusable with a screen reader and ambiguous with a mouse. + preferences.mockResolvedValue([preference("invitation.accepted")]); + + render(); + + // The title falls back to the kind when a translation is missing, which + // is what the stub above returns — so this asserts the label is wired to + // the switch, not what any particular language says. + expect( + await screen.findByRole("switch", { name: /invitation\.accepted/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/application/notifications/NotificationPreferencesPanel.tsx b/src/application/notifications/NotificationPreferencesPanel.tsx new file mode 100644 index 0000000..abdc797 --- /dev/null +++ b/src/application/notifications/NotificationPreferencesPanel.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ShieldAlert } from "lucide-react"; + +import { CustomLoader } from "../../components/custom"; +import { notificationApi } from "./NotificationApi"; +import type { NotificationPreference } from "./NotificationTypes"; + +/** + * Which notifications you want. + * + * The server has enforced these since they existed; nothing ever showed them. + * A preference nobody can find is not a preference — the workspace with a busy + * audit trail still sends its administrators mail they did not choose, and it + * still ends the same way: a filing rule, and then nobody reads any of them, + * including the one that mattered. + * + * Every kind arrives with an answer already filled in. Absence means enabled on + * the server, so a listing of stored rows would be empty for almost everybody + * and this panel would have to invent what that meant. + */ + +/** The ones somebody would regret turning off, marked as such rather than + * prevented. Somebody who does not want mail about their own sign-ins is + * entitled not to get it, and the audit trail records the event either way — + * but they should know which lever they are pulling. */ +const SENSITIVE = new Set([ + "security.account_locked", + "security.mfa_enabled", + "security.mfa_disabled", +]); + +const NotificationPreferencesPanel: React.FC = () => { + const { t } = useTranslation(["notifications", "common"]); + + const [preferences, setPreferences] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [busyKind, setBusyKind] = useState(null); + const [saveFailed, setSaveFailed] = useState(null); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + setPreferences(await notificationApi.preferences()); + } catch { + // Not an empty list: that would render as "you have turned + // everything off", which is both wrong and alarming. + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const toggle = async (preference: NotificationPreference) => { + const wanted = !preference.enabled; + setBusyKind(preference.kind); + setSaveFailed(null); + + // Moved before the request so the switch responds to the finger rather + // than to the network — and put back below if the server disagrees. + setPreferences((current) => + current.map((row) => + row.kind === preference.kind ? { ...row, enabled: wanted } : row + ) + ); + + try { + setPreferences(await notificationApi.setPreference(preference.kind, wanted)); + } catch { + setPreferences((current) => + current.map((row) => + row.kind === preference.kind + ? { ...row, enabled: preference.enabled } + : row + ) + ); + // The switch springing back is the signal; this says why. Being left + // believing you turned something off when you did not is the failure + // that only surfaces weeks later, as silence. + setSaveFailed(preference.kind); + } finally { + setBusyKind(null); + } + }; + + return ( +
+
+

+ {t("preferences.title")} +

+

+ {t("preferences.description")} +

+
+ + {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("preferences.loadFailed")} +

+ ) : ( +
    + {preferences.map((preference) => { + const isSensitive = SENSITIVE.has(preference.kind); + const inputId = `notification-preference-${preference.kind}`; + + return ( +
  • +
    +
    + +

    + {t(`kinds.${preference.kind}.description`, { + defaultValue: "", + })} +

    +
    + + void toggle(preference)} + className="h-5 w-5 shrink-0 cursor-pointer rounded accent-[var(--primary)] disabled:cursor-not-allowed disabled:opacity-50" + /> +
    + + {saveFailed === preference.kind && ( +

    + {t("preferences.saveFailed")} +

    + )} +
  • + ); + })} +
+ )} +
+ ); +}; + +export default NotificationPreferencesPanel; diff --git a/src/application/notifications/NotificationTypes.ts b/src/application/notifications/NotificationTypes.ts new file mode 100644 index 0000000..8fa981d --- /dev/null +++ b/src/application/notifications/NotificationTypes.ts @@ -0,0 +1,40 @@ +export type NotificationSeverity = "info" | "warning"; + +export type Notification = { + id: string; + /** A short machine-readable kind beside the human text, so the icon and the + * click target are chosen from a value rather than parsed out of a sentence. */ + kind: string; + severity: NotificationSeverity; + title: string; + body?: string | null; + /** Where to go about it. A notice with nothing to do about it is one people + * learn to ignore. */ + link?: string | null; + data?: Record | null; + read_at?: string | null; + created_at: string; +}; + +export type NotificationList = { + items: Notification[]; + unread: number; +}; + +export type UnreadCount = { + unread: number; +}; + +/** + * One kind, one channel, and whether it is wanted. + * + * The server fills these in from its catalogue rather than returning stored + * rows: a row exists only where somebody has turned something off, so a raw + * listing would be empty for almost everybody and this screen would have to + * invent what that meant. + */ +export type NotificationPreference = { + kind: string; + channel: string; + enabled: boolean; +}; diff --git a/src/application/operations/OperationsApi.ts b/src/application/operations/OperationsApi.ts new file mode 100644 index 0000000..204d000 --- /dev/null +++ b/src/application/operations/OperationsApi.ts @@ -0,0 +1,37 @@ +import { apiClient } from "../../lib/apiClient"; +import type { + AuditRetentionStatus, + NoticeSummary, + OpenAlert, + OutboxSummary, + SessionSummary, +} from "./OperationsTypes"; + +/** + * Read-only, superadmin-only. Silent because the page renders its own failure + * state — a toast per panel on a page with four of them is noise. + */ +export const operationsApi = { + notices: (days = 30) => + apiClient.get( + `/api/admin/operations/subscription-notices?days=${days}`, + { silent: true } + ), + outbox: () => + apiClient.get("/api/admin/operations/outbox", { + silent: true, + }), + sessions: () => + apiClient.get("/api/admin/operations/sessions", { + silent: true, + }), + alerts: () => + apiClient.get("/api/admin/operations/alerts", { + silent: true, + }), + auditRetention: () => + apiClient.get( + "/api/admin/operations/audit-retention", + { silent: true } + ), +}; diff --git a/src/application/operations/OperationsPage.test.tsx b/src/application/operations/OperationsPage.test.tsx new file mode 100644 index 0000000..637f905 --- /dev/null +++ b/src/application/operations/OperationsPage.test.tsx @@ -0,0 +1,254 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import OperationsPage from "./OperationsPage"; + +/** + * The page exists so three numbers stop needing somebody to grep a worker's + * stdout. What it must not do is look calm while one of them is non-zero — the + * whole value is that a glance is enough. + */ + +const notices = vi.fn(); +const outbox = vi.fn(); +const sessions = vi.fn(); +const alerts = vi.fn(); +const auditRetention = vi.fn(); + +vi.mock("./OperationsApi", () => ({ + operationsApi: { + notices: () => notices(), + outbox: () => outbox(), + sessions: () => sessions(), + alerts: () => alerts(), + auditRetention: () => auditRetention(), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + 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(); + 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(); + 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(); + + expect(await screen.findByText("alerts.open:1")).toBeInTheDocument(); + expect(screen.getByText("outbox_stuck")).toBeInTheDocument(); + expect(screen.getByText("12 events are overdue")).toBeInTheDocument(); + }); + + it("says when an open alert reached nobody", async () => { + // Otherwise the absence of a message reads as the absence of a problem, + // which is the exact failure an alerting system must not have. + alerts.mockResolvedValue([ + { + key: "token_reuse", + severity: "critical", + detail: "a token was used twice", + observed: 1, + opened_at: "2026-01-01T00:00:00Z", + last_notified_at: null, + notify_count: 0, + }, + ]); + render(); + + 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(); + + 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(); + + 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(); + + expect(await screen.findByText("Alpha Corp")).toBeInTheDocument(); + expect(screen.getByText("table.nobody")).toBeInTheDocument(); + }); + + it("offers a retry rather than a blank page when a read fails", async () => { + // Four independent reads: one failing should not leave the operator + // looking at nothing with no way forward. + outbox.mockRejectedValue(new Error("boom")); + render(); + + await waitFor(() => + expect(screen.getByText("loadError")).toBeInTheDocument() + ); + }); +}); + +describe("the audit retention panel", () => { + it("does not take the page down when retention is not configured", async () => { + // Retention connects as its own database role, so this endpoint failing + // is a real and common state on a fresh deployment. Hiding the three + // panels that work in order to report the one that does not is exactly + // backwards. + healthy(); + auditRetention.mockRejectedValue(new Error("not configured")); + + render(); + + expect( + await screen.findByText("panels.retentionUnavailable") + ).toBeInTheDocument(); + // The rest of the page is still there. + expect(screen.getByText("panels.sessions")).toBeInTheDocument(); + }); + + it("says plainly when nothing is past its window", async () => { + healthy(); + render(); + + expect(await screen.findByText("panels.pastRetention:0")).toBeInTheDocument(); + }); + + it("shows a backlog rather than burying it", async () => { + // A sweep that is not keeping up is the thing worth knowing before the + // table is the reason an operations query times out. + healthy(); + auditRetention.mockResolvedValue({ + total_entries: 9_000_000, + oldest_entry: "2019-01-01T00:00:00Z", + past_retention: 4200, + retention_days: 365, + security_retention_days: 730, + }); + + render(); + + expect(await screen.findByText("panels.pastRetention:4200")).toBeInTheDocument(); + }); +}); diff --git a/src/application/operations/OperationsPage.tsx b/src/application/operations/OperationsPage.tsx new file mode 100644 index 0000000..8311b12 --- /dev/null +++ b/src/application/operations/OperationsPage.tsx @@ -0,0 +1,402 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertTriangle, + BellRing, + MailWarning, + RefreshCcw, + ShieldAlert, +} from "lucide-react"; + +import { CustomButton, CustomLoader } from "../../components/custom"; +import { operationsApi } from "./OperationsApi"; +import type { + AuditRetentionStatus, + NoticeSummary, + OpenAlert, + OutboxSummary, + SessionSummary, +} from "./OperationsTypes"; + +/** + * What the background work has been doing. + * + * Four jobs run unattended — the event outbox, the session sweep, the + * subscription notices and the alert checks — and every one of them reported + * only into a log file. The numbers that most want watching were answerable + * and, in practice, never answered. + * + * Anything currently firing sits at the top: it is the only part of the page + * that says something is wrong *now*. Below it are the three figures that mean + * somebody should act — customers who will lapse with no warning, events that + * are not getting through, and refresh tokens that were used twice. + */ + +type Health = "ok" | "warn" | "bad"; + +const Figure: React.FC<{ + label: string; + value: number | string; + hint?: string; + health?: Health; + icon?: React.ReactNode; +}> = ({ label, value, hint, health = "ok", icon }) => { + const tone = + health === "bad" + ? "border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/40" + : health === "warn" + ? "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40" + : "border-[var(--card-border)] bg-[var(--card-bg)]"; + + return ( +
+
+ {icon} + {label} +
+

+ {value} +

+ {hint && ( +

{hint}

+ )} +
+ ); +}; + +const Panel: React.FC<{ title: string; children: React.ReactNode }> = ({ + title, + children, +}) => ( +
+

+ {title} +

+ {children} +
+); + +const Counts: React.FC<{ counts: Record; empty: string }> = ({ + counts, + empty, +}) => { + const entries = Object.entries(counts).filter(([, n]) => n > 0); + if (entries.length === 0) { + return

{empty}

; + } + return ( +
    + {entries.map(([key, count]) => ( +
  • + {key} + {count} +
  • + ))} +
+ ); +}; + +const OperationsPage: React.FC = () => { + const { t, i18n } = useTranslation(["operations", "common"]); + + const [notices, setNotices] = useState(null); + const [outbox, setOutbox] = useState(null); + const [sessions, setSessions] = useState(null); + const [alerts, setAlerts] = useState([]); + const [retention, setRetention] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [failed, setFailed] = useState(false); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + // Together: four independent reads, and waiting for them in series + // makes the page feel broken on a slow connection. + const [n, o, s, a] = await Promise.all([ + operationsApi.notices(), + operationsApi.outbox(), + operationsApi.sessions(), + operationsApi.alerts(), + ]); + setNotices(n); + setOutbox(o); + setSessions(s); + setAlerts(a); + + // Deliberately not in that Promise.all. Retention runs on its own + // database role, and the endpoint fails when that role is not + // configured — which is a real state, and a common one on a fresh + // deployment. Letting it take the whole page down would hide the + // three panels that are working to report the one that is not. + try { + setRetention(await operationsApi.auditRetention()); + } catch { + setRetention(null); + } + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const when = (value: string | null) => + value ? new Date(value).toLocaleString(i18n.language) : "—"; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (failed) { + return ( +
+ {t("loadError")} +
+ {t("common:retry", "Retry")} +
+
+ ); + } + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + + {t("common:refresh", "Refresh")} + +
+ + {alerts.length > 0 && ( +
+
+ + {t("alerts.open", { count: alerts.length })} +
+
    + {alerts.map((alert) => ( +
  • + + {alert.key} + + + {alert.detail} + + {alert.notify_count === 0 && ( + // Open and undelivered. Worth saying, because + // otherwise the absence of a message reads as + // the absence of a problem. + + {t("alerts.undelivered")} + + )} +
  • + ))} +
+
+ )} + + {/* The three that mean somebody should act. */} +
+
0 + ? "warn" + : "ok" + } + icon={} + /> +
0 ? "bad" : "ok"} + icon={} + /> +
0 ? "bad" : "ok"} + icon={} + /> +
+ +
+ + + {(notices?.recorded_but_not_sent ?? 0) > 0 && ( +

+ {t("panels.recordedNotSent", { + count: notices?.recorded_but_not_sent ?? 0, + })} +

+ )} +
+ + + +

+ {t("panels.oldestPending", { + when: when(outbox?.oldest_pending_at ?? null), + })} +

+
+ + + {retention === null ? ( +

+ {t("panels.retentionUnavailable")} +

+ ) : ( + <> +
+ {retention.total_entries.toLocaleString()} + + {t("panels.auditEntries")} + +
+ {/* The number that matters. High once is a sweep + that has not run yet; high across runs is a sweep + that is not keeping up, and the two look + identical in a single reading — so the window is + shown beside it rather than left implied. */} +

0 + ? "font-medium text-amber-700 dark:text-amber-400" + : "text-[var(--text-secondary)]" + }`} + > + {t("panels.pastRetention", { + count: retention.past_retention, + })} +

+

+ {t("panels.retentionWindow", { + days: retention.retention_days, + securityDays: retention.security_retention_days, + })} +

+

+ {t("panels.oldestEntry", { + when: when(retention.oldest_entry), + })} +

+ + )} +
+ + +
+ {sessions?.active ?? 0} + + {t("panels.activeSessions")} + +
+ +
+ + + {(outbox?.failing_targets.length ?? 0) === 0 ? ( +

+ {t("panels.noFailingTargets")} +

+ ) : ( +
    + {outbox?.failing_targets.map((target) => ( +
  • +
    + + {target.target_url} + + + {target.count} + +
    + {target.last_error && ( +

    + {target.last_error} +

    + )} +
  • + ))} +
+ )} +
+
+ + + {(notices?.recent.length ?? 0) === 0 ? ( +

+ {t("panels.noNotices")} +

+ ) : ( +
+ + + + + + + + + + + {notices?.recent.map((row, index) => ( + + + + + + + ))} + +
{t("table.workspace")}{t("table.kind")}{t("table.sentTo")}{t("table.sentAt")}
{row.tenant_name}{row.kind} + {row.sent_to ?? ( + + {t("table.nobody")} + + )} + {when(row.sent_at)}
+
+ )} +
+
+ ); +}; + +export default OperationsPage; diff --git a/src/application/operations/OperationsTypes.ts b/src/application/operations/OperationsTypes.ts new file mode 100644 index 0000000..3ebcf2f --- /dev/null +++ b/src/application/operations/OperationsTypes.ts @@ -0,0 +1,76 @@ +/** Summaries of the background work, from `/api/admin/operations/*`. */ + +export type NoticeRow = { + tenant_name: string; + kind: string; + for_end_date: string | null; + /** Null means the notice was recorded but there was nobody to send it to. */ + sent_to: string | null; + sent_at: string; +}; + +export type NoticeSummary = { + window_days: number; + by_kind: Record; + total: number; + recorded_but_not_sent: number; + /** + * Workspaces with an end date and no billing address. A forecast, not a + * history: these are the ones that will lapse without warning next time. + */ + workspaces_with_no_billing_contact: number; + recent: NoticeRow[]; +}; + +export type FailingTarget = { + target_url: string; + count: number; + last_error: string | null; +}; + +export type OutboxSummary = { + by_status: Record; + /** Pending, overdue and already retried — the number worth an alert. */ + stuck: number; + oldest_pending_at: string | null; + failing_targets: FailingTarget[]; +}; + +export type SessionSummary = { + active: number; + ended_by_reason: Record; + /** + * A security signal, not a capacity one: somebody presented a refresh token + * the legitimate client had already spent. + */ + reuse_detected: number; + awaiting_sweep: number; +}; + +export type OpenAlert = { + key: string; + severity: string; + detail: string | null; + observed: number | null; + opened_at: string; + last_notified_at: string | null; + /** 0 means open and undelivered: a webhook outage, or nowhere configured. */ + notify_count: number; +}; + +/** + * How far behind the audit sweep is. + * + * `past_retention` staying high across runs means the sweep is not keeping up — + * worth knowing before the table is the reason an operations query times out, + * rather than after. + */ +export type AuditRetentionStatus = { + total_entries: number; + oldest_entry: string | null; + past_retention: number; + retention_days: number; + /** Security entries are kept longer, so a non-zero `past_retention` against + * a small window is not automatically a backlog. */ + security_retention_days: number; +}; diff --git a/src/application/organisation/OrgApi.ts b/src/application/organisation/OrgApi.ts new file mode 100644 index 0000000..01dae83 --- /dev/null +++ b/src/application/organisation/OrgApi.ts @@ -0,0 +1,95 @@ +import { apiClient } from "../../lib/apiClient"; +import type { OrgMember, OrgUnit, SeatSummary } from "./OrgTypes"; + +export const orgApi = { + /** Ordered so parents come before their children — the server sorts on the + * materialised path, which gives depth-first order without a second pass. */ + list: () => apiClient.get("/api/org-units"), + + create: (payload: { name: string; code?: string | null; parent_id?: string | null }) => + apiClient.post("/api/org-units", payload, { + successMessage: "Unit created", + errorMessage: "Could not create the unit", + }), + + rename: (id: string, payload: { name?: string; code?: string | null }) => + apiClient.put(`/api/org-units/${id}`, payload, { + successMessage: "Unit updated", + errorMessage: "Could not update the unit", + }), + + /** Rewrites every descendant's path on the server. Expensive and rare, which + * is the trade that keeps the permission check to one indexed prefix match. */ + move: (id: string, parentId: string | null) => + apiClient.post(`/api/org-units/${id}/move`, { parent_id: parentId }, { + successMessage: "Unit moved", + errorMessage: "Could not move the unit", + }), + + /** Refused while children or members remain — a cascade would dissolve a + * sub-tree and quietly widen everybody scoped to one of them. */ + remove: (id: string) => + apiClient.delete(`/api/org-units/${id}`, { + successMessage: "Unit deleted", + errorMessage: "Could not delete the unit", + }), + + members: (id: string) => + apiClient.get(`/api/org-units/${id}/members`, { toast: false }), + + addMember: ( + id: string, + payload: { user_id: string; primary?: boolean; lead?: boolean } + ) => + apiClient.post(`/api/org-units/${id}/members`, payload, { + successMessage: "Added to the unit", + errorMessage: "Could not add them", + }), + + removeMember: (id: string, userId: string) => + apiClient.delete(`/api/org-units/${id}/members/${userId}`, { + successMessage: "Removed from the unit", + errorMessage: "Could not remove them", + }), + + /** + * Confine somebody's user administration to this unit and everything under it. + * + * This only ever *narrows*. Somebody with no scopes administers the whole + * workspace — which is what every administrator is today — so granting one + * takes access away and never adds any. + */ + grantScope: (id: string, userId: string) => + apiClient.post(`/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(`/api/org-units/${id}/administrators/${userId}`, { + successMessage: "Scope removed", + errorMessage: "Could not remove the scope", + }), + + /** The workspace's seat position. Each unit's own allocation arrives with the + * unit in `list`, so this is one request rather than one per branch. */ + seatSummary: () => + apiClient.get("/api/org-units/seats", { toast: false }), + + /** + * Cap one unit, or remove its cap by passing null. + * + * Null is not zero. Zero means nobody may be in this unit; removing the cap + * means the unit has none of its own and only the workspace limit applies. + * + * The server refuses a limit below the unit's current headcount and refuses a + * total above what was bought, and says which in the message — so the error + * is shown rather than replaced with something vaguer. + */ + setSeats: (id: string, seatLimit: number | null) => + apiClient.put( + `/api/org-units/${id}/seats`, + { seat_limit: seatLimit }, + { toast: false } + ), +}; diff --git a/src/application/organisation/OrgTypes.ts b/src/application/organisation/OrgTypes.ts new file mode 100644 index 0000000..d8475f7 --- /dev/null +++ b/src/application/organisation/OrgTypes.ts @@ -0,0 +1,42 @@ +export type OrgUnit = { + id: string; + name: string; + code?: string | null; + parent_id?: string | null; + /** Materialised ancestry, `/root/child/self/`. The list arrives ordered by it, + * which is depth-first order for free — a child's path is its parent's plus + * one segment. */ + path: string; + depth: number; + is_active: boolean; + /** Absent means unconstrained rather than zero. A unit with no allocation is + * bounded only by the workspace, and rendering a `0` there would read as + * "nobody may join this branch" — the opposite of what it means. */ + seat_limit?: number | null; + /** Active members, counted the way the server's guard counts them. If the + * screen counted differently it would say a branch is full while the server + * let somebody in. */ + seats_used: number; +}; + +/** What was bought, what is spoken for, and what is left to give. + * + * `purchased` is null on a plan with no seat limit, and `unallocated` is null + * with it — there is no such thing as "left to give" out of an unbounded + * supply, and showing a number there would invent one. */ +export type SeatSummary = { + purchased: number | null; + allocated: number; + unallocated: number | null; +}; + +export type OrgMember = { + user_id: string; + email: string; + /** Somebody can be in several units; exactly one is primary, and that is the + * one shown beside their name. */ + is_primary: boolean; + /** A lead runs the unit. Separate from administering it — the two are + * frequently different people. */ + is_lead: boolean; +}; diff --git a/src/application/organisation/OrganisationPage.tsx b/src/application/organisation/OrganisationPage.tsx new file mode 100644 index 0000000..9818ebb --- /dev/null +++ b/src/application/organisation/OrganisationPage.tsx @@ -0,0 +1,541 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ChevronRight, + CornerDownRight, + Network, + Plus, + ShieldCheck, + Trash2, + UserMinus, + UserPlus, + Users, +} from "lucide-react"; + +import { + CustomButton, + CustomConfirmationModal, + CustomInput, + CustomLoader, + CustomModal, + CustomSearchableDropdown, +} from "../../components/custom"; +import { usersApi } from "../users/UserApi"; +import type { User } from "../users/UserTypes"; +import { orgApi } from "./OrgApi"; +import type { OrgMember, OrgUnit, SeatSummary } from "./OrgTypes"; +import SeatAllocationDialog from "./SeatAllocationDialog"; +import SeatSummaryStrip from "./SeatSummaryStrip"; + +/** + * Departments, branches and teams — and who administers which. + * + * ## What the screen has to convey that the API cannot + * + * **Scoping only narrows.** Somebody with no scopes administers the whole + * workspace, which is what every administrator is today. Granting a scope takes + * access away; removing the last one gives it back. That reads backwards from + * "revoke", so the screen says it in as many words rather than leaving somebody + * to discover it. + * + * **A unit will not delete while anything depends on it.** The server refuses, + * on purpose — a cascade would dissolve a department's whole sub-tree and + * quietly widen every administrator scoped to one of them — and the message + * explains what to do instead. + * + * The tree is rendered from the materialised path rather than by recursion: the + * list already arrives parents-first, so indentation is `depth` and nothing has + * to be assembled. + */ + +const UnitDetail: React.FC<{ + unit: OrgUnit; + people: User[]; + onChanged: () => void; +}> = ({ unit, people, onChanged }) => { + const { t } = useTranslation(["organisation", "common"]); + + const [members, setMembers] = useState([]); + 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 ( +
+ {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : members.length === 0 ? ( +

+ {t("members.empty")} +

+ ) : ( +
    + {members.map((member) => ( +
  • +
    + + {member.email} + + {member.is_lead && ( + + {t("members.lead")} + + )} +
    + + void scope(member)} + title={t("scope.explain")} + > + + {t("scope.grant")} + + void unscope(member)} + > + {t("scope.revoke")} + + void remove(member)} + > + + +
  • + ))} +
+ )} + +
+

+ {t("members.add")} +

+
+
+ ({ + label: person.email, + value: person.id, + }))} + /> +
+ + + {t("members.addButton")} + +
+
+ + {/* Separate from membership on purpose. Administering a unit and being in + it are different things — a regional HR administrator looks after a + branch they have never worked at — and offering this only against the + member list would quietly make the narrower case the only one. */} +
+

+ {t("scope.title")} +

+

+ {t("scope.note")} +

+
+
+ ({ + label: person.email, + value: person.id, + }))} + /> +
+ + + {t("scope.grant")} + +
+
+
+ ); +}; + +const OrganisationPage: React.FC = () => { + const { t } = useTranslation(["organisation", "common"]); + + const [units, setUnits] = useState([]); + const [people, setPeople] = useState([]); + 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(null); + const [pendingRemove, setPendingRemove] = useState(null); + const [removeError, setRemoveError] = useState(""); + + const [seats, setSeats] = useState(null); + const [seatUnit, setSeatUnit] = useState(null); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + setUnits(await orgApi.list()); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + + // Separately, and allowed to fail on its own: the seat position is useful + // context, but a workspace whose plan lookup hiccups must still be able to + // manage its structure. The strip renders nothing rather than zeroes. + try { + setSeats(await orgApi.seatSummary()); + } catch { + setSeats(null); + } + }, []); + + useEffect(() => { + void load(); + // People are needed to add somebody to a unit. A failure here must not stop + // the structure being managed, so the dropdown simply comes up empty. + void usersApi + .getAll() + .then(setPeople) + .catch(() => setPeople([])); + }, [load]); + + const create = async () => { + setError(""); + setIsBusy(true); + try { + await orgApi.create({ + name: name.trim(), + code: code.trim() || null, + parent_id: parentId || null, + }); + setIsCreateOpen(false); + setName(""); + setCode(""); + setParentId(""); + await load(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const remove = async () => { + if (!pendingRemove) return; + const target = pendingRemove; + setPendingRemove(null); + setRemoveError(""); + try { + await orgApi.remove(target.id); + } catch (caught) { + // The server refuses while children or members remain, and that refusal + // is the useful part — it says which. + setRemoveError( + caught instanceof Error ? caught.message : t("errors.generic") + ); + } finally { + await load(); + } + }; + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + setIsCreateOpen(true)}> + + {t("add")} + +
+ + {/* Above the tree: dividing seats between branches without knowing what + is left to give is arithmetic done on paper beside the screen. */} +
+ +
+ + {removeError && ( +
+ {removeError} +
+ )} + +
+ {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : units.length === 0 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
    + {units.map((unit) => ( +
  • + {unit.depth > 0 && ( + + )} +
    + + {unit.name} + + {unit.code && ( + + {unit.code} + + )} +
    + + {/* `used / limit` when capped, and the bare headcount when not. + A capped branch at its limit is the thing somebody is looking + for on this screen, so it is coloured rather than counted. */} + + + setOpen(unit)} + > + {t("members.open")} + + + setPendingRemove(unit)} + > + + +
  • + ))} +
+ )} +
+ + { + setIsCreateOpen(false); + setError(""); + }} + title={t("add")} + > +
+ setName(event.target.value)} + /> + + setCode(event.target.value)} + /> +

{t("form.codeNote")}

+ + ({ + label: `${"— ".repeat(unit.depth)}${unit.name}`, + value: unit.id, + })), + ]} + /> + + {error &&

{error}

} + + + {t("form.submit")} + +
+
+ + setOpen(null)} + title={open?.name ?? ""} + > + {open && ( + + )} + + + setSeatUnit(null)} + onSaved={load} + /> + + setPendingRemove(null)} + onConfirm={remove} + title={t("confirmRemove.title")} + description={t("confirmRemove.message", { name: pendingRemove?.name ?? "" })} + confirmText={t("confirmRemove.confirm")} + /> +
+ ); +}; + +export default OrganisationPage; diff --git a/src/application/organisation/SeatAllocationDialog.test.tsx b/src/application/organisation/SeatAllocationDialog.test.tsx new file mode 100644 index 0000000..88c76d5 --- /dev/null +++ b/src/application/organisation/SeatAllocationDialog.test.tsx @@ -0,0 +1,210 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SeatAllocationDialog from "./SeatAllocationDialog"; +import SeatSummaryStrip from "./SeatSummaryStrip"; + +/** + * The distinction this dialog exists to protect is empty-versus-zero. Removing a + * branch's cap and forbidding anybody from being in it are opposite intentions, + * and one text field has to carry both without conflating them. + */ + +const setSeats = vi.fn(); + +vi.mock("./OrgApi", () => ({ + orgApi: { + setSeats: (id: string, limit: number | null) => setSeats(id, limit), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options && Object.keys(options).length + ? `${key}:${JSON.stringify(options)}` + : key, + i18n: { language: "en" }, + }), +})); + +const unit = (overrides: Record = {}) => ({ + id: "u1", + name: "Lahore", + code: null, + parent_id: null, + path: "/u1/", + depth: 0, + is_active: true, + seat_limit: null as number | null, + seats_used: 2, + ...overrides, +}); + +beforeEach(() => { + setSeats.mockReset().mockResolvedValue({ + purchased: 50, + allocated: 10, + unallocated: 40, + }); +}); + +describe("SeatAllocationDialog", () => { + it("sends null when the box is cleared, not zero", async () => { + // Zero means nobody may be in this branch. Null means it has no cap of + // its own. Sending the wrong one empties a branch nobody asked to empty. + const user = userEvent.setup({ delay: null }); + render( + + ); + + 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( + + ); + + const field = screen.getByRole("spinbutton"); + await user.clear(field); + await user.type(field, "0"); + await user.click(screen.getByRole("button", { name: /actions\.save/ })); + + await waitFor(() => expect(setSeats).toHaveBeenCalledWith("u1", 0)); + }); + + it("opens showing the branch's own limit rather than the last one edited", async () => { + // One component is reused for every row. A stale value here would + // silently re-cap the wrong branch at the previous one's number. + const { rerender } = render( + + ); + expect(screen.getByRole("spinbutton")).toHaveValue(8); + + rerender( + + ); + await waitFor(() => expect(screen.getByRole("spinbutton")).toHaveValue(3)); + }); + + it("opens empty for a branch that has no cap", async () => { + render( + + ); + expect(screen.getByRole("spinbutton")).toHaveValue(null); + }); + + it("shows the server's refusal rather than a generic message", async () => { + // The server's message names the number — "at most 2 can go to Lahore". + // That is the only part that says what to type instead. + setSeats.mockRejectedValue(new Error("At most 2 can go to Lahore.")); + + const user = userEvent.setup({ delay: null }); + const onSaved = vi.fn(); + render( + + ); + + const field = screen.getByRole("spinbutton"); + await user.clear(field); + await user.type(field, "9"); + await user.click(screen.getByRole("button", { name: /actions\.save/ })); + + expect(await screen.findByText("At most 2 can go to Lahore.")).toBeInTheDocument(); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it("stays open when the save fails", async () => { + // Closing on failure would look exactly like success. + setSeats.mockRejectedValue(new Error("nope")); + + const user = userEvent.setup({ delay: null }); + const onClose = vi.fn(); + render( + + ); + + 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( + + ); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe("SeatSummaryStrip", () => { + it("shows what is left to give", async () => { + render(); + + expect(screen.getByText("50")).toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + expect(screen.getByText("38")).toBeInTheDocument(); + }); + + it("does not invent a remainder out of an unlimited plan", () => { + // "Left to give" out of an unbounded supply is not a quantity. + render(); + + expect(screen.getByText("seats.unlimited")).toBeInTheDocument(); + expect(screen.queryByText("seats.unallocated")).not.toBeInTheDocument(); + }); + + it("shows nothing rather than zeroes when the position is unknown", () => { + // A strip reading "0 of 0" is a claim about the customer's plan that a + // failed request puts us in no position to make. + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/application/organisation/SeatAllocationDialog.tsx b/src/application/organisation/SeatAllocationDialog.tsx new file mode 100644 index 0000000..4a27033 --- /dev/null +++ b/src/application/organisation/SeatAllocationDialog.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { CustomButton, CustomInput, CustomModal } from "../../components/custom"; +import { orgApi } from "./OrgApi"; +import type { OrgUnit } from "./OrgTypes"; + +/** + * How many of the workspace's seats this branch may use. + * + * The workspace limit was always enforced; it was simply the wrong grain for an + * organisation whose branches have their own budgets. One branch could use + * forty-eight of fifty seats and nobody found out until another branch could not + * add anybody. + * + * Two things this screen has to get right, because both are refusals the server + * will make and the person needs to understand *before* they type: + * + * - **Empty is not zero.** Clearing the box removes the cap, so only the + * workspace limit applies. Typing `0` means nobody may be in this branch. + * They are different intentions and the field cannot conflate them. + * - **The floor is the current headcount.** A branch instantly over its limit, + * with no action that caused it, is a number somebody has to fix by removing + * a person. + */ + +const SeatAllocationDialog: React.FC<{ + unit: OrgUnit | null; + /** What is left to give, excluding this unit's own current allocation. Null + * when the plan has no seat limit at all, in which case there is no ceiling + * to warn about. */ + availableToThisUnit: number | null; + onClose: () => void; + onSaved: () => void | Promise; +}> = ({ unit, availableToThisUnit, onClose, onSaved }) => { + const { t } = useTranslation(["organisation", "common"]); + + const [value, setValue] = useState(""); + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + // Re-seeded per unit rather than once: the dialog is one component reused + // for every row, and a stale value here would silently re-cap the wrong + // branch at the previous one's number. + setValue(unit?.seat_limit == null ? "" : String(unit.seat_limit)); + setError(""); + }, [unit]); + + if (!unit) return null; + + const trimmed = value.trim(); + const parsed = trimmed === "" ? null : Number(trimmed); + const isValid = + parsed === null || (Number.isInteger(parsed) && parsed >= 0); + + const save = async () => { + if (!isValid) { + setError(t("seats.notAWholeNumber")); + return; + } + setError(""); + setIsBusy(true); + try { + await orgApi.setSeats(unit.id, parsed); + await onSaved(); + onClose(); + } catch (failure) { + // The server's message names the actual number — "already has 3 + // active members", "at most 2 can go to Lahore". Replacing it with + // something generic would take away the only part that tells the + // person what to type instead. + setError( + failure instanceof Error && failure.message + ? failure.message + : t("seats.saveFailed") + ); + } finally { + setIsBusy(false); + } + }; + + return ( + +
+

+ {t("seats.explanation")} +

+ + setValue(event.target.value)} + /> + +

+ {t("seats.currentUse", { count: unit.seats_used })} + {availableToThisUnit !== null && ( + <> + {" · "} + {t("seats.availableToThisUnit", { count: availableToThisUnit })} + + )} +

+ + {/* Said before they press it, not after the server refuses. */} +

+ {t("seats.emptyMeansUncapped")} +

+ + {error && ( +

{error}

+ )} + +
+ + {t("common:actions.cancel")} + + + {t("common:actions.save")} + +
+
+
+ ); +}; + +export default SeatAllocationDialog; diff --git a/src/application/organisation/SeatSummaryStrip.tsx b/src/application/organisation/SeatSummaryStrip.tsx new file mode 100644 index 0000000..64854ea --- /dev/null +++ b/src/application/organisation/SeatSummaryStrip.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import type { SeatSummary } from "./OrgTypes"; + +/** + * What was bought, what is spoken for, and what is left to give. + * + * Without the third number, dividing seats between branches is arithmetic done + * on paper beside the screen — and the person only finds out they overcommitted + * when the server refuses the last one. + */ + +const SeatSummaryStrip: React.FC<{ summary: SeatSummary | null }> = ({ summary }) => { + const { t } = useTranslation(["organisation", "common"]); + + // Nothing rather than zeroes. A strip reading "0 of 0" while the request is + // in flight, or after it failed, is a claim about the customer's plan that + // we are in no position to make. + if (!summary) return null; + + // An unlimited plan has no ceiling to divide, so the only honest number is + // what has been handed out. "Unallocated" out of an unbounded supply is not + // a quantity. + const isUnlimited = summary.purchased === null; + + return ( +
+ {!isUnlimited && ( + + {t("seats.purchased")}{" "} + + {summary.purchased} + + + )} + + + {t("seats.allocated")}{" "} + + {summary.allocated} + + + + {isUnlimited ? ( + {t("seats.unlimited")} + ) : ( + + {t("seats.unallocated")}{" "} + + {summary.unallocated} + + + )} +
+ ); +}; + +export default SeatSummaryStrip; diff --git a/src/application/profile/ProfilePage.tsx b/src/application/profile/ProfilePage.tsx index ec86485..b731e4b 100644 --- a/src/application/profile/ProfilePage.tsx +++ b/src/application/profile/ProfilePage.tsx @@ -7,6 +7,9 @@ import { Key, Pencil } from "lucide-react"; import type { PasswordForm, FormatDateFunction, FormatNameFunction, RenderStatusBadgeFunction } from "./ProfileTypes"; import { useTheme } from "../../context/ThemeContext"; import { paletteApi } from "../theme/PaletteApi"; +import SessionsPanel from "./SessionsPanel"; +import SecurityPanel from "../security/SecurityPanel"; +import NotificationPreferencesPanel from "../notifications/NotificationPreferencesPanel"; import type { ColorPalette } from "../theme/ThemeTypes"; type ProfileForm = { @@ -371,6 +374,19 @@ const ProfilePage: React.FC = () => { + {/* Security before sessions, and both above appearance: the order is + "what protects the account", then "where it is signed in", then + preferences. A second factor is the one that changes the others' + worth. */} + + + + + {/* After sessions and before appearance: it is a preference rather + than a protection, but it is the one preference where the wrong + answer is discovered as silence. */} + + {/* Appearance / Theme Section */}

diff --git a/src/application/profile/ProfileTypes.ts b/src/application/profile/ProfileTypes.ts index 312bc70..6375a9d 100644 --- a/src/application/profile/ProfileTypes.ts +++ b/src/application/profile/ProfileTypes.ts @@ -11,3 +11,16 @@ export type FormatDateFunction = (value: string) => string; export type FormatNameFunction = (firstName: string, lastName?: string | null) => string; export type RenderStatusBadgeFunction = (status?: string) => JSX.Element; + +/** One place this account is signed in. */ +export interface UserSession { + id: string; + /** Raw, as sent. Interpreted for display only — the client chooses it. */ + user_agent: string | null; + ip_address: string | null; + created_at: string; + last_used_at: string; + expires_at: string; + /** The session this browser is holding. Flagged so the list is safe to act on. */ + is_current: boolean; +} diff --git a/src/application/profile/SessionsPanel.test.tsx b/src/application/profile/SessionsPanel.test.tsx new file mode 100644 index 0000000..d0b04d6 --- /dev/null +++ b/src/application/profile/SessionsPanel.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SessionsPanel from "./SessionsPanel"; + +/** + * A list of sessions is only useful if you can tell which one you are holding. + * Without that flag nobody dares press anything — and the button that matters, + * "sign out everywhere else", is precisely the one you press when you have just + * lost a laptop and cannot afford to also lose the session you are using. + */ + +const listSessions = vi.fn(); +const endSession = vi.fn(); +const endOtherSessions = vi.fn(); + +vi.mock("../authentication/AuthApi", () => ({ + authApi: { + listSessions: () => listSessions(), + endSession: (id: string) => endSession(id), + endOtherSessions: () => endOtherSessions(), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +const session = (overrides: Record = {}) => ({ + id: "s1", + user_agent: "Mozilla/5.0 (Windows NT 10.0) Chrome/120.0", + ip_address: "203.0.113.7", + created_at: "2026-01-01T00:00:00Z", + last_used_at: new Date().toISOString(), + expires_at: "2027-01-01T00:00:00Z", + is_current: false, + ...overrides, +}); + +beforeEach(() => { + listSessions.mockReset(); + endSession.mockReset().mockResolvedValue({ message: "ok" }); + endOtherSessions.mockReset().mockResolvedValue({ message: "ok", ended: 1 }); +}); + +describe("SessionsPanel", () => { + it("names the device rather than showing the raw user agent", async () => { + // A user agent string is not for reading. Two things a person recognises + // — roughly what browser, roughly what platform — and nothing more. + listSessions.mockResolvedValue([session()]); + render(); + + 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(); + + 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(); + + expect(await screen.findByText("fields.thisDevice")).toBeInTheDocument(); + }); + + it("offers no end button for the current session", async () => { + // A button that logs you out of the page you are standing on reads as a + // mistake. Signing out is what ends the current session. + listSessions.mockResolvedValue([session({ id: "mine", is_current: true })]); + render(); + + 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(); + + await userEvent.click(await screen.findByText("buttons.endSession")); + + expect(endSession).toHaveBeenCalledWith("other"); + // Removed locally rather than by refetching: the row should go the + // moment it is ended, not after a round trip. + await waitFor(() => + expect(screen.queryByText("buttons.endSession")).not.toBeInTheDocument() + ); + }); + + it("hides sign-out-everywhere when there is nowhere else", async () => { + listSessions.mockResolvedValue([session({ id: "mine", is_current: true })]); + render(); + + 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(); + + await userEvent.click(await screen.findByText("buttons.signOutOthers")); + + expect(endOtherSessions).toHaveBeenCalled(); + await waitFor(() => + expect(screen.getByText("fields.thisDevice")).toBeInTheDocument() + ); + expect(screen.queryByText("buttons.endSession")).not.toBeInTheDocument(); + }); + + it("admits it could not load rather than claiming no sessions", async () => { + // "No other sessions" is a stronger and possibly false claim than "we do + // not know". On a security control the difference matters: one invites + // you to relax, the other to look again. + listSessions.mockRejectedValue(new Error("network")); + render(); + + 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(); + + await waitFor(() => + expect(screen.getByText("messages.noSessions")).toBeInTheDocument() + ); + }); +}); diff --git a/src/application/profile/SessionsPanel.tsx b/src/application/profile/SessionsPanel.tsx new file mode 100644 index 0000000..db63f06 --- /dev/null +++ b/src/application/profile/SessionsPanel.tsx @@ -0,0 +1,211 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Laptop, LogOut, Smartphone, Trash2 } from "lucide-react"; + +import { CustomButton, CustomLoader } from "../../components/custom"; +import { authApi } from "../authentication/AuthApi"; +import type { UserSession } from "./ProfileTypes"; + +/** + * Where this account is signed in, and how to end it. + * + * The platform rotated and revoked refresh tokens already; what it never did was + * show anyone the result. Someone who suspected a session they did not recognise + * had no option but to change their password and hope. + */ + +/** + * A user agent string is not for reading. This turns it into the two things a + * person actually recognises about a session — roughly what kind of device, and + * roughly what browser — and stops there. Guessing harder from a string the + * client controls produces confident nonsense. + */ +const describeDevice = (userAgent?: string | null) => { + if (!userAgent) return { label: null as string | null, isMobile: false }; + + const isMobile = /Mobile|Android|iPhone|iPad/i.test(userAgent); + const browser = + /Edg\//.test(userAgent) ? "Edge" + : /OPR\/|Opera/.test(userAgent) ? "Opera" + : /Chrome\//.test(userAgent) ? "Chrome" + : /Safari\//.test(userAgent) ? "Safari" + : /Firefox\//.test(userAgent) ? "Firefox" + : null; + const platform = + /Windows/.test(userAgent) ? "Windows" + : /Android/.test(userAgent) ? "Android" + : /iPhone|iPad|iOS/.test(userAgent) ? "iOS" + : /Mac OS X|Macintosh/.test(userAgent) ? "macOS" + : /Linux/.test(userAgent) ? "Linux" + : null; + + const label = [browser, platform].filter(Boolean).join(" · ") || null; + return { label, isMobile }; +}; + +const relativeTime = (value: string, locale: string) => { + const then = new Date(value).getTime(); + if (Number.isNaN(then)) return value; + + const seconds = Math.round((then - Date.now()) / 1000); + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + const steps: [Intl.RelativeTimeFormatUnit, number][] = [ + ["second", 60], + ["minute", 60], + ["hour", 24], + ["day", 7], + ["week", 4.35], + ["month", 12], + ]; + + let amount = seconds; + for (const [unit, size] of steps) { + if (Math.abs(amount) < size) return formatter.format(Math.round(amount), unit); + amount /= size; + } + return formatter.format(Math.round(amount), "year"); +}; + +const SessionsPanel: React.FC = () => { + const { t, i18n } = useTranslation(["profile", "common"]); + + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [failed, setFailed] = useState(false); + const [isRevokingOthers, setIsRevokingOthers] = useState(false); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + setSessions(await authApi.listSessions()); + } catch { + // An empty list would assert "you are signed in nowhere else" — a + // stronger and possibly false claim than admitting we do not know. + // On a security control the difference matters. + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const endSession = async (id: string) => { + setBusyId(id); + try { + await authApi.endSession(id); + setSessions((current) => current.filter((session) => session.id !== id)); + } finally { + setBusyId(null); + } + }; + + const endOthers = async () => { + setIsRevokingOthers(true); + try { + await authApi.endOtherSessions(); + setSessions((current) => current.filter((session) => session.is_current)); + } finally { + setIsRevokingOthers(false); + } + }; + + const others = sessions.filter((session) => !session.is_current); + + return ( +
+
+
+

+ {t("sections.sessions")} +

+

+ {t("sections.sessionsDesc")} +

+
+ + {others.length > 0 && ( + + + {t("buttons.signOutOthers")} + + )} +
+ + {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("messages.loadFailed")} +

+ ) : sessions.length === 0 ? ( +

+ {t("messages.noSessions")} +

+ ) : ( +
    + {sessions.map((session) => { + const { label, isMobile } = describeDevice(session.user_agent); + const DeviceIcon = isMobile ? Smartphone : Laptop; + + return ( +
  • + + +
    +
    + + {label ?? t("fields.unknownDevice")} + + {session.is_current && ( + + {t("fields.thisDevice")} + + )} +
    +

    + {[ + session.ip_address, + relativeTime(session.last_used_at, i18n.language), + ] + .filter(Boolean) + .join(" · ")} +

    +
    + + {/* The current session is ended by signing out, not from + here — a button that logs you out of the page you are + standing on reads as a mistake. */} + {!session.is_current && ( + endSession(session.id)} + disabled={busyId === session.id} + > + + {t("buttons.endSession")} + + )} +
  • + ); + })} +
+ )} +
+ ); +}; + +export default SessionsPanel; diff --git a/src/application/reference/ReferenceApi.ts b/src/application/reference/ReferenceApi.ts new file mode 100644 index 0000000..4200e8f --- /dev/null +++ b/src/application/reference/ReferenceApi.ts @@ -0,0 +1,89 @@ +import { apiClient } from "../../lib/apiClient"; + +export type ReferenceList = { + id: string; + code: string; + name: string; + description?: string | null; + allows_custom_items: boolean; + /** The platform owns it: visible to you, not yours to change — though you may + * usually add your own items to it. */ + is_platform: boolean; +}; + +export type ReferenceItem = { + id: string; + code: string; + label: string; + sort_order: number; + is_active: boolean; + metadata_json?: Record | null; + is_platform: boolean; +}; + +/** + * Reference lists — the things dropdowns are made of. + * + * Reading needs only a session, deliberately: a picker is needed by every + * screen, and a permission on it would mean a form that renders empty rather + * than one that refuses. + * + * There is no "rename a code" call. A code is what integrations name and stored + * records point at, so changing one is a silent data migration disguised as an + * edit — the API has no field for it and neither does this. + */ +export const referenceApi = { + lists: () => apiClient.get("/api/reference", { toast: false }), + + items: (code: string, includeInactive = false) => + apiClient.get( + `/api/reference/${encodeURIComponent(code)}/items` + + (includeInactive ? "?include_inactive=true" : ""), + { toast: false } + ), + + createList: (payload: { code: string; name: string; description?: string | null }) => + apiClient.post("/api/reference", payload, { + successMessage: "List created", + errorMessage: "Could not create the list", + }), + + renameList: (id: string, payload: { name?: string; description?: string | null }) => + apiClient.put(`/api/reference/${id}`, payload, { + successMessage: "List updated", + errorMessage: "Could not update the list", + }), + + deleteList: (id: string) => + apiClient.delete(`/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( + `/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(`/api/reference/items/${id}`, payload, { + successMessage: "Item updated", + errorMessage: "Could not update the item", + }), + + /** Retires rather than deletes, and returns the item to say so — a record + * from last year still points at it. */ + retireItem: (id: string) => + apiClient.delete(`/api/reference/items/${id}`, { + successMessage: "Item retired", + errorMessage: "Could not retire the item", + }), +}; diff --git a/src/application/reference/ReferencePage.tsx b/src/application/reference/ReferencePage.tsx new file mode 100644 index 0000000..9ae3d7e --- /dev/null +++ b/src/application/reference/ReferencePage.tsx @@ -0,0 +1,430 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronRight, List, Lock, Plus, Trash2, Undo2 } from "lucide-react"; + +import { + CustomButton, + CustomConfirmationModal, + CustomInput, + CustomLoader, + CustomModal, +} from "../../components/custom"; +import { referenceApi } from "./ReferenceApi"; +import type { ReferenceItem, ReferenceList } from "./ReferenceApi"; + +/** + * Reference lists. + * + * The screen exists to make one asymmetry obvious, because it is surprising + * until it is explained: **a list the platform maintains is visible and not + * editable — but you can usually add your own items to it.** Both halves matter. + * Somebody who thinks they cannot extend a standard list will copy it, and the + * copy drifts the moment the standard one changes. + * + * So platform rows are marked, platform items are marked, and the edit controls + * are simply absent on them rather than present and failing. + * + * Retiring is offered instead of deleting, and says which: a record from last + * year still points at the item, and removing it would break the report that + * shows it months after the change that caused it. + */ + +const ItemsPanel: React.FC<{ list: ReferenceList; onChanged: () => void }> = ({ + list, + onChanged, +}) => { + const { t } = useTranslation(["reference", "common"]); + + const [items, setItems] = useState([]); + 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 ( +
+ {list.is_platform && ( +

+ {canAdd ? t("platform.extendable") : t("platform.closed")} +

+ )} + + {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : items.length === 0 ? ( +

+ {t("items.empty")} +

+ ) : ( +
    + {items.map((item) => ( +
  • +
    + + {item.label} + + + {item.code} + + {item.is_platform && ( + + + {t("platform.badge")} + + )} + {!item.is_active && ( + + {t("items.retired")} + + )} +
    + + {/* Absent rather than present-and-failing on platform items. A + button that always refuses teaches people to ignore buttons. */} + {!item.is_platform && + (item.is_active ? ( + void retire(item)} + > + + + ) : ( + void restore(item)} + > + + {t("items.restore")} + + ))} +
  • + ))} +
+ )} + + + + {canAdd && ( +
+

+ {t("items.add")} +

+
+ setLabel(event.target.value)} + /> + setCode(event.target.value)} + /> +
+

+ {t("items.codeNote")} +

+ + {error &&

{error}

} + + + + {t("items.addButton")} + +
+ )} +
+ ); +}; + +const ReferencePage: React.FC = () => { + const { t } = useTranslation(["reference", "common"]); + + const [lists, setLists] = useState([]); + 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(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleteError, setDeleteError] = useState(""); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + setLists(await referenceApi.lists()); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const create = async () => { + setError(""); + setIsBusy(true); + try { + await referenceApi.createList({ code: code.trim(), name: name.trim() }); + setIsCreateOpen(false); + setCode(""); + setName(""); + await load(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const remove = async () => { + if (!pendingDelete) return; + const target = pendingDelete; + setPendingDelete(null); + setDeleteError(""); + try { + await referenceApi.deleteList(target.id); + } catch (caught) { + // The server refuses while it still holds items, and that refusal is the + // useful part — it says how many. + setDeleteError( + caught instanceof Error ? caught.message : t("errors.generic") + ); + } finally { + await load(); + } + }; + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + setIsCreateOpen(true)}> + + {t("add")} + +
+ + {deleteError && ( +
+ {deleteError} +
+ )} + +
+ {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : lists.length === 0 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
    + {lists.map((list) => ( +
  • +
    + + {list.name} + + + {list.code} + + {list.is_platform && ( + + + {t("platform.badge")} + + )} +
    + + setOpen(list)} + > + {t("items.open")} + + + {!list.is_platform && ( + setPendingDelete(list)} + > + + + )} +
  • + ))} +
+ )} +
+ + { + setIsCreateOpen(false); + setError(""); + }} + title={t("add")} + > +
+ setName(event.target.value)} + /> + setCode(event.target.value)} + /> +

+ {t("form.codeNote")} +

+ + {error &&

{error}

} + + + {t("form.submit")} + +
+
+ + setOpen(null)} + title={open?.name ?? ""} + > + {open && } + + + setPendingDelete(null)} + onConfirm={remove} + title={t("confirmDelete.title")} + description={t("confirmDelete.message", { name: pendingDelete?.name ?? "" })} + confirmText={t("confirmDelete.confirm")} + /> +
+ ); +}; + +export default ReferencePage; diff --git a/src/application/security/SecurityApi.ts b/src/application/security/SecurityApi.ts new file mode 100644 index 0000000..544fa79 --- /dev/null +++ b/src/application/security/SecurityApi.ts @@ -0,0 +1,61 @@ +import { apiClient } from "../../lib/apiClient"; +import type { + MfaEnrolmentStarted, + MfaRecoveryCodes, + MfaStatus, +} from "./SecurityTypes"; + +/** + * Managing the second factor on your own account. + * + * Every call here acts on the caller — there is no user id in any path. An + * administrator cannot enrol a factor on somebody else's behalf, because a + * factor somebody else set up is not a second factor, it is a second person who + * can sign in as them. + * + * `disable` and `regenerateRecoveryCodes` send the password as well as a code. + * A session token is enough to *use* the account — that is what a session is — + * but it must not be enough to disarm it, or a laptop left open removes the + * protection and keeps the access. + */ +export const securityApi = { + status: () => apiClient.get("/api/auth/mfa"), + + beginEnrolment: () => + apiClient.post("/api/auth/mfa/enrol", null, { + // No toast: nothing has happened yet. The factor is inactive until a code + // from it has been presented, and saying "created" here would tell + // somebody they are protected when they are not. + toast: false, + }), + + confirmEnrolment: (code: string) => + apiClient.post( + "/api/auth/mfa/confirm", + { code }, + { + successMessage: "Two-factor authentication is on", + errorMessage: "That code is not correct", + } + ), + + regenerateRecoveryCodes: (password: string, code?: string) => + apiClient.post( + "/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( + "/api/auth/mfa/disable", + { password, code }, + { + successMessage: "Two-factor authentication is off", + errorMessage: "Could not turn it off", + } + ), +}; diff --git a/src/application/security/SecurityPanel.test.tsx b/src/application/security/SecurityPanel.test.tsx new file mode 100644 index 0000000..119e8c2 --- /dev/null +++ b/src/application/security/SecurityPanel.test.tsx @@ -0,0 +1,224 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SecurityPanel from "./SecurityPanel"; + +/** + * The second-factor panel. + * + * The state worth testing hardest is the middle one: a secret generated and + * never proved. The factor does *nothing* then, and somebody who scanned a code + * and walked away believing they were protected is worse off than somebody who + * never started — so the screen has to say so rather than showing a neutral + * "pending". + * + * The rest is about not letting a session token disarm the account, and about + * the recovery codes being hard to dismiss by reflex, since that is the only + * moment they exist. + */ + +const status = vi.fn(); +const beginEnrolment = vi.fn(); +const confirmEnrolment = vi.fn(); +const disable = vi.fn(); +const regenerate = vi.fn(); + +vi.mock("./SecurityApi", () => ({ + securityApi: { + status: () => status(), + beginEnrolment: () => beginEnrolment(), + confirmEnrolment: (code: string) => confirmEnrolment(code), + disable: (password: string, code?: string) => disable(password, code), + regenerateRecoveryCodes: (password: string, code?: string) => + regenerate(password, code), + }, +})); + +// The QR library sits behind a module of ours, so the test mocks that rather +// than reaching through to the package's own interop. +// A plain async function rather than `vi.fn().mockResolvedValue(...)`: the +// factory is hoisted above the imports, and a spy built there resolves to +// `undefined` — which looks exactly like a QR code that failed to render. +vi.mock("./qr", () => ({ + toQrDataUrl: async () => "data:image/png;base64,x", +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + // The key itself, so a test asserting on text is asserting on the key + // rather than on prose somebody may reword. + t: (key: string, options?: Record) => + 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(); + + 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(); + + expect(await screen.findByText("state.pendingWarning")).toBeTruthy(); + // And still shows "off", because that is what it is. + expect(screen.getByText("state.off")).toBeTruthy(); + }); + + it("shows how many recovery codes are left when it is on", async () => { + status.mockResolvedValue(on); + render(); + + 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(); + + expect(await screen.findByText("on.runningLow")).toBeTruthy(); + }); + + it("does not claim it is off when it could not ask", async () => { + // Showing "two-factor is off" when the request failed is a false claim + // about a security control, and the one that would make somebody act. + status.mockRejectedValue(new Error("network")); + render(); + + expect(await screen.findByText("errors.loadFailed")).toBeTruthy(); + expect(screen.queryByText("off.start")).toBeNull(); + }); +}); + +describe("enrolling", () => { + it("shows the secret as text as well as a QR code", async () => { + // Every authenticator app accepts a typed key, and somebody reading the + // screen on the same device they are enrolling cannot scan it. + const user = userEvent.setup({ delay: null }); + status.mockResolvedValue(off); + beginEnrolment.mockResolvedValue({ + secret: "JBSWY3DPEHPK3PXP", + otpauth_uri: "otpauth://totp/x", + }); + render(); + + 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(); + + 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(); + + await user.click(await screen.findByText("off.start")); + const field = await screen.findByLabelText("enrol.codeLabel"); + await user.type(field, "000000"); + await user.click(screen.getByText("enrol.confirm")); + + await waitFor(() => expect(field).toHaveValue("")); + }); + + it("will not let the recovery codes be dismissed unread", async () => { + // This is the only time they exist in readable form. A modal somebody + // can close by reflex is how they get lost. + const user = userEvent.setup({ delay: null }); + status.mockResolvedValue(off); + beginEnrolment.mockResolvedValue({ secret: "S", otpauth_uri: "otpauth://x" }); + confirmEnrolment.mockResolvedValue({ codes: ["aaaa-bbbb-cccc"] }); + render(); + + await user.click(await screen.findByText("off.start")); + await user.type(await screen.findByLabelText("enrol.codeLabel"), "123456"); + await user.click(screen.getByText("enrol.confirm")); + + const done = await screen.findByText("recovery.done"); + expect(done.closest("button")).toBeDisabled(); + + await user.click(screen.getByLabelText("recovery.acknowledge")); + expect(done.closest("button")).not.toBeDisabled(); + }); +}); + +describe("turning it off", () => { + it("asks for the password, not just the session", async () => { + // A stolen session must not be enough to disarm the account it stole. + const user = userEvent.setup({ delay: null }); + status.mockResolvedValue(on); + render(); + + 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(); + + 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(); + + await user.click(await screen.findByText("on.disable")); + const submit = await screen.findByText("confirm.submit"); + + expect(submit.closest("button")).toBeDisabled(); + }); +}); diff --git a/src/application/security/SecurityPanel.tsx b/src/application/security/SecurityPanel.tsx new file mode 100644 index 0000000..cfca37f --- /dev/null +++ b/src/application/security/SecurityPanel.tsx @@ -0,0 +1,445 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Copy, KeyRound, ShieldCheck, ShieldOff } from "lucide-react"; + +import { + CustomButton, + CustomInput, + CustomLoader, + CustomModal, +} from "../../components/custom"; +import { securityApi } from "./SecurityApi"; +import { toQrDataUrl } from "./qr"; +import type { MfaStatus } from "./SecurityTypes"; + +/** + * The second factor, on your own account. + * + * Three states, and the screen has to make the middle one unmistakable: + * + * - **off** — nothing set up. + * - **half-enrolled** — a secret was generated and never proved. The factor + * does *nothing* in this state. Somebody who scans a code, walks away, and + * believes they are protected is worse off than somebody who never started, + * so this says so in as many words rather than showing a neutral "pending". + * - **on** — a code from the secret has been presented once. + * + * Turning it off costs the password *and* a code. A session token is enough to + * use the account; it must not be enough to disarm it. + */ + +const RecoveryCodes: React.FC<{ codes: string[]; onDone: () => void }> = ({ + codes, + onDone, +}) => { + const { t } = useTranslation(["security", "common"]); + const [acknowledged, setAcknowledged] = useState(false); + + const copyAll = () => { + void navigator.clipboard?.writeText(codes.join("\n")); + }; + + const download = () => { + // A blob rather than a link to a server: these exist only in this response, + // and a URL that could fetch them again would defeat the point of hashing + // them on the way in. + const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "recovery-codes.txt"; + anchor.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+

+ {t("recovery.explain")} +

+ +
    + {codes.map((code) => ( +
  • + {code} +
  • + ))} +
+ +
+ + + {t("recovery.copy")} + + + {t("recovery.download")} + +
+ + + + {/* Deliberately gated. This is the only time these exist in readable form, + and a modal somebody can dismiss by reflex is how they get lost. */} + + {t("recovery.done")} + +
+ ); +}; + +const SecurityPanel: React.FC = () => { + const { t } = useTranslation(["security", "common"]); + + const [status, setStatus] = useState(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(null); + + const [confirmAction, setConfirmAction] = useState<"disable" | "regenerate" | null>( + null + ); + const [password, setPassword] = useState(""); + const [confirmCode, setConfirmCode] = useState(""); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + setStatus(await securityApi.status()); + } catch { + // Not an empty state. Showing "two-factor is off" when we could not ask is + // a false claim about a security control, and the one that would make + // somebody act. + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const startEnrolment = async () => { + setError(""); + setIsBusy(true); + try { + const started = await securityApi.beginEnrolment(); + // Null when it could not be drawn — see `toQrDataUrl`. The secret below is + // shown either way, and every authenticator app accepts it typed. + const qr = await toQrDataUrl(started.otpauth_uri); + setEnrolment({ secret: started.secret, uri: started.otpauth_uri, qr }); + setCode(""); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const confirmEnrolment = async () => { + setError(""); + setIsBusy(true); + try { + const issued = await securityApi.confirmEnrolment(code.trim()); + setEnrolment(null); + setCode(""); + setCodes(issued.codes); + await load(); + } catch (caught) { + // The code is cleared: leaving a spent one in the field means the next + // attempt fails for a reason the person cannot see. + setCode(""); + setError(caught instanceof Error ? caught.message : t("errors.badCode")); + } finally { + setIsBusy(false); + } + }; + + const runConfirmedAction = async () => { + setError(""); + setIsBusy(true); + try { + if (confirmAction === "disable") { + await securityApi.disable(password, confirmCode.trim() || undefined); + setConfirmAction(null); + await load(); + } else { + const issued = await securityApi.regenerateRecoveryCodes( + password, + confirmCode.trim() || undefined + ); + setConfirmAction(null); + setCodes(issued.codes); + await load(); + } + setPassword(""); + setConfirmCode(""); + } catch (caught) { + setConfirmCode(""); + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const card = "rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6"; + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + {status?.enabled ? ( + + + {t("state.on")} + + ) : ( + + + {t("state.off")} + + )} +
+ + {failed && ( +

+ {t("errors.loadFailed")} +

+ )} + + {error &&

{error}

} + + {/* Half-enrolled. Said plainly, because believing you are protected when + you are not is worse than knowing you are not. */} + {!failed && status?.enrolment_pending && !enrolment && ( +
+ {t("state.pendingWarning")} +
+ )} + + {!failed && !status?.enabled && !enrolment && ( +
+

{t("off.explain")}

+ + + {t("off.start")} + +
+ )} + + {enrolment && ( +
+

+ {t("enrol.step1")} +

+ +
+ {enrolment.qr && ( + {t("enrol.qrAlt")} + )} + +
+

+ {t("enrol.manual")} +

+
+ + {enrolment.secret} + + + void navigator.clipboard?.writeText(enrolment.secret) + } + > + + +
+
+
+ +

{t("enrol.step2")}

+ + setCode(event.target.value)} + /> + +
+ + {t("enrol.confirm")} + + { + setEnrolment(null); + setError(""); + }} + disabled={isBusy} + > + {t("common:buttons.cancel", "Cancel")} + +
+
+ )} + + {!failed && status?.enabled && !enrolment && ( +
+

+ {t("on.recoveryRemaining", { + count: status.recovery_codes_remaining, + })} +

+ + {/* A running-low count is the useful part: somebody down to their last + code is one lost phone away from a support ticket. */} + {status.recovery_codes_remaining <= 2 && ( +
+ {t("on.runningLow")} +
+ )} + +
+ { + setConfirmAction("regenerate"); + setError(""); + }} + > + {t("on.regenerate")} + + { + setConfirmAction("disable"); + setError(""); + }} + > + + {t("on.disable")} + +
+
+ )} + + setCodes(null)} + title={t("recovery.title")} + > + {codes && setCodes(null)} />} + + + { + setConfirmAction(null); + setPassword(""); + setConfirmCode(""); + }} + title={ + confirmAction === "disable" + ? t("on.disable") + : t("on.regenerate") + } + > +
+

+ {confirmAction === "disable" + ? t("confirm.disableExplain") + : t("confirm.regenerateExplain")} +

+ + setPassword(event.target.value)} + /> + + setConfirmCode(event.target.value)} + /> + + {error &&

{error}

} + + + {t("confirm.submit")} + +
+
+
+ ); +}; + +export default SecurityPanel; diff --git a/src/application/security/SecurityTypes.ts b/src/application/security/SecurityTypes.ts new file mode 100644 index 0000000..f08e938 --- /dev/null +++ b/src/application/security/SecurityTypes.ts @@ -0,0 +1,20 @@ +export type MfaStatus = { + enabled: boolean; + /** A secret was generated and never proved. The factor does nothing in this + * state — which is deliberate: somebody who scans a QR code and whose phone + * then dies must not be locked out by a secret nobody holds. */ + enrolment_pending: boolean; + recovery_codes_remaining: number; +}; + +export type MfaEnrolmentStarted = { + secret: string; + otpauth_uri: string; +}; + +export type MfaRecoveryCodes = { + /** Returned once. They are hashed on the way in, so there is no endpoint that + * can show them again — which is the property that makes them safe to store + * at all, and the reason the screen insists you take them now. */ + codes: string[]; +}; diff --git a/src/application/security/qr.ts b/src/application/security/qr.ts new file mode 100644 index 0000000..21e0fb0 --- /dev/null +++ b/src/application/security/qr.ts @@ -0,0 +1,24 @@ +/** + * Render an `otpauth://` URI as a data URL, or nothing. + * + * A named boundary rather than a call inline, for three reasons: + * + * - **Failing is a real case, not an exception.** The secret is shown as text + * beside it and every authenticator app accepts a typed key, so a failed + * render is a smaller problem than a dead enrolment screen. Returning `null` + * makes that the ordinary path rather than something a `catch` hides. + * - **The library loads on demand.** Imported at the top of the file it lands in + * the profile chunk, which every signed-in person downloads to look at their + * own details — for a drawing almost none of them will ever need. Enrolling in + * a second factor happens once, if ever, and that is when this is fetched. + * - It is the one place the QR library is named, so nothing else has to know how + * that package resolves. + */ +export const toQrDataUrl = async (text: string): Promise => { + try { + const { default: QRCode } = await import("qrcode"); + return await QRCode.toDataURL(text, { margin: 1, width: 200 }); + } catch { + return null; + } +}; diff --git a/src/application/settings/SettingsPage.tsx b/src/application/settings/SettingsPage.tsx index ccbb218..2b8ba76 100644 --- a/src/application/settings/SettingsPage.tsx +++ b/src/application/settings/SettingsPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { CustomDropdown, CustomConfirmationModal } from '../../components/custom'; import { useAuth } from '../../context/AuthContext'; -import { languages } from '../../i18n/config'; +import { languages, loadLanguage } from '../../i18n/config'; import type { SupportedLanguage } from '../../i18n/config'; import { Globe } from 'lucide-react'; @@ -31,12 +31,17 @@ const SettingsPage: React.FC = () => { setShowConfirmation(false); try { + // Fetched before the reload rather than after it. The page comes back in + // the new language either way, but pre-warming means the bundle is in the + // browser's cache when it does, instead of a flash of English. + await loadLanguage(pendingLanguage); + localStorage.setItem('preferred_language', pendingLanguage); - + if (user) { await updateLanguage(pendingLanguage); } - + window.location.reload(); } catch (error) { console.error('Failed to update language:', error); diff --git a/src/application/sso/SsoApi.ts b/src/application/sso/SsoApi.ts new file mode 100644 index 0000000..232aa30 --- /dev/null +++ b/src/application/sso/SsoApi.ts @@ -0,0 +1,48 @@ +import { apiClient } from "../../lib/apiClient"; +import type { + IdentityProvider, + IdentityProviderCreate, + IdentityProviderUpdate, +} from "./SsoTypes"; + +/** + * A workspace's own identity provider — its Azure AD, Okta or Google. + * + * The inbound direction. "SSO" elsewhere in this codebase means the platform + * signing users *into* modules, which is a different thing entirely and lives + * under a different path for exactly that reason. + */ +export const ssoApi = { + list: () => apiClient.get("/api/admin/sso/"), + + create: (payload: IdentityProviderCreate) => + apiClient.post("/api/admin/sso/", payload, { + successMessage: "Connection added", + errorMessage: "Could not add the connection", + }), + + update: (id: string, payload: IdentityProviderUpdate) => + apiClient.patch(`/api/admin/sso/${id}`, payload, { + successMessage: "Connection updated", + errorMessage: "Could not update the connection", + }), + + /** + * Re-read the provider's discovery document. + * + * Endpoints are filled in from it rather than typed, which is both less + * error-prone and how a provider signals that one has moved — so this is the + * button to press when a working connection suddenly is not. + */ + discover: (id: string) => + apiClient.post(`/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", + }), +}; diff --git a/src/application/sso/SsoPage.tsx b/src/application/sso/SsoPage.tsx new file mode 100644 index 0000000..6a668fb --- /dev/null +++ b/src/application/sso/SsoPage.tsx @@ -0,0 +1,420 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Copy, LogIn, Plus, RefreshCw, Trash2 } from "lucide-react"; + +import { + CustomButton, + CustomCheckBox, + CustomConfirmationModal, + CustomInput, + CustomLoader, + CustomModal, + CustomSwitch, +} from "../../components/custom"; +import { ssoApi } from "./SsoApi"; +import type { IdentityProvider } from "./SsoTypes"; + +/** + * A workspace connecting its own identity provider, and pointing its directory + * at us. + * + * ## Two things this screen is careful about + * + * **The secret is write-only.** There is no field to read it back, because a + * secret that can be read is a secret in every response log, browser cache and + * screen-share. The form shows whether one is set and offers to replace it; + * leaving the field empty on an edit keeps the stored one. + * + * **A connection starts switched off.** Turning it on is a separate, + * deliberate act after discovery has succeeded — otherwise a half-configured + * provider becomes the sign-in route for a whole workspace the moment somebody + * saves the form. + */ + +const CopyRow: React.FC<{ label: string; value: string }> = ({ label, value }) => ( +
+

{label}

+
+ + {value} + + void navigator.clipboard?.writeText(value)} + > + + +
+
+); + +const SsoPage: React.FC = () => { + const { t } = useTranslation(["sso", "common"]); + + const [providers, setProviders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [failed, setFailed] = useState(false); + + const [editing, setEditing] = useState(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(null); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + setProviders(await ssoApi.list()); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const openNew = () => { + setEditing(null); + setName(""); + setSlug(""); + setIssuer(""); + setClientId(""); + setClientSecret(""); + setDomains(""); + setJit(true); + setLinkByEmail(false); + setError(""); + setIsFormOpen(true); + }; + + const openEdit = (provider: IdentityProvider) => { + setEditing(provider); + setName(provider.name); + setSlug(provider.slug); + setIssuer(provider.issuer ?? ""); + setClientId(provider.client_id ?? ""); + // Always blank. The stored secret cannot be read back, so pre-filling + // anything here would be a lie that overwrites it on save. + setClientSecret(""); + setDomains(provider.allowed_domains ?? ""); + setJit(provider.jit_provisioning); + setLinkByEmail(provider.link_existing_by_email); + setError(""); + setIsFormOpen(true); + }; + + const save = async () => { + setError(""); + setIsBusy(true); + try { + if (editing) { + await ssoApi.update(editing.id, { + name: name.trim(), + issuer: issuer.trim() || null, + client_id: clientId.trim() || null, + // Omitted when blank, which leaves the stored secret alone. + ...(clientSecret ? { client_secret: clientSecret } : {}), + allowed_domains: domains.trim() || null, + jit_provisioning: jit, + link_existing_by_email: linkByEmail, + }); + } else { + await ssoApi.create({ + name: name.trim(), + slug: slug.trim(), + issuer: issuer.trim() || null, + client_id: clientId.trim() || null, + client_secret: clientSecret || null, + allowed_domains: domains.trim() || null, + jit_provisioning: jit, + link_existing_by_email: linkByEmail, + // Off. Turning it on is a separate act, after discovery has worked. + enabled: false, + }); + } + setIsFormOpen(false); + await load(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const toggle = async (provider: IdentityProvider) => { + await ssoApi.update(provider.id, { enabled: !provider.enabled }); + await load(); + }; + + const rediscover = async (provider: IdentityProvider) => { + await ssoApi.discover(provider.id); + await load(); + }; + + const remove = async () => { + if (!pendingRemove) return; + const target = pendingRemove; + setPendingRemove(null); + try { + await ssoApi.remove(target.id); + } finally { + await load(); + } + }; + + const scimBase = `${window.location.origin.replace(/\/$/, "")}/scim/v2`; + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + + + {t("add")} + +
+ +
+ {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : providers.length === 0 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
    + {providers.map((provider) => ( +
  • +
    +
    +
    + + {provider.name} + + {!provider.client_secret_set && ( + + {t("noSecret")} + + )} + {!provider.discovered_at && ( + + {t("notDiscovered")} + + )} +
    +

    + {provider.issuer ?? t("noIssuer")} +

    +

    + {provider.allowed_domains + ? t("domains.limited", { domains: provider.allowed_domains }) + : t("domains.any")} +

    +
    + +
    + void toggle(provider)} + label={provider.enabled ? t("on") : t("off")} + /> + void rediscover(provider)} + > + + {t("rediscover")} + + openEdit(provider)} + > + {t("common:actions.edit", "Edit")} + + setPendingRemove(provider)} + > + + +
    +
    +
  • + ))} +
+ )} +
+ + {/* Provisioning. Kept on the same screen as the sign-in connection + because they are two halves of the same setup: one decides who you + are, the other whether you still exist. */} +
+

+ {t("scim.title")} +

+

+ {t("scim.subtitle")} +

+ +
+ +

{t("scim.token")}

+
    +
  • {t("scim.step1")}
  • +
  • {t("scim.step2")}
  • +
  • {t("scim.step3")}
  • +
+
+
+ + { + setIsFormOpen(false); + setError(""); + }} + title={editing ? t("form.editTitle") : t("add")} + > +
+ setName(event.target.value)} + /> + + {!editing && ( + <> + + setSlug(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")) + } + /> +

+ {t("form.slugNote")} +

+ + )} + + setIssuer(event.target.value)} + /> +

+ {t("form.issuerNote")} +

+ + setClientId(event.target.value)} + /> + + setClientSecret(event.target.value)} + /> + {editing && editing.client_secret_set && ( +

+ {t("form.secretNote")} +

+ )} + + setDomains(event.target.value)} + /> +

+ {t("form.domainsNote")} +

+ + setJit(event.target.checked)} + /> + setLinkByEmail(event.target.checked)} + /> +

+ {t("form.linkByEmailNote")} +

+ + {error &&

{error}

} + + + {editing ? t("form.save") : t("form.create")} + +
+
+ + setPendingRemove(null)} + onConfirm={remove} + title={t("confirmRemove.title")} + description={t("confirmRemove.message", { name: pendingRemove?.name ?? "" })} + confirmText={t("confirmRemove.confirm")} + /> +
+ ); +}; + +export default SsoPage; diff --git a/src/application/sso/SsoTypes.ts b/src/application/sso/SsoTypes.ts new file mode 100644 index 0000000..196e838 --- /dev/null +++ b/src/application/sso/SsoTypes.ts @@ -0,0 +1,49 @@ +export type IdentityProvider = { + id: string; + tenant_id: string; + kind: string; + name: string; + /** Part of the login URL, so it is fixed once created — changing it would + * break any bookmark or portal link a customer has already handed out. */ + slug: string; + enabled: boolean; + issuer?: string | null; + client_id?: string | null; + scopes: string; + /** Comma-separated. Empty means any domain the provider vouches for, which is + * the difference between "our staff" and "anyone with a Google account". */ + allowed_domains?: string | null; + jit_provisioning: boolean; + default_role_id?: string | null; + link_existing_by_email: boolean; + authorization_endpoint?: string | null; + token_endpoint?: string | null; + jwks_uri?: string | null; + discovered_at?: string | null; + created_at: string; + /** There is no field carrying the secret itself: one that can be read back is + * a secret in every response log, browser cache and screen-share. */ + client_secret_set: boolean; +}; + +export type IdentityProviderCreate = { + name: string; + slug: string; + issuer?: string | null; + client_id?: string | null; + client_secret?: string | null; + scopes?: string; + allowed_domains?: string | null; + jit_provisioning?: boolean; + link_existing_by_email?: boolean; + enabled?: boolean; +}; + +export type IdentityProviderUpdate = Partial< + Omit +> & { + /** Turning a connection on is a separate act from configuring it: a + * half-configured provider must not become the sign-in route for a whole + * workspace the moment somebody saves the form. */ + enabled?: boolean; +}; diff --git a/src/application/subscriptions/SubscriptionTypes.ts b/src/application/subscriptions/SubscriptionTypes.ts index b07fe1f..c131e16 100644 --- a/src/application/subscriptions/SubscriptionTypes.ts +++ b/src/application/subscriptions/SubscriptionTypes.ts @@ -5,6 +5,8 @@ export type SubscriptionPlan = { price?: number | null; duration_days?: number | null; max_users_allowed?: number | null; + /** Days a lapsed workspace stays read-only before it is locked out. 0 = none. */ + grace_period_days?: number | null; is_public: boolean; status: string; created_at: string; @@ -22,6 +24,7 @@ export type SubscriptionPlanCreateRequest = { price?: number; duration_days?: number; max_users_allowed?: number; + grace_period_days?: number; is_public?: boolean; status?: string; access_ids?: string[]; @@ -34,6 +37,7 @@ export type SubscriptionPlanUpdateRequest = { price?: number; duration_days?: number; max_users_allowed?: number; + grace_period_days?: number; is_public?: boolean; status?: string; access_ids?: string[]; diff --git a/src/application/subscriptions/buildPlanPayload.test.ts b/src/application/subscriptions/buildPlanPayload.test.ts new file mode 100644 index 0000000..7071747 --- /dev/null +++ b/src/application/subscriptions/buildPlanPayload.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { buildPlanCreatePayload, buildPlanUpdatePayload } from "./buildPlanPayload"; + +/** + * A plan decides the permission bound, the seat limit and the grace window. + * Every one of those is optional, has a meaningful zero, and has a "not set" + * that means something different from zero — which is exactly the shape that + * goes wrong quietly. + * + * The specific trap: number fields come from `` as strings, + * and `Number("")` is `0`. An empty seat-limit box becomes a real zero unless the + * empty case is handled first — and a plan with `max_users_allowed: 0` cannot + * have a single user added to it. + */ + +const base = { + name: " Enterprise ", + is_public: true, + status: "active", +}; + +describe("buildPlanCreatePayload", () => { + it("trims the name", () => { + expect(buildPlanCreatePayload(base, [], []).name).toBe("Enterprise"); + }); + + it("leaves an unset seat limit unset", () => { + // Unset means unlimited. This is the one that locks a customer out of + // their own workspace if it becomes 0. + const payload = buildPlanCreatePayload( + { ...base, max_users_allowed: "" }, + [], + [] + ); + + expect(payload.max_users_allowed).toBeUndefined(); + }); + + it("keeps a seat limit of zero, which is not the same thing", () => { + const payload = buildPlanCreatePayload( + { ...base, max_users_allowed: 0 }, + [], + [] + ); + + expect(payload.max_users_allowed).toBe(0); + }); + + it("reads a number typed into a text input", () => { + const payload = buildPlanCreatePayload( + { ...base, max_users_allowed: "25", price: "99.5", duration_days: "30" }, + [], + [] + ); + + expect(payload.max_users_allowed).toBe(25); + expect(payload.price).toBe(99.5); + expect(payload.duration_days).toBe(30); + }); + + it("keeps a price of zero, which is a free plan", () => { + expect(buildPlanCreatePayload({ ...base, price: 0 }, [], []).price).toBe(0); + }); + + it("defaults grace to zero rather than leaving it out", () => { + // Unset and zero genuinely are the same here — no grace — so it is sent + // concretely. On an edit, `undefined` would leave a previous window in + // place rather than clearing it. + expect( + buildPlanCreatePayload({ ...base, grace_period_days: "" }, [], []) + .grace_period_days + ).toBe(0); + }); + + it("sends a configured grace window", () => { + expect( + buildPlanCreatePayload({ ...base, grace_period_days: "14" }, [], []) + .grace_period_days + ).toBe(14); + }); + + it("drops a description somebody cleared", () => { + expect( + buildPlanCreatePayload({ ...base, description: " " }, [], []).description + ).toBeUndefined(); + }); + + it("ignores a number field that is not a number", () => { + // Browsers usually prevent it; a pasted value or an autofill does not. + // `Number("abc")` is NaN, and NaN serialises to `null`, which the API + // reads as an explicit "no limit". + expect( + buildPlanCreatePayload({ ...base, max_users_allowed: "abc" }, [], []) + .max_users_allowed + ).toBeUndefined(); + }); + + it("splits the permission selection into the two the API expects", () => { + const payload = buildPlanCreatePayload( + base, + ["p", "m"], + [{ id: "p" }, { id: "m", module_id: "mod" }] + ); + + expect(payload.access_ids).toEqual(["p"]); + expect(payload.module_access_ids).toEqual(["m"]); + }); + + it("survives JSON serialisation without inventing values", () => { + // What actually goes on the wire. `undefined` keys are dropped, which is + // what "not set" has to mean; a stray `null` would be read as an + // explicit clear. + const payload = buildPlanCreatePayload( + { ...base, max_users_allowed: "", price: "" }, + [], + [] + ); + const wire = JSON.parse(JSON.stringify(payload)); + + expect("max_users_allowed" in wire).toBe(false); + expect("price" in wire).toBe(false); + expect(wire.grace_period_days).toBe(0); + }); +}); + +describe("buildPlanUpdatePayload", () => { + it("sends every field the create payload does", () => { + // The two forms drifting is how a field ends up settable on create and + // not on edit — which reads as "the save didn't work". + const values = { + ...base, + price: "10", + duration_days: "30", + max_users_allowed: "5", + grace_period_days: "7", + }; + + expect(Object.keys(buildPlanUpdatePayload(values, [], [])).sort()).toEqual( + Object.keys(buildPlanCreatePayload(values, [], [])).sort() + ); + }); + + it("carries the grace window through an edit", () => { + expect( + buildPlanUpdatePayload({ ...base, grace_period_days: "30" }, [], []) + .grace_period_days + ).toBe(30); + }); +}); diff --git a/src/application/subscriptions/buildPlanPayload.ts b/src/application/subscriptions/buildPlanPayload.ts new file mode 100644 index 0000000..21d1436 --- /dev/null +++ b/src/application/subscriptions/buildPlanPayload.ts @@ -0,0 +1,81 @@ +import { splitAccessIds, type AccessLike } from "./splitAccessIds"; +import type { + SubscriptionPlanCreateRequest, + SubscriptionPlanUpdateRequest, +} from "./SubscriptionTypes"; + +/** + * Turns what the plan form holds into what the API expects. + * + * A plan decides three things that matter and are easy to send wrong: the + * permission bound (an upper limit on what roles may grant, since finding S-2), + * the seat limit, and the grace window. Each is optional, each has a meaningful + * zero, and each has a "not set" that is different from zero: + * + * - `max_users_allowed` **unset** means unlimited seats. `0` means nobody can be + * added. Collapsing them locks a customer out of their own workspace. + * - `grace_period_days` **unset** and `0` genuinely are the same thing — no + * grace — which is why it defaults to `0` rather than to `undefined`. Sending + * `undefined` would leave an existing value in place on an edit. + * - `price` of `0` is a free plan, not a missing price. + * + * The number fields arrive from ``, which yields a string, + * and `Number("")` is `0` — so an empty box becomes a real zero unless the empty + * case is handled first. That is the specific way this goes wrong. + */ + +const optionalNumber = (value: unknown): number | undefined => { + if (value === "" || value === null || value === undefined) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const optionalText = (value: string | null | undefined): string | undefined => { + const text = (value ?? "").trim(); + return text === "" ? undefined : text; +}; + +export type PlanFormValues = { + name: string; + description?: string | null; + price?: number | string | null; + duration_days?: number | string | null; + max_users_allowed?: number | string | null; + grace_period_days?: number | string | null; + // Optional in the request type but never genuinely absent in a form: a + // checkbox is checked or it is not. Defaulted rather than required so the + // form state can be passed straight in. + is_public?: boolean; + status?: string; +}; + +const common = (values: PlanFormValues, selected: string[], all: AccessLike[]) => { + const { accessIds, moduleAccessIds } = splitAccessIds(selected, all); + return { + name: values.name.trim(), + description: optionalText(values.description), + price: optionalNumber(values.price), + duration_days: optionalNumber(values.duration_days), + max_users_allowed: optionalNumber(values.max_users_allowed), + // Unset and zero mean the same thing here, so the default is concrete — + // and on an edit, `undefined` would leave a previous grace window in + // place rather than clearing it. + grace_period_days: optionalNumber(values.grace_period_days) ?? 0, + is_public: values.is_public ?? true, + status: values.status ?? "active", + access_ids: accessIds, + module_access_ids: moduleAccessIds, + }; +}; + +export const buildPlanCreatePayload = ( + values: PlanFormValues, + selectedAccessIds: string[], + allAccesses: AccessLike[] +): SubscriptionPlanCreateRequest => common(values, selectedAccessIds, allAccesses); + +export const buildPlanUpdatePayload = ( + values: PlanFormValues, + selectedAccessIds: string[], + allAccesses: AccessLike[] +): SubscriptionPlanUpdateRequest => common(values, selectedAccessIds, allAccesses); diff --git a/src/application/subscriptions/components/AddSubscriptions.tsx b/src/application/subscriptions/components/AddSubscriptions.tsx index 46444ca..a4619a8 100644 --- a/src/application/subscriptions/components/AddSubscriptions.tsx +++ b/src/application/subscriptions/components/AddSubscriptions.tsx @@ -11,6 +11,7 @@ import { subscriptionsApi } from "../SubscriptionsApi"; import type { SubscriptionPlanCreateRequest } from "../SubscriptionTypes"; import type { RoleAccess } from "../../roles/RolesTypes"; import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector"; +import { buildPlanCreatePayload } from "../buildPlanPayload"; const AddSubscriptions = () => { const navigate = useNavigate(); @@ -21,6 +22,7 @@ const AddSubscriptions = () => { price: undefined, duration_days: undefined, max_users_allowed: undefined, + grace_period_days: 0, is_public: true, status: "active", access_ids: [], @@ -69,42 +71,17 @@ const AddSubscriptions = () => { setFormData((prev) => ({ ...prev, [name]: value })); }; - const splitAccessIds = (ids: string[]) => { - const accessIds: string[] = []; - const moduleAccessIds: string[] = []; - const accessMap = new Map(allAccesses.map((a) => [a.id, a])); - - ids.forEach((id) => { - const access = accessMap.get(id); - if (access?.module_id) { - moduleAccessIds.push(id); - } else { - accessIds.push(id); - } - }); - - return { accessIds, moduleAccessIds }; - }; - const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setErrorMessage(""); setIsLoading(true); try { - const { accessIds, moduleAccessIds } = splitAccessIds(selectedAccessIds); - - const payload: SubscriptionPlanCreateRequest = { - name: formData.name.trim(), - description: formData.description?.trim() || undefined, - price: formData.price ? Number(formData.price) : undefined, - duration_days: formData.duration_days ? Number(formData.duration_days) : undefined, - max_users_allowed: formData.max_users_allowed ?? undefined, - is_public: formData.is_public, - status: formData.status, - access_ids: accessIds, - module_access_ids: moduleAccessIds, - }; + const payload = buildPlanCreatePayload( + formData, + selectedAccessIds, + allAccesses + ); await subscriptionsApi.create(payload); navigate("/subscriptions"); @@ -189,6 +166,22 @@ const AddSubscriptions = () => { })) } /> + {/* How long a lapsed workspace stays read-only before it is locked + out. Zero keeps the old behaviour, so an existing plan does not + change because the field appeared. */} + + setFormData((prev) => ({ + ...prev, + grace_period_days: e.target.value ? Number(e.target.value) : 0, + })) + } + />

{ if (price == null) return "-"; @@ -50,6 +51,7 @@ interface EditFormState { price: number | undefined; duration_days: number | undefined; max_users_allowed: number | undefined; + grace_period_days: number | undefined; is_public: boolean; status: string; } @@ -103,6 +105,7 @@ const AllSubscriptions = () => { price: undefined, duration_days: undefined, max_users_allowed: undefined, + grace_period_days: 0, is_public: true, status: "active", }); @@ -223,26 +226,6 @@ const AllSubscriptions = () => { return counts; }, [allPlansForCounts]); - const splitAccessIds = useCallback( - (ids: string[]) => { - const accessIds: string[] = []; - const moduleAccessIds: string[] = []; - const accessMap = new Map(allAccesses.map((a) => [a.id, a])); - - ids.forEach((id) => { - const access = accessMap.get(id); - if (access?.module_id) { - moduleAccessIds.push(id); - } else { - accessIds.push(id); - } - }); - - return { accessIds, moduleAccessIds }; - }, - [allAccesses] - ); - const openView = useCallback(async (plan: SubscriptionPlan) => { setSelectedPlan(plan); setViewAccessIds([]); @@ -266,6 +249,7 @@ const AllSubscriptions = () => { price: plan.price ?? undefined, duration_days: plan.duration_days ?? undefined, max_users_allowed: plan.max_users_allowed ?? undefined, + grace_period_days: plan.grace_period_days ?? 0, is_public: plan.is_public, status: plan.status, }); @@ -322,19 +306,7 @@ const AllSubscriptions = () => { setEditError(""); try { - const { accessIds, moduleAccessIds } = splitAccessIds(editAccessIds); - - const payload: SubscriptionPlanUpdateRequest = { - name: editForm.name.trim(), - description: editForm.description.trim() || undefined, - price: editForm.price ? Number(editForm.price) : undefined, - duration_days: editForm.duration_days ? Number(editForm.duration_days) : undefined, - max_users_allowed: editForm.max_users_allowed ?? undefined, - is_public: editForm.is_public, - status: editForm.status, - access_ids: accessIds, - module_access_ids: moduleAccessIds, - }; + const payload = buildPlanUpdatePayload(editForm, editAccessIds, allAccesses); const updated = await subscriptionsApi.update(selectedPlan.id, payload); setPlans((prev) => @@ -773,6 +745,19 @@ const AllSubscriptions = () => { })) } /> + + setEditForm((prev) => ({ + ...prev, + grace_period_days: e.target.value ? Number(e.target.value) : 0, + })) + } + /> ({ id }); +const ofModule = (id: string) => ({ id, module_id: "mod-1" }); + +describe("splitAccessIds", () => { + it("sends a platform permission to the platform list", () => { + const result = splitAccessIds(["a"], [platform("a")]); + + expect(result).toEqual({ accessIds: ["a"], moduleAccessIds: [] }); + }); + + it("sends a module permission to the module list", () => { + const result = splitAccessIds(["m"], [ofModule("m")]); + + expect(result).toEqual({ accessIds: [], moduleAccessIds: ["m"] }); + }); + + it("sorts a mixed selection", () => { + const result = splitAccessIds( + ["a", "m", "b", "n"], + [platform("a"), ofModule("m"), platform("b"), ofModule("n")] + ); + + expect(result.accessIds).toEqual(["a", "b"]); + expect(result.moduleAccessIds).toEqual(["m", "n"]); + }); + + it("keeps the order the picker gave", () => { + // Not important to the server, but a diff between two saves that differ + // only in ordering is a diff somebody has to read and dismiss. + const result = splitAccessIds( + ["c", "a", "b"], + [platform("a"), platform("b"), platform("c")] + ); + + expect(result.accessIds).toEqual(["c", "a", "b"]); + }); + + it("treats a null module_id as a platform permission", () => { + // The API returns `module_id: null` rather than omitting it, and `null` + // is falsy — but only by luck. Stated so it stays true. + const result = splitAccessIds(["a"], [{ id: "a", module_id: null }]); + + expect(result.accessIds).toEqual(["a"]); + }); + + it("puts an id it does not recognise with the platform permissions", () => { + // The conservative half: the server ignores an id that is not in + // `plan_accesses`, whereas one wrongly placed among module permissions + // could match a real module permission and grant something unintended. + const result = splitAccessIds(["ghost"], []); + + expect(result).toEqual({ accessIds: ["ghost"], moduleAccessIds: [] }); + }); + + it("selects nothing from nothing", () => { + expect(splitAccessIds([], [platform("a")])).toEqual({ + accessIds: [], + moduleAccessIds: [], + }); + }); + + it("does not invent entries for permissions that were not selected", () => { + // The picker's full list is passed as the second argument; only the + // first is a selection. Confusing the two would grant everything. + const result = splitAccessIds( + ["a"], + [platform("a"), platform("b"), ofModule("m")] + ); + + expect(result.accessIds).toEqual(["a"]); + expect(result.moduleAccessIds).toEqual([]); + }); +}); diff --git a/src/application/subscriptions/splitAccessIds.ts b/src/application/subscriptions/splitAccessIds.ts new file mode 100644 index 0000000..c6e8a1b --- /dev/null +++ b/src/application/subscriptions/splitAccessIds.ts @@ -0,0 +1,55 @@ +/** + * One flat list of permission ids, split into the two the API expects. + * + * The picker shows platform permissions and module permissions together, + * because to an administrator granting them they are one list. The server keeps + * them in two tables — `plan_accesses` and `plan_module_accesses` — so something + * has to sort them, and the only thing distinguishing them is whether the + * permission belongs to a module. + * + * Getting it wrong is quiet. An id sent in the wrong array does not error: the + * server looks it up in the table it was told to use, does not find it, and + * drops it. The permission simply never takes effect, and the console goes on + * showing it as granted. + * + * Extracted because it existed twice — once in the create form and once in the + * edit form — with no test on either. Two copies of a rule is how the two + * copies drift, which is the same defect this codebase has now had in four + * other places. + */ + +export type AccessLike = { + id: string; + /** Present only on permissions belonging to a module. */ + module_id?: string | null; +}; + +export type SplitAccessIds = { + accessIds: string[]; + moduleAccessIds: string[]; +}; + +export const splitAccessIds = ( + ids: string[], + accesses: AccessLike[] +): SplitAccessIds => { + const accessIds: string[] = []; + const moduleAccessIds: string[] = []; + const byId = new Map(accesses.map((access) => [access.id, access])); + + for (const id of ids) { + const access = byId.get(id); + // An id the picker does not know about goes with the platform + // permissions, which is where an unknown id was always sent. It is the + // conservative half of the split: the server ignores an id that is not + // in `accesses`, whereas one wrongly placed among module permissions + // could match a real module permission id. + if (access?.module_id) { + moduleAccessIds.push(id); + } else { + accessIds.push(id); + } + } + + return { accessIds, moduleAccessIds }; +}; diff --git a/src/application/tenants/TenantsTypes.ts b/src/application/tenants/TenantsTypes.ts index 711920f..1122f3a 100644 --- a/src/application/tenants/TenantsTypes.ts +++ b/src/application/tenants/TenantsTypes.ts @@ -6,6 +6,8 @@ export type Tenant = { tenant_name: string; tenant_domain: string; tenant_logo_url?: string | null; + /** Who to tell before the subscription lapses. */ + billing_email?: string | null; is_active: boolean; plan_id?: string | null; start_date?: string | null; @@ -24,6 +26,7 @@ export type TenantCreateRequest = { tenant_name: string; tenant_domain: string; tenant_logo_url?: string; + billing_email?: string; plan_id: string; start_date?: string; end_date?: string; @@ -35,6 +38,8 @@ export type TenantUpdateRequest = { tenant_name?: string; tenant_domain?: string; tenant_logo_url?: string | null; + /** Who to tell before the subscription lapses. */ + billing_email?: string | null; is_active?: boolean; plan_id?: string; start_date?: string | null; diff --git a/src/application/tenants/buildTenantPayload.test.ts b/src/application/tenants/buildTenantPayload.test.ts new file mode 100644 index 0000000..ac7e4a9 --- /dev/null +++ b/src/application/tenants/buildTenantPayload.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import { + buildTenantCreatePayload, + buildTenantUpdatePayload, +} from "./buildTenantPayload"; + +/** + * The workspace form. `billing_email` is the field worth guarding: it is the + * only address the platform will ever write to about a subscription, and a + * workspace with an empty string there looks configured and gets no warning + * before it lapses. + */ + +const base = { + tenantName: " Alpha Corp ", + tenantDomain: " alpha.example.com ", + planId: "plan-1", + status: "ACTIVE" as const, +}; + +describe("buildTenantCreatePayload", () => { + it("trims the name and domain", () => { + const payload = buildTenantCreatePayload(base); + + expect(payload.tenant_name).toBe("Alpha Corp"); + expect(payload.tenant_domain).toBe("alpha.example.com"); + }); + + it("sends a billing address", () => { + expect( + buildTenantCreatePayload({ ...base, billingEmail: " finance@x.com " }) + .billing_email + ).toBe("finance@x.com"); + }); + + it("treats a cleared billing address as absent, not as an empty string", () => { + // `""` is a value the API will store. A workspace whose billing_email is + // `""` looks configured and is not: the notice worker records "nobody to + // tell" and sends nothing. + const payload = buildTenantCreatePayload({ ...base, billingEmail: " " }); + + expect(payload.billing_email).toBeUndefined(); + expect("billing_email" in JSON.parse(JSON.stringify(payload))).toBe(false); + }); + + it("leaves optional dates out rather than sending empty strings", () => { + const payload = buildTenantCreatePayload({ + ...base, + startDate: "", + endDate: "", + }); + + expect(payload.start_date).toBeUndefined(); + expect(payload.end_date).toBeUndefined(); + }); + + it("sends dates that were filled in", () => { + const payload = buildTenantCreatePayload({ + ...base, + startDate: "2026-01-01", + endDate: "2026-12-31", + }); + + expect(payload.start_date).toBe("2026-01-01"); + expect(payload.end_date).toBe("2026-12-31"); + }); + + it("drops a module assignment with no environment chosen", () => { + // An assignment with an empty slug is not an assignment. The server now + // refuses it — and used to accept it, silently falling back to the + // module's default, which is production. + const payload = buildTenantCreatePayload({ + ...base, + moduleEnvironments: [ + { module_id: "a", environment_slug: "prod" }, + { module_id: "b", environment_slug: "" }, + ], + }); + + expect(payload.module_environments).toEqual([ + { module_id: "a", environment_slug: "prod" }, + ]); + }); + + it("sends an empty list when nothing was assigned", () => { + expect(buildTenantCreatePayload(base).module_environments).toEqual([]); + }); +}); + +describe("buildTenantUpdatePayload", () => { + const editBase = { + tenantName: "Alpha Corp", + tenantDomain: "alpha.example.com", + tenantLogoUrl: "", + billingEmail: "", + isActive: true, + }; + + it("clears a field with null rather than leaving it alone", () => { + // The difference between the two forms. On create, "not set" is + // `undefined` and the key is dropped. On edit, clearing a box means + // *remove it* — `undefined` would leave the old value in place and look + // like the save had not worked. + const payload = buildTenantUpdatePayload(editBase); + + expect(payload.tenant_logo_url).toBeNull(); + expect(payload.billing_email).toBeNull(); + + const wire = JSON.parse(JSON.stringify(payload)); + expect(wire.billing_email).toBeNull(); + }); + + it("can clear a billing address that was set", () => { + // The edit form used `|| undefined` here while using `|| null` for the + // logo two lines above, so a billing address could be set and never + // removed: clearing the box dropped the key and the server left the old + // value in place. Surfaced by extracting the two forms' rules into one + // place and reading them side by side. + expect(buildTenantUpdatePayload(editBase).billing_email).toBeNull(); + }); + + it("clears the dates the same way", () => { + expect(buildTenantUpdatePayload(editBase).start_date).toBeNull(); + expect(buildTenantUpdatePayload(editBase).end_date).toBeNull(); + }); + + it("sends a billing address that was filled in", () => { + expect( + buildTenantUpdatePayload({ ...editBase, billingEmail: " ops@x.com " }) + .billing_email + ).toBe("ops@x.com"); + }); + + it("carries the active flag, including when false", () => { + // `false` is a real value and the one that matters — a workspace being + // deactivated. Dropping it as falsy would make deactivation impossible. + expect( + buildTenantUpdatePayload({ ...editBase, isActive: false }).is_active + ).toBe(false); + }); + + it("leaves the plan alone when none was chosen", () => { + // Unlike the logo, an empty plan on an edit form means "unchanged", not + // "remove the plan" — there is no way to have no plan through this form. + expect(buildTenantUpdatePayload(editBase).plan_id).toBeUndefined(); + }); + + it("sends a plan that was chosen", () => { + expect( + buildTenantUpdatePayload({ ...editBase, planId: "plan-2" }).plan_id + ).toBe("plan-2"); + }); +}); diff --git a/src/application/tenants/buildTenantPayload.ts b/src/application/tenants/buildTenantPayload.ts new file mode 100644 index 0000000..c467741 --- /dev/null +++ b/src/application/tenants/buildTenantPayload.ts @@ -0,0 +1,95 @@ +import type { + ModuleEnvironmentAssignment, + TenantCreateRequest, + TenantStatus, + TenantUpdateRequest, +} from "./TenantsTypes"; + +/** + * Turns what the workspace form holds into what the API expects. + * + * Extracted so it can be tested. A form that quietly stops sending a field is + * the defect this codebase has already had twice on the server side — + * `provisioning_endpoint` and `grace_period_days` were both accepted by a schema + * and dropped before they reached the model — and the console half is worse, + * because it compiles, renders, saves, and reports success. + * + * The rules, once, rather than in each form: + * + * - **Trim, then treat empty as absent.** A field somebody typed into and + * cleared should read as "not set", not as the empty string. `""` is a value + * the API will happily store, and a workspace whose `billing_email` is `""` + * gets no warning before it lapses while looking configured. + * - **`undefined`, never `null`, on create.** The request is serialised with + * `JSON.stringify`, which drops `undefined` keys and sends `null` ones. On + * create those mean the same thing; on update they do not. + * - **On update, `null` is how you clear something.** `undefined` leaves it + * alone. Collapsing the two would make a field impossible to unset once set. + */ + +const trimmed = (value: string | null | undefined): string | undefined => { + const text = (value ?? "").trim(); + return text === "" ? undefined : text; +}; + +export type TenantFormValues = { + tenantName: string; + tenantDomain: string; + tenantLogoUrl?: string; + billingEmail?: string; + planId: string; + startDate?: string; + endDate?: string; + status: TenantStatus; + moduleEnvironments?: ModuleEnvironmentAssignment[]; +}; + +export const buildTenantCreatePayload = ( + values: TenantFormValues +): TenantCreateRequest => ({ + tenant_name: values.tenantName.trim(), + tenant_domain: values.tenantDomain.trim(), + tenant_logo_url: trimmed(values.tenantLogoUrl), + billing_email: trimmed(values.billingEmail), + plan_id: values.planId, + start_date: trimmed(values.startDate), + end_date: trimmed(values.endDate), + status: values.status, + // An assignment with no environment chosen is not an assignment. Sending it + // would pin the workspace to an empty slug, which the server now refuses — + // and used to accept, silently falling back to production. + module_environments: (values.moduleEnvironments ?? []).filter( + (assignment) => assignment.environment_slug + ), +}); + +export type TenantEditValues = { + tenantName: string; + tenantDomain: string; + tenantLogoUrl: string; + billingEmail: string; + isActive: boolean; + planId?: string; + startDate?: string; + endDate?: string; + status?: TenantStatus; +}; + +export const buildTenantUpdatePayload = ( + values: TenantEditValues +): TenantUpdateRequest => ({ + tenant_name: values.tenantName.trim(), + tenant_domain: values.tenantDomain.trim(), + // `null` rather than `undefined`: on an edit form, clearing the logo means + // remove it. `undefined` would leave the old one in place and look like the + // save had not worked. + tenant_logo_url: trimmed(values.tenantLogoUrl) ?? null, + billing_email: trimmed(values.billingEmail) ?? null, + is_active: values.isActive, + // A plan cannot be removed through this form — an empty selection means + // "unchanged", not "no plan" — so this one stays undefined. + plan_id: values.planId || undefined, + start_date: trimmed(values.startDate) ?? null, + end_date: trimmed(values.endDate) ?? null, + status: values.status || undefined, +}); diff --git a/src/application/tenants/components/AddTenants.tsx b/src/application/tenants/components/AddTenants.tsx index 1af21ad..e0578fb 100644 --- a/src/application/tenants/components/AddTenants.tsx +++ b/src/application/tenants/components/AddTenants.tsx @@ -4,7 +4,7 @@ import { CustomButton, CustomDatePicker, CustomInput, CustomDropdown } from "../ import CustomBackButton from "../../../components/custom/CustomBackButton"; import { CustomLoader } from "../../../components/custom"; import { tenantsApi } from "../TenantsApi"; -import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes"; +import type { Tenant, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes"; import { useAuth } from "../../../context/AuthContext"; import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi"; import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes"; @@ -12,6 +12,7 @@ import type { RoleAccess } from "../../roles/RolesTypes"; import { adminModuleApi } from "../../modules/admin/AdminModuleApi"; import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes"; import { AlertCircle } from "lucide-react"; +import { buildTenantCreatePayload } from "../buildTenantPayload"; const toIsoDate = (value: Date) => value.toISOString().slice(0, 10); @@ -31,6 +32,7 @@ const AddTenants = () => { const [tenantName, setTenantName] = useState(""); const [tenantDomain, setTenantDomain] = useState(""); const [tenantLogoUrl, setTenantLogoUrl] = useState(""); + const [billingEmail, setBillingEmail] = useState(""); const [selectedPlanId, setSelectedPlanId] = useState(""); const [startDate, setStartDate] = useState(today); const [endDate, setEndDate] = useState(""); @@ -212,16 +214,17 @@ const AddTenants = () => { setIsLoading(true); try { - const payload: TenantCreateRequest = { - tenant_name: tenantName.trim(), - tenant_domain: tenantDomain.trim(), - tenant_logo_url: tenantLogoUrl.trim() || undefined, - plan_id: selectedPlanId, - start_date: startDate || undefined, - end_date: endDate || undefined, + const payload = buildTenantCreatePayload({ + tenantName, + tenantDomain, + tenantLogoUrl, + billingEmail, + planId: selectedPlanId, + startDate, + endDate, status: tenantStatus, - module_environments: moduleEnvAssignments.filter((a) => a.environment_slug), - }; + moduleEnvironments: moduleEnvAssignments, + }); await tenantsApi.create(payload); navigate("/tenants"); @@ -321,6 +324,19 @@ const AddTenants = () => { onChange={(e) => setTenantLogoUrl(e.target.value)} /> + {/* The only address the platform will ever write to about this + workspace's subscription. A workspace without one gets no warning + before it lapses — the notice worker logs that gap, but nobody + reads a log on a customer's behalf. */} + setBillingEmail(e.target.value)} + /> +

Subscription Plan

{ tenant_name: "", tenant_domain: "", tenant_logo_url: "", + billing_email: "", is_active: true, plan_id: "", start_date: "", @@ -318,6 +321,7 @@ const AllTenants = () => { tenant_name: tenant.tenant_name, tenant_domain: tenant.tenant_domain, tenant_logo_url: tenant.tenant_logo_url ?? "", + billing_email: tenant.billing_email ?? "", is_active: tenant.is_active, plan_id: tenant.plan_id ?? "", start_date: tenant.start_date ?? "", @@ -374,16 +378,17 @@ const AllTenants = () => { setIsSaving(true); try { - const payload: TenantUpdateRequest = { - tenant_name: editForm.tenant_name.trim(), - tenant_domain: editForm.tenant_domain.trim(), - tenant_logo_url: editForm.tenant_logo_url.trim() || null, - is_active: editForm.is_active, - plan_id: editForm.plan_id || undefined, - start_date: editForm.start_date || null, - end_date: editForm.end_date || null, + const payload = buildTenantUpdatePayload({ + tenantName: editForm.tenant_name, + tenantDomain: editForm.tenant_domain, + tenantLogoUrl: editForm.tenant_logo_url, + billingEmail: editForm.billing_email, + isActive: editForm.is_active, + planId: editForm.plan_id, + startDate: editForm.start_date, + endDate: editForm.end_date, status: editForm.status, - }; + }); const updatedTenant = await tenantsApi.update(tenantId, payload); @@ -841,6 +846,19 @@ const AllTenants = () => { onChange={handleEditChange} /> + {/* The only address the platform will ever write to about this + workspace's subscription. A workspace without one gets no warning + before it lapses — the notice worker logs that gap, but nobody + reads a log on a customer's behalf. */} + +
{ const [tenantFilter, setTenantFilter] = useState([]); const [roleFilter, setRoleFilter] = useState([]); const [totalRows, setTotalRows] = useState(0); + // Bumped when a restored account should reappear in the list. A counter + // rather than calling `loadUsers` from outside the effect: the effect already + // owns every parameter the query depends on, and a second caller would drift + // from it the first time a filter is added. + const [refreshToken, setRefreshToken] = useState(0); const [activeSort, setActiveSort] = useState<{ column: "first_name" | "email" | "status" | "tenant_id" | "role_id" | null; direction: ColumnSortDirection; @@ -211,6 +217,7 @@ const AllUsers = () => { tenantFilter, roleFilter, activeSort, + refreshToken, ]); // Reset page on search/filter change @@ -592,9 +599,17 @@ const AllUsers = () => {

Manage system users, their roles, and account status.

- - + {t('add')} - +
+ {/* Inviting is the primary action now: it is the one where the + person sets their own password, so nobody else ever knows it. + Adding directly stays available and stays secondary. */} + + {t('invite', 'Invite')} + + + + {t('add')} + +
@@ -627,6 +642,13 @@ const AllUsers = () => { /> )} + {/* Below the live list and collapsed by default: this answers "we removed + the wrong person this morning", and putting it above would make the + ordinary case read as an afterthought. */} + + setRefreshToken((n) => n + 1)} /> + + {/* View Modal */} void }> = ({ + onRestored, +}) => { + const { t, i18n } = useTranslation(["users", "common"]); + + const [isOpen, setIsOpen] = useState(false); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [failed, setFailed] = useState(false); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + setItems( + await apiClient.get("/api/user/deleted", { toast: false }) + ); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (isOpen) void load(); + }, [isOpen, load]); + + const restore = async (user: DeletedUser) => { + setBusyId(user.id); + setError(""); + try { + await apiClient.post(`/api/user/${user.id}/restore`, null, { + successMessage: t("deleted.restored"), + errorMessage: t("deleted.restoreFailed"), + }); + setItems((current) => current.filter((item) => item.id !== user.id)); + onRestored?.(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("deleted.restoreFailed")); + } finally { + setBusyId(null); + } + }; + + const Chevron = isOpen ? ChevronDown : ChevronRight; + + return ( +
+ + + {isOpen && ( +
+ {error &&

{error}

} + + {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("deleted.loadFailed")} +

+ ) : items.length === 0 ? ( +

+ {t("deleted.empty")} +

+ ) : ( +
    + {items.map((user) => ( +
  • +
    + + {user.email} + +

    + {t("deleted.on", { + when: formatDate(user.deleted_at, i18n.language), + })} +

    +
    + + void restore(user)} + disabled={busyId === user.id} + > + + {t("deleted.restore")} + +
  • + ))} +
+ )} +
+ )} +
+ ); +}; + +export default DeletedUsersPanel; diff --git a/src/application/webhooks/WebhookApi.ts b/src/application/webhooks/WebhookApi.ts new file mode 100644 index 0000000..621a72a --- /dev/null +++ b/src/application/webhooks/WebhookApi.ts @@ -0,0 +1,66 @@ +import { apiClient } from "../../lib/apiClient"; +import type { + WebhookDeliveryList, + WebhookEndpoint, + WebhookEndpointCreated, + WebhookEndpointList, + WebhookTestResult, +} from "./WebhookTypes"; + +export const webhookApi = { + list: () => apiClient.get("/api/webhooks"), + + register: (payload: { + url: string; + description?: string | null; + event_types: string[]; + }) => + apiClient.post("/api/webhooks", payload, { + // The response carries the signing secret, which the person has to copy + // into their own receiver. A success toast beside it invites dismissing + // the dialog that matters. + toast: false, + }), + + update: ( + id: string, + payload: { + description?: string | null; + event_types?: string[]; + is_active?: boolean; + } + ) => + apiClient.put(`/api/webhooks/${id}`, payload, { + successMessage: "Endpoint updated", + errorMessage: "Could not update the endpoint", + }), + + rotateSecret: (id: string) => + apiClient.post<{ secret: string }>( + `/api/webhooks/${id}/rotate-secret`, + null, + { toast: false } + ), + + remove: (id: string) => + apiClient.delete(`/api/webhooks/${id}`, { + successMessage: "Endpoint removed", + errorMessage: "Could not remove the endpoint", + }), + + deliveries: (id: string, status?: string) => + apiClient.get( + `/api/webhooks/${id}/deliveries${status ? `?status=${status}` : ""}`, + { toast: false } + ), + + /** + * Sent inline rather than queued: somebody is sitting in front of the form + * waiting to find out whether their URL works, and "queued" answers a + * different question than the one they asked. + */ + sendTest: (id: string) => + apiClient.post(`/api/webhooks/${id}/test`, null, { + toast: false, + }), +}; diff --git a/src/application/webhooks/WebhookTypes.ts b/src/application/webhooks/WebhookTypes.ts new file mode 100644 index 0000000..1f59793 --- /dev/null +++ b/src/application/webhooks/WebhookTypes.ts @@ -0,0 +1,66 @@ +export type WebhookEndpoint = { + id: string; + url: string; + description?: string | null; + /** Empty means every event. The first endpoint a workspace registers is + * usually "send me what you have". */ + event_types: string[]; + is_active: boolean; + /** Set when the platform switched it off itself, so the reason is on the + * record rather than only in a log somewhere the customer cannot read. */ + disabled_reason?: string | null; + consecutive_failures: number; + last_success_at?: string | null; + last_failure_at?: string | null; + created_at: string; +}; + +export type WebhookEndpointCreated = { + endpoint: WebhookEndpoint; + /** Shown at registration and again only on rotation. Encrypted rather than + * hashed, because the customer has to put this exact value into their + * receiver to verify signatures. */ + secret: string; +}; + +export type WebhookDelivery = { + id: string; + event_id: string; + event_type: string; + status: "pending" | "delivered" | "failed"; + attempts: number; + response_status?: number | null; + error?: string | null; + next_attempt_at: string; + delivered_at?: string | null; + created_at: string; +}; + +export type WebhookEndpointList = { + items: WebhookEndpoint[]; + total: number; +}; + +export type WebhookDeliveryList = { + items: WebhookDelivery[]; + total: number; +}; + +export type WebhookTestResult = { + delivered: boolean; + response_status?: number | null; + error?: string | null; +}; + +/** The events the platform emits. Kept in step with the server's catalogue — + * a name that is not on this list is refused at registration, deliberately, + * because a typo would otherwise leave somebody waiting for an event that can + * never arrive. */ +export const EVENT_TYPES = [ + "user.created", + "user.updated", + "user.deleted", + "user.invited", + "invitation.accepted", + "subscription.changed", +] as const; diff --git a/src/application/webhooks/WebhooksPage.tsx b/src/application/webhooks/WebhooksPage.tsx new file mode 100644 index 0000000..08bb8d6 --- /dev/null +++ b/src/application/webhooks/WebhooksPage.tsx @@ -0,0 +1,509 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertTriangle, + Copy, + Plus, + RefreshCw, + Send, + Trash2, + Webhook, +} from "lucide-react"; + +import { + CustomButton, + CustomCheckBox, + CustomConfirmationModal, + CustomInput, + CustomLoader, + CustomModal, +} from "../../components/custom"; +import { formatDate } from "../../lib/dateFormat"; +import { webhookApi } from "./WebhookApi"; +import { EVENT_TYPES } from "./WebhookTypes"; +import type { + WebhookDelivery, + WebhookEndpoint, + WebhookTestResult, +} from "./WebhookTypes"; + +/** + * A workspace's own webhook endpoints. + * + * The screen exists mostly to answer one question — "we never got it" — so the + * delivery log is not a secondary feature. Without it the only available answer + * is asking the customer to trust that we tried. + * + * The other thing it has to do is make a **switched-off endpoint impossible to + * miss**. The platform disables one after twenty consecutive failures, and from + * the customer's side an endpoint failing silently looks exactly like one that + * was never called. The reason is on the record; this puts it on the screen. + */ + +const SecretDialog: React.FC<{ value: string; onDone: () => void }> = ({ + value, + onDone, +}) => { + const { t } = useTranslation(["webhooks", "common"]); + const [acknowledged, setAcknowledged] = useState(false); + + return ( +
+

{t("secret.explain")}

+ +
+ + {value} + + void navigator.clipboard?.writeText(value)} + > + + +
+ + + + + {t("secret.done")} + +
+ ); +}; + +const DeliveryList: React.FC<{ endpointId: string }> = ({ endpointId }) => { + const { t, i18n } = useTranslation(["webhooks", "common"]); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + void (async () => { + setIsLoading(true); + setFailed(false); + try { + const list = await webhookApi.deliveries(endpointId); + if (!cancelled) setItems(list.items); + } catch { + if (!cancelled) setFailed(true); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [endpointId]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (failed) { + return ( +

+ {t("errors.loadFailed")} +

+ ); + } + + if (items.length === 0) { + return ( +

+ {t("deliveries.empty")} +

+ ); + } + + const badge = (status: WebhookDelivery["status"]) => { + const styles: Record = { + delivered: + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", + pending: + "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", + failed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + }; + return ( + + {t(`deliveries.status.${status}`)} + + ); + }; + + return ( +
    + {items.map((delivery) => ( +
  • +
    + + {delivery.event_type} + + {badge(delivery.status)} + {delivery.response_status != null && ( + + HTTP {delivery.response_status} + + )} +
    +

    + {[ + formatDate(delivery.created_at, i18n.language), + t("deliveries.attempts", { count: delivery.attempts }), + ].join(" · ")} +

    + {delivery.error && ( +

    {delivery.error}

    + )} +
  • + ))} +
+ ); +}; + +const WebhooksPage: React.FC = () => { + const { t, i18n } = useTranslation(["webhooks", "common"]); + + const [endpoints, setEndpoints] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [failed, setFailed] = useState(false); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [url, setUrl] = useState(""); + const [description, setDescription] = useState(""); + const [selected, setSelected] = useState([]); + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(""); + + const [secret, setSecret] = useState(null); + const [pendingRemove, setPendingRemove] = useState(null); + const [openDeliveries, setOpenDeliveries] = useState(null); + const [testResult, setTestResult] = useState< + (WebhookTestResult & { url: string }) | null + >(null); + + const load = useCallback(async () => { + setIsLoading(true); + setFailed(false); + try { + const list = await webhookApi.list(); + setEndpoints(list.items); + } catch { + setFailed(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const register = async () => { + setError(""); + setIsBusy(true); + try { + const created = await webhookApi.register({ + url: url.trim(), + description: description.trim() || null, + event_types: selected, + }); + setIsCreateOpen(false); + setUrl(""); + setDescription(""); + setSelected([]); + setSecret(created.secret); + await load(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("errors.generic")); + } finally { + setIsBusy(false); + } + }; + + const reEnable = async (endpoint: WebhookEndpoint) => { + // Re-enabling clears the failure count on the server too, so a fix gets a + // chance to prove itself rather than being one blip from being switched off + // again. + await webhookApi.update(endpoint.id, { is_active: true }); + await load(); + }; + + const rotate = async (endpoint: WebhookEndpoint) => { + const rotated = await webhookApi.rotateSecret(endpoint.id); + setSecret(rotated.secret); + }; + + const sendTest = async (endpoint: WebhookEndpoint) => { + const result = await webhookApi.sendTest(endpoint.id); + setTestResult({ ...result, url: endpoint.url }); + await load(); + }; + + const remove = async () => { + if (!pendingRemove) return; + const target = pendingRemove; + setPendingRemove(null); + try { + await webhookApi.remove(target.id); + } finally { + await load(); + } + }; + + const toggleEvent = (name: string) => { + setSelected((current) => + current.includes(name) + ? current.filter((item) => item !== name) + : [...current, name] + ); + }; + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + setIsCreateOpen(true)}> + + {t("add")} + +
+ +
+ {isLoading ? ( +
+ +
+ ) : failed ? ( +

+ {t("errors.loadFailed")} +

+ ) : endpoints.length === 0 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
    + {endpoints.map((endpoint) => ( +
  • + {/* A switched-off endpoint first and loudest. From the + customer's side, one failing silently is indistinguishable + from one that was never called. */} + {!endpoint.is_active && ( +
    + +
    +

    {endpoint.disabled_reason ?? t("disabled.generic")}

    + +
    +
    + )} + +
    +
    +

    + {endpoint.url} +

    + {endpoint.description && ( +

    + {endpoint.description} +

    + )} +

    + {[ + endpoint.event_types.length === 0 + ? t("events.all") + : t("events.some", { + count: endpoint.event_types.length, + }), + endpoint.last_success_at + ? t("lastSuccess", { + when: formatDate( + endpoint.last_success_at, + i18n.language + ), + }) + : t("neverDelivered"), + ].join(" · ")} +

    +
    + +
    + void sendTest(endpoint)} + > + + {t("test")} + + setOpenDeliveries(endpoint)} + > + {t("deliveries.open")} + + void rotate(endpoint)} + > + + {t("rotate")} + + setPendingRemove(endpoint)} + > + + +
    +
    +
  • + ))} +
+ )} +
+ + { + setIsCreateOpen(false); + setError(""); + }} + title={t("add")} + > +
+ setUrl(event.target.value)} + /> +

+ {t("form.urlNote")} +

+ + setDescription(event.target.value)} + /> + +
+

+ {t("form.events")} +

+

+ {t("form.eventsNote")} +

+
+ {EVENT_TYPES.map((name) => ( + toggleEvent(name)} + /> + ))} +
+
+ + {error &&

{error}

} + + + {t("form.submit")} + +
+
+ + setSecret(null)} + title={t("secret.title")} + > + {secret && setSecret(null)} />} + + + setOpenDeliveries(null)} + title={t("deliveries.title")} + > + {openDeliveries && } + + + setTestResult(null)} + title={t("testResult.title")} + > + {testResult && ( +
+

+ {testResult.delivered + ? t("testResult.ok", { status: testResult.response_status ?? 200 }) + : t("testResult.failed")} +

+ {testResult.error && ( +

+ {testResult.error} +

+ )} +
+ )} +
+ + setPendingRemove(null)} + onConfirm={remove} + title={t("confirmRemove.title")} + description={t("confirmRemove.message", { url: pendingRemove?.url ?? "" })} + confirmText={t("confirmRemove.confirm")} + /> +
+ ); +}; + +export default WebhooksPage; diff --git a/src/components/custom/CustomColumnFilter.utils.test.ts b/src/components/custom/CustomColumnFilter.utils.test.ts new file mode 100644 index 0000000..d75db47 --- /dev/null +++ b/src/components/custom/CustomColumnFilter.utils.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { + buildColumnFilterOptions, + resolveColumnSortState, +} from "./CustomColumnFilter.utils"; + +/** + * Shared by every sortable, filterable table in the console. + * + * `resolveColumnSortState` is the one worth reading twice. It is two nested + * conditionals deciding what happens when you clear a sort, and the interesting + * case is clearing a sort on a column that is *not* the one currently sorted — + * which must leave the existing sort alone rather than dropping it. + */ + +describe("buildColumnFilterOptions", () => { + it("puts the count in the label and the bare value in the value", () => { + // The label is for reading; the value is what goes in the request. Send + // "ACTIVE (12)" as a filter and it matches nothing. + expect(buildColumnFilterOptions([["ACTIVE", 12]])).toEqual([ + { label: "ACTIVE (12)", value: "ACTIVE" }, + ]); + }); + + it("keeps the order it was given", () => { + // Usually a Map built in insertion order. Re-sorting here would make the + // dropdown disagree with whatever the caller arranged. + expect( + buildColumnFilterOptions([ + ["b", 1], + ["a", 2], + ]).map((option) => option.value) + ).toEqual(["b", "a"]); + }); + + it("accepts a Map directly", () => { + const counts = new Map([["EXPIRED", 3]]); + + expect(buildColumnFilterOptions(counts)).toEqual([ + { label: "EXPIRED (3)", value: "EXPIRED" }, + ]); + }); + + it("handles nothing to show", () => { + expect(buildColumnFilterOptions([])).toEqual([]); + }); + + it("keeps a zero count rather than hiding the option", () => { + // "0" is informative: it says the value exists and nothing currently + // matches. Dropping it makes the filter list change shape as data does. + expect(buildColumnFilterOptions([["NONE", 0]])).toEqual([ + { label: "NONE (0)", value: "NONE" }, + ]); + }); +}); + +describe("resolveColumnSortState", () => { + const none = { column: null, direction: null } as const; + + it("sorts an unsorted table", () => { + expect(resolveColumnSortState(none, "name", "asc")).toEqual({ + column: "name", + direction: "asc", + }); + }); + + it("reverses the column already sorted", () => { + expect( + resolveColumnSortState({ column: "name", direction: "asc" }, "name", "desc") + ).toEqual({ column: "name", direction: "desc" }); + }); + + it("moves the sort to a different column", () => { + expect( + resolveColumnSortState({ column: "name", direction: "asc" }, "created", "desc") + ).toEqual({ column: "created", direction: "desc" }); + }); + + it("clears the sort when the sorted column is cleared", () => { + expect( + resolveColumnSortState({ column: "name", direction: "asc" }, "name", null) + ).toEqual({ column: null, direction: null }); + }); + + it("leaves the sort alone when a different column is cleared", () => { + // The case worth having a test for. Clearing a filter on column B while + // column A is sorted must not unsort A — the user touched something + // else, and the table jumping back to its default order reads as a bug. + expect( + resolveColumnSortState({ column: "name", direction: "asc" }, "created", null) + ).toEqual({ column: "name", direction: "asc" }); + }); + + it("stays cleared when clearing an already-unsorted table", () => { + expect(resolveColumnSortState(none, "name", null)).toEqual(none); + }); +}); diff --git a/src/components/custom/CustomInput.tsx b/src/components/custom/CustomInput.tsx index 27e74a2..edddd13 100644 --- a/src/components/custom/CustomInput.tsx +++ b/src/components/custom/CustomInput.tsx @@ -1,4 +1,4 @@ -import React, { useState, forwardRef } from "react"; +import React, { useId, useState, forwardRef } from "react"; import { Phone, Eye, EyeOff } from "lucide-react"; type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "url"; @@ -32,6 +32,13 @@ const CustomInput = forwardRef( }, ref ) => { + // `htmlFor` pointed at `props.id`, and no caller passed one — so every + // label in the product was decoration: clicking it did nothing, and a screen + // reader could not say which field it belonged to. Generated when absent, so + // this is fixed everywhere at once rather than form by form. + const generatedId = useId(); + const inputId = props.id ?? generatedId; + const isPassword = type === "password"; const isPhone = phonePrefix !== undefined; const [showPassword, setShowPassword] = useState(false); @@ -51,7 +58,7 @@ const CustomInput = forwardRef( return (
{label && ( -