create-astram-app 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/index.js +98 -4
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/frontend/_env.example +7 -0
  5. package/template/frontend/package.json +2 -2
  6. package/template/frontend/src/App.jsx +24 -0
  7. package/template/frontend/src/auth/AuthContext.jsx +73 -30
  8. package/template/frontend/src/auth/CaptchaWidget.jsx +108 -0
  9. package/template/frontend/src/auth/captchaProvider.js +23 -0
  10. package/template/frontend/src/auth/captchaProvider.test.mjs +44 -0
  11. package/template/frontend/src/auth/captchaRender.js +125 -0
  12. package/template/frontend/src/auth/captchaRender.test.mjs +182 -0
  13. package/template/frontend/src/auth/passwordVisibility.js +9 -0
  14. package/template/frontend/src/auth/passwordVisibility.test.mjs +12 -0
  15. package/template/frontend/src/auth/reenrollWording.js +29 -0
  16. package/template/frontend/src/auth/reenrollWording.test.mjs +23 -0
  17. package/template/frontend/src/auth/routeGate.js +10 -0
  18. package/template/frontend/src/auth/routeGate.test.mjs +22 -1
  19. package/template/frontend/src/auth/sessionFlow.js +80 -4
  20. package/template/frontend/src/auth/sessionFlow.test.mjs +212 -1
  21. package/template/frontend/src/auth/signInOptions.js +64 -0
  22. package/template/frontend/src/auth/signInOptions.test.mjs +94 -0
  23. package/template/frontend/src/auth/useSignInOptions.js +34 -0
  24. package/template/frontend/src/pages/ForgotPassword.jsx +49 -3
  25. package/template/frontend/src/pages/Login.jsx +54 -4
  26. package/template/frontend/src/pages/ResetPassword.jsx +83 -11
  27. package/template/frontend/src/pages/account/ReenrollPage.jsx +17 -11
  28. package/template/frontend/src/pages/account/TwoFactorPage.jsx +9 -6
  29. package/template/frontend/src/pages/audit/AuditPage.jsx +17 -3
  30. package/template/frontend/src/pages/audit/actorKind.js +17 -0
  31. package/template/frontend/src/pages/audit/actorKind.test.mjs +23 -0
  32. package/template/frontend/src/pages/companies/CompaniesPage.jsx +104 -4
  33. package/template/frontend/src/pages/companies/moveAction.js +61 -0
  34. package/template/frontend/src/pages/companies/moveAction.test.mjs +91 -0
  35. package/template/frontend/src/pages/org-structure/OrgStructurePage.jsx +408 -0
  36. package/template/frontend/src/pages/org-structure/orgStructure.js +140 -0
  37. package/template/frontend/src/pages/org-structure/orgStructure.test.mjs +199 -0
  38. package/template/frontend/src/pages/roles/RoleForm.jsx +32 -14
  39. package/template/frontend/src/pages/roles/RolesPage.jsx +167 -42
  40. package/template/frontend/src/pages/roles/roleRules.js +104 -0
  41. package/template/frontend/src/pages/roles/roleRules.test.mjs +178 -0
  42. package/template/frontend/src/pages/security/PolicyFormDialog.jsx +243 -0
  43. package/template/frontend/src/pages/security/SecuritySettingsPage.jsx +229 -0
  44. package/template/frontend/src/pages/security/securityPolicies.js +246 -0
  45. package/template/frontend/src/pages/security/securityPolicies.test.mjs +429 -0
  46. package/template/frontend/src/pages/users/MembershipCard.jsx +5 -2
  47. package/template/frontend/src/pages/users/PostingRow.jsx +13 -10
  48. package/template/frontend/src/pages/users/SignInActionDialog.jsx +98 -0
  49. package/template/frontend/src/pages/users/UserForm.jsx +65 -3
  50. package/template/frontend/src/pages/users/UsersPage.jsx +91 -3
  51. package/template/frontend/src/pages/users/loginName.js +108 -0
  52. package/template/frontend/src/pages/users/loginName.test.mjs +133 -0
  53. package/template/frontend/src/pages/users/signInAdmin.js +122 -0
  54. package/template/frontend/src/pages/users/signInAdmin.test.mjs +183 -0
  55. package/template/frontend/src/platform/httpClient.js +40 -28
  56. package/template/frontend/src/platform/httpClient.test.mjs +71 -0
  57. package/template/frontend/src/platform/shieldTransport.js +12 -12
  58. package/template/frontend/src/shell/AppShell.jsx +4 -0
  59. package/template/frontend/src/shell/GraceBanner.jsx +28 -0
  60. package/template/frontend/src/shell/graceDeadline.js +50 -0
  61. package/template/frontend/src/shell/graceDeadline.test.mjs +59 -0
  62. package/template/frontend/src/shell/nav.js +30 -1
  63. package/template/frontend/src/viteConfig.test.mjs +63 -0
  64. package/template/frontend/vite/captchaCsp.js +99 -0
  65. package/template/frontend/vite.config.js +14 -3
  66. package/template/server/_env.example +25 -0
  67. package/template/server/package.json +9 -6
  68. package/template/server/src/app.module.ts +17 -6
@@ -0,0 +1,199 @@
1
+ // orgStructure.test.mjs — G-012: the Org structure page's permission keys, name rules (spec §10),
2
+ // advisory delete hint, display rows (names only, never ids) and toast text.
3
+ //
4
+ // node --test src/pages/org-structure/orgStructure.test.mjs
5
+ import { test, describe } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import {
8
+ KINDS,
9
+ LABELS,
10
+ MAX_NAME_LENGTH,
11
+ PERMISSIONS,
12
+ canManage,
13
+ deleteHint,
14
+ displayRows,
15
+ outcome,
16
+ usedByLabel,
17
+ validateName,
18
+ } from './orgStructure.js';
19
+
20
+ describe('permission keys', () => {
21
+ test('department and branch keys match the server routes', () => {
22
+ assert.deepEqual(KINDS, ['department', 'branch']);
23
+ assert.deepEqual(PERMISSIONS.department, {
24
+ read: 'identity:company_department:read',
25
+ create: 'identity:department:create',
26
+ update: 'identity:department:update',
27
+ delete: 'identity:department:delete',
28
+ });
29
+ assert.deepEqual(PERMISSIONS.branch, {
30
+ read: 'identity:company_branch:read',
31
+ create: 'identity:branch:create',
32
+ update: 'identity:branch:update',
33
+ delete: 'identity:branch:delete',
34
+ });
35
+ });
36
+ });
37
+
38
+ describe('canManage', () => {
39
+ const holding =
40
+ (...keys) =>
41
+ (k) =>
42
+ keys.includes(k);
43
+
44
+ test('read only: lists without write buttons', () => {
45
+ assert.deepEqual(canManage(holding('identity:company_department:read'), 'department'), {
46
+ read: true,
47
+ create: false,
48
+ rename: false,
49
+ remove: false,
50
+ });
51
+ });
52
+
53
+ test('with the Org Structure bundle keys: every action', () => {
54
+ const can = holding(...Object.values(PERMISSIONS.branch));
55
+ assert.deepEqual(canManage(can, 'branch'), {
56
+ read: true,
57
+ create: true,
58
+ rename: true,
59
+ remove: true,
60
+ });
61
+ // A branch key never unlocks departments.
62
+ assert.equal(canManage(can, 'department').create, false);
63
+ });
64
+
65
+ test('fails closed: no projection, an unknown kind, or a non-boolean answer', () => {
66
+ const none = { read: false, create: false, rename: false, remove: false };
67
+ assert.deepEqual(canManage(undefined, 'department'), none);
68
+ assert.deepEqual(
69
+ canManage(() => true, 'team'),
70
+ none,
71
+ );
72
+ assert.deepEqual(
73
+ canManage(() => 'yes', 'department'),
74
+ none,
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('validateName (spec §10)', () => {
80
+ test('trims and collapses whitespace', () => {
81
+ assert.deepEqual(validateName(' Sales Ops '), { ok: true, value: 'Sales Ops' });
82
+ assert.deepEqual(validateName('Sales\t\nOps'), { ok: true, value: 'Sales Ops' });
83
+ });
84
+
85
+ test('normalises to NFC', () => {
86
+ const decomposed = 'Qualité';
87
+ const r = validateName(decomposed);
88
+ assert.equal(r.ok, true);
89
+ assert.equal(r.value, 'Qualité');
90
+ });
91
+
92
+ test('refuses control characters (C0 and C1)', () => {
93
+ for (const bad of ['Sa\u0000les', 'Sa\u0007les', 'Sa\u007fles', 'Sa\u0085les', 'Sa\u009fles']) {
94
+ assert.deepEqual(validateName(bad), {
95
+ ok: false,
96
+ error: 'The name cannot contain control characters.',
97
+ });
98
+ }
99
+ });
100
+
101
+ test('refuses an empty or blank name', () => {
102
+ for (const blank of ['', ' ', '\n\t', undefined, null]) {
103
+ assert.deepEqual(validateName(blank), { ok: false, error: 'Enter a name.' });
104
+ }
105
+ });
106
+
107
+ test('1 to 150 characters after normalising, counted as characters', () => {
108
+ assert.equal(MAX_NAME_LENGTH, 150);
109
+ assert.equal(validateName('x'.repeat(150)).ok, true);
110
+ assert.equal(validateName(` ${'x'.repeat(150)} `).ok, true);
111
+ assert.deepEqual(validateName('x'.repeat(151)), {
112
+ ok: false,
113
+ error: 'The name can be at most 150 characters.',
114
+ });
115
+ // 150 astral characters are 300 UTF-16 units but 150 characters.
116
+ assert.equal(validateName('\u{1F600}'.repeat(150)).ok, true);
117
+ });
118
+ });
119
+
120
+ describe('deleteHint and usedByLabel (advisory)', () => {
121
+ test('used by assignments ⇒ disabled with the count', () => {
122
+ assert.deepEqual(deleteHint({ usedBy: 3 }), { disabled: true, hint: 'Used by 3 assignments' });
123
+ assert.deepEqual(deleteHint({ usedBy: 1 }), { disabled: true, hint: 'Used by 1 assignment' });
124
+ });
125
+
126
+ test('unused, or no count from the server ⇒ enabled (the server decides)', () => {
127
+ assert.deepEqual(deleteHint({ usedBy: 0 }), { disabled: false, hint: '' });
128
+ assert.deepEqual(deleteHint({}), { disabled: false, hint: '' });
129
+ assert.deepEqual(deleteHint(null), { disabled: false, hint: '' });
130
+ });
131
+
132
+ test('"used by N" only when the server sent a count', () => {
133
+ assert.equal(usedByLabel({ usedBy: 0 }), 'Used by 0 assignments');
134
+ assert.equal(usedByLabel({ usedBy: 2 }), 'Used by 2 assignments');
135
+ assert.equal(usedByLabel({}), '');
136
+ });
137
+ });
138
+
139
+ describe('displayRows', () => {
140
+ test('sorted by name; labels are names, never ids', () => {
141
+ const rows = displayRows([
142
+ { uuid: 'd-2', name: 'Sales', usedBy: 2 },
143
+ { uuid: 'd-1', name: 'Finance' },
144
+ { uuid: 'd-3', name: '' },
145
+ { name: 'No id' },
146
+ ]);
147
+ assert.deepEqual(rows, [
148
+ { uuid: 'd-3', label: '(unnamed)' },
149
+ { uuid: 'd-1', label: 'Finance' },
150
+ { uuid: 'd-2', label: 'Sales', usedBy: 2 },
151
+ ]);
152
+ for (const r of rows) assert.notEqual(r.label, r.uuid);
153
+ });
154
+
155
+ test('no rows ⇒ empty', () => {
156
+ assert.deepEqual(displayRows(undefined), []);
157
+ });
158
+ });
159
+
160
+ describe('outcome', () => {
161
+ test('success text per kind and action', () => {
162
+ assert.deepEqual(outcome({ ok: true }, 'department', 'create'), {
163
+ kind: 'success',
164
+ text: 'Department added',
165
+ });
166
+ assert.deepEqual(outcome({ ok: true }, 'branch', 'rename'), {
167
+ kind: 'success',
168
+ text: 'Branch renamed',
169
+ });
170
+ assert.deepEqual(outcome({ ok: true }, 'branch', 'delete'), {
171
+ kind: 'success',
172
+ text: 'Branch deleted',
173
+ });
174
+ });
175
+
176
+ test('a refusal → the server message', () => {
177
+ const message = 'A department named "Qualité" already exists in this company.';
178
+ assert.deepEqual(outcome({ ok: false, error: message }, 'department', 'create'), {
179
+ kind: 'error',
180
+ text: message,
181
+ });
182
+ });
183
+
184
+ test('no message → a fixed fallback', () => {
185
+ assert.deepEqual(outcome({ ok: false }, 'department', 'delete'), {
186
+ kind: 'error',
187
+ text: 'Could not delete the department.',
188
+ });
189
+ assert.deepEqual(outcome(null, 'branch', 'create'), {
190
+ kind: 'error',
191
+ text: 'Could not add the branch.',
192
+ });
193
+ });
194
+
195
+ test('labels and empty states', () => {
196
+ assert.equal(LABELS.department.empty, 'No departments yet');
197
+ assert.equal(LABELS.branch.empty, 'No branches yet');
198
+ });
199
+ });
@@ -2,9 +2,12 @@
2
2
  * RoleForm — the Add/Edit role form (a single Name field), rendered as a routed-modal
3
3
  * overlay by /roles/add and /roles/edit/:uuid under RolesPage's layout. The SAME
4
4
  * component serves add + edit via `useParams()` (a `:uuid` → edit, load first; else add).
5
+ * Add creates the role in the company selected on RolesPage (`createCompanyRole`, G-013); the
6
+ * name is checked with the server's rule first (`validateRoleName`), and a refusal (duplicate
7
+ * name in this company, bad name, out of reach) is toasted with the server's message.
5
8
  * Closes via `navigate(-1)`; on success toasts + calls the layout's `onRefresh`.
6
9
  *
7
- * ⛔ The `:uuid` is a route param only — never rendered as visible text.
10
+ * ⛔ The `:uuid` and the company uuid are params only — never rendered as visible text.
8
11
  */
9
12
  import { useEffect, useState } from 'react';
10
13
  import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
@@ -13,6 +16,7 @@ import { useAuth } from '@/auth/AuthContext';
13
16
  import EntityFormModal from '@/components/EntityFormModal';
14
17
  import { Input } from '@/components/ui/input';
15
18
  import { Label } from '@/components/ui/label';
19
+ import { roleOutcome, validateRoleName } from './roleRules';
16
20
 
17
21
  export default function RoleForm() {
18
22
  const { tokens, identity } = useAuth();
@@ -20,7 +24,8 @@ export default function RoleForm() {
20
24
  const { uuid } = useParams();
21
25
  const isEdit = Boolean(uuid);
22
26
  const navigate = useNavigate();
23
- const { onRefresh } = useOutletContext() ?? {};
27
+ const { onRefresh, company } = useOutletContext() ?? {};
28
+ const companyUuid = company?.value ?? null;
24
29
 
25
30
  const [name, setName] = useState('');
26
31
  const [loading, setLoading] = useState(isEdit);
@@ -47,38 +52,50 @@ export default function RoleForm() {
47
52
  }, [identity, token, uuid, isEdit]);
48
53
 
49
54
  const close = () => navigate(-1);
55
+ const noCompany = !isEdit && !companyUuid;
50
56
 
51
57
  const onSubmit = async () => {
52
- const trimmed = name.trim();
53
- if (!trimmed) {
54
- setError('Enter a role name.');
58
+ if (noCompany) {
59
+ setError('Choose a company first.');
60
+ return;
61
+ }
62
+ const checked = validateRoleName(name);
63
+ if (!checked.ok) {
64
+ setError(checked.error);
55
65
  return;
56
66
  }
57
67
  setError('');
58
68
  setBusy(true);
59
69
  const r = isEdit
60
- ? await identity.updateRole(token, uuid, { name: trimmed })
61
- : await identity.createRole(token, trimmed);
70
+ ? await identity.updateRole(token, uuid, { name: checked.value })
71
+ : await identity.createCompanyRole(token, companyUuid, checked.value);
62
72
  setBusy(false);
63
- if (!r.ok) {
64
- setError(r.error || 'Could not save the role.');
73
+ const o = roleOutcome(r, isEdit ? 'rename' : 'create');
74
+ if (o.kind === 'error') {
75
+ toast.error(o.text);
65
76
  return;
66
77
  }
67
- toast.success(isEdit ? 'Role updated' : 'Role created');
78
+ toast.success(o.text);
68
79
  onRefresh?.();
69
80
  close();
70
81
  };
71
82
 
83
+ const description = isEdit
84
+ ? undefined
85
+ : company?.label
86
+ ? `Create a named grant in ${company.label}.`
87
+ : 'Create a named grant in the selected company.';
88
+
72
89
  return (
73
90
  <EntityFormModal
74
91
  title={isEdit ? 'Edit role' : 'Add role'}
75
- description={isEdit ? undefined : 'Create a named grant.'}
92
+ description={description}
76
93
  open
77
94
  onClose={close}
78
95
  onSubmit={onSubmit}
79
96
  submitLabel={isEdit ? 'Save changes' : 'Create role'}
80
97
  busy={busy || loading}
81
- error={error}
98
+ error={noCompany ? 'Choose a company on the Roles page first.' : error}
82
99
  >
83
100
  <div className="space-y-1.5">
84
101
  <Label htmlFor="role-name" variant="field">
@@ -87,10 +104,11 @@ export default function RoleForm() {
87
104
  <Input
88
105
  id="role-name"
89
106
  autoFocus
90
- maxLength={120}
107
+ maxLength={300}
91
108
  placeholder="e.g. Admin, Approver, Viewer"
92
109
  value={name}
93
- disabled={loading}
110
+ disabled={loading || noCompany}
111
+ aria-invalid={!!error}
94
112
  onChange={(e) => setName(e.target.value)}
95
113
  />
96
114
  </div>
@@ -1,21 +1,32 @@
1
1
  /**
2
- * RolesPage — the Roles management screen (name-only catalog). Same routed-modal CRUD
3
- * shape as CompaniesPage: a layout route owning the list + `<Outlet context={{ onRefresh }} />`
4
- * for the /roles/add and /roles/edit/:uuid child routes. Loads on mount via the shared
5
- * identity client service; a 403 (roles are root-only server-side) degrades to a
6
- * graceful "need platform-admin access" message.
2
+ * RolesPage — the Roles management screen (name-only catalog), per company (G-013). Pick a
3
+ * company from the caller's reachable companies (`identity.listCompanies`, as the Org structure
4
+ * page), then list and create that company's own roles (`listCompanyRoles`,
5
+ * `createCompanyRole`). Each company owns its roles: the same name may exist in another company,
6
+ * and a role can only be posted in its own company.
7
7
  *
8
- * ⛔ No raw UUIDs: the single column renders `fields.name`; `row.id` is a key/route param only.
8
+ * Same routed-modal CRUD shape as CompaniesPage: a layout route owning the list +
9
+ * `<Outlet context={{ onRefresh, company }} />` for the /roles/add and /roles/edit/:uuid child
10
+ * routes. The server walls every route to the caller's subtree (404 outside it) and decides every
11
+ * write; refusals (duplicate name in this company, bad name) are toasted with the server's
12
+ * message. Buttons show only for callers holding the role keys (`roleRules.js`; UX only).
13
+ *
14
+ * ⛔ No raw UUIDs: the columns render `fields.name`; `row.id` and the company uuid are
15
+ * keys/route params only.
9
16
  */
10
- import { useCallback, useEffect, useState } from 'react';
17
+ import { useCallback, useEffect, useRef, useState } from 'react';
11
18
  import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
12
- import { ShieldCheck } from 'lucide-react';
19
+ import { RefreshCw, ShieldCheck } from 'lucide-react';
13
20
  import { toast } from 'sonner';
14
21
  import { useAuth } from '@/auth/AuthContext';
15
22
  import AppShell from '@/shell/AppShell';
16
23
  import PageHeader from '@/components/PageHeader';
17
24
  import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
18
25
  import ConfirmDialog from '@/components/ConfirmDialog';
26
+ import { Button } from '@/components/ui/button';
27
+ import { Label } from '@/components/ui/label';
28
+ import { SearchableSelect } from '@/components/ui/searchable-select';
29
+ import { EMPTY_ROLES_LABEL, canManageRoles, nameOptions, roleOutcome } from './roleRules';
19
30
 
20
31
  /** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
21
32
  function formatDate(v) {
@@ -70,15 +81,22 @@ function readQueryFromParams(sp) {
70
81
  }
71
82
 
72
83
  export default function RolesPage() {
73
- const { tokens, identity } = useAuth();
84
+ const { tokens, identity, can } = useAuth();
74
85
  const token = tokens?.accessToken;
75
86
  const navigate = useNavigate();
76
87
  const [searchParams, setSearchParams] = useSearchParams();
88
+ const perms = canManageRoles(can);
89
+
90
+ // The company whose roles are shown: `{ value: uuid, label: name }`; the uuid is never shown.
91
+ const [company, setCompany] = useState(null);
92
+ const [companyError, setCompanyError] = useState('');
93
+ const [companiesLoaded, setCompaniesLoaded] = useState(false);
77
94
 
78
95
  const [rows, setRows] = useState([]);
79
96
  const [total, setTotal] = useState(0);
80
- const [loading, setLoading] = useState(true);
97
+ const [loading, setLoading] = useState(false);
81
98
  const [error, setError] = useState('');
99
+ const request = useRef(0);
82
100
 
83
101
  // Seed ONCE from the URL (initializer runs only on mount) → single source of truth.
84
102
  const [query, setQuery] = useState(() => readQueryFromParams(searchParams));
@@ -99,10 +117,53 @@ export default function RolesPage() {
99
117
  const [deleteTarget, setDeleteTarget] = useState(null);
100
118
  const [deleteBusy, setDeleteBusy] = useState(false);
101
119
 
102
- const load = useCallback(async () => {
120
+ // The caller's reachable companies, searchable and paged by the server, sorted by name.
121
+ const fetchCompanies = useCallback(
122
+ async ({ search, page, limit }) => {
123
+ const r = await identity.listCompanies(token, {
124
+ page,
125
+ limit,
126
+ search,
127
+ sortBy: 'name',
128
+ sortOrder: 'asc',
129
+ });
130
+ if (!r.ok) return { options: [], total: 0, page };
131
+ return { options: nameOptions(r.rows), total: r.total ?? 0, page: r.page ?? page };
132
+ },
133
+ [identity, token],
134
+ );
135
+
136
+ // Start on the first reachable company by name, so a one-company admin sees it at once.
137
+ const loadFirstCompany = useCallback(async () => {
103
138
  if (!token) return;
139
+ const r = await identity.listCompanies(token, {
140
+ page: 1,
141
+ limit: 1,
142
+ sortBy: 'name',
143
+ sortOrder: 'asc',
144
+ });
145
+ setCompaniesLoaded(true);
146
+ if (!r.ok) {
147
+ setCompanyError(r.error || 'Could not load companies.');
148
+ return;
149
+ }
150
+ setCompanyError('');
151
+ const [first] = nameOptions(r.rows);
152
+ if (first) setCompany((current) => current ?? first);
153
+ }, [identity, token]);
154
+
155
+ useEffect(() => {
156
+ loadFirstCompany();
157
+ }, [loadFirstCompany]);
158
+
159
+ const companyUuid = company?.value ?? null;
160
+
161
+ const load = useCallback(async () => {
162
+ if (!token || !companyUuid) return;
163
+ const mine = ++request.current;
104
164
  setLoading(true);
105
- const r = await identity.listRoles(token, query);
165
+ const r = await identity.listCompanyRoles(token, companyUuid, query);
166
+ if (mine !== request.current) return; // a newer company or query superseded this one
106
167
  if (r.ok) {
107
168
  setRows(r.rows || []);
108
169
  setTotal(r.total || 0);
@@ -113,12 +174,32 @@ export default function RolesPage() {
113
174
  setError(r.error || 'Could not load roles.');
114
175
  }
115
176
  setLoading(false);
116
- }, [identity, token, query]);
177
+ }, [identity, token, companyUuid, query]);
117
178
 
118
179
  useEffect(() => {
119
180
  load();
120
181
  }, [load]);
121
182
 
183
+ const onCompanyChange = (value) => {
184
+ if (!value) {
185
+ request.current += 1; // drop any in-flight list for the old company
186
+ setCompany(null);
187
+ setRows([]);
188
+ setTotal(0);
189
+ setError('');
190
+ setLoading(false);
191
+ return;
192
+ }
193
+ if (value === companyUuid) return;
194
+ // Clear the old company's rows so they never show under the new company's name.
195
+ setRows([]);
196
+ setTotal(0);
197
+ setCompany({ value, label: '' });
198
+ setQuery((q) => ({ ...q, page: 1 }));
199
+ };
200
+ const onCompanyLabel = (label) =>
201
+ setCompany((current) => (current && label ? { ...current, label } : current));
202
+
122
203
  const onSearchChange = useCallback((search) => setQuery((q) => ({ ...q, search, page: 1 })), []);
123
204
  const onSortChange = useCallback(
124
205
  (sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
@@ -130,20 +211,20 @@ export default function RolesPage() {
130
211
  const onConfirmDelete = async () => {
131
212
  if (!deleteTarget) return;
132
213
  setDeleteBusy(true);
133
- const r = await identity.deleteRole(token, deleteTarget.id);
214
+ const o = roleOutcome(await identity.deleteRole(token, deleteTarget.id), 'delete');
134
215
  setDeleteBusy(false);
135
- if (!r.ok) {
136
- toast.error(r.error || 'Could not delete the role.');
216
+ if (o.kind === 'error') {
217
+ toast.error(o.text);
137
218
  return;
138
219
  }
139
220
  setDeleteTarget(null);
140
- toast.success('Role deleted');
221
+ toast.success(o.text);
141
222
  await load();
142
223
  };
143
224
 
144
225
  return (
145
226
  <AppShell section="Identity" title="Roles">
146
- <div className="p-4">
227
+ <div className="space-y-4 p-4">
147
228
  <PageHeader
148
229
  section="Identity"
149
230
  title="Roles"
@@ -154,37 +235,81 @@ export default function RolesPage() {
154
235
  onSearchChange={onSearchChange}
155
236
  searchPlaceholder="Search roles…"
156
237
  addLabel="Add role"
157
- onAdd={() => navigate('/roles/add')}
238
+ onAdd={perms.create && company ? () => navigate('/roles/add') : undefined}
158
239
  />
159
240
  }
160
241
  />
161
242
 
162
- <EntityList
163
- columns={COLUMNS}
164
- rows={rows}
165
- search={query.search}
166
- sortBy={query.sortBy}
167
- sortOrder={query.sortOrder}
168
- loading={loading}
169
- error={error}
170
- emptyLabel="No roles yet. Add one to get started."
171
- onEdit={(row) => navigate(`/roles/edit/${row.id}`)}
172
- onDelete={(row) => setDeleteTarget(row)}
173
- onSortChange={onSortChange}
174
- />
243
+ <div className="max-w-md space-y-1.5">
244
+ <Label htmlFor="roles-company" variant="field">
245
+ Company
246
+ </Label>
247
+ <SearchableSelect
248
+ id="roles-company"
249
+ aria-label="Company"
250
+ value={companyUuid}
251
+ selectedLabel={company?.label || null}
252
+ onChange={onCompanyChange}
253
+ onLabelChange={onCompanyLabel}
254
+ fetchPage={fetchCompanies}
255
+ placeholder="Select a company"
256
+ searchPlaceholder="Search companies"
257
+ />
258
+ {companyError && (
259
+ <div className="flex items-center gap-2 text-sm text-destructive">
260
+ <span>{companyError}</span>
261
+ <Button type="button" variant="outline" size="sm" onClick={loadFirstCompany}>
262
+ <RefreshCw className="size-4" />
263
+ Retry
264
+ </Button>
265
+ </div>
266
+ )}
267
+ </div>
175
268
 
176
- <EntityListPager
177
- total={total}
178
- page={query.page}
179
- limit={query.limit}
180
- loading={loading}
181
- error={error}
182
- onPageChange={onPageChange}
183
- onLimitChange={onLimitChange}
184
- />
269
+ {company ? (
270
+ <div>
271
+ {error && (
272
+ <div className="mb-2 flex justify-end">
273
+ <Button type="button" variant="outline" size="sm" onClick={load} disabled={loading}>
274
+ <RefreshCw className="size-4" />
275
+ Retry
276
+ </Button>
277
+ </div>
278
+ )}
279
+
280
+ <EntityList
281
+ columns={COLUMNS}
282
+ rows={rows}
283
+ search={query.search}
284
+ sortBy={query.sortBy}
285
+ sortOrder={query.sortOrder}
286
+ loading={loading}
287
+ error={error}
288
+ emptyLabel={EMPTY_ROLES_LABEL}
289
+ onEdit={perms.rename ? (row) => navigate(`/roles/edit/${row.id}`) : undefined}
290
+ onDelete={perms.remove ? (row) => setDeleteTarget(row) : undefined}
291
+ onSortChange={onSortChange}
292
+ />
293
+
294
+ <EntityListPager
295
+ total={total}
296
+ page={query.page}
297
+ limit={query.limit}
298
+ loading={loading}
299
+ error={error}
300
+ onPageChange={onPageChange}
301
+ onLimitChange={onLimitChange}
302
+ />
303
+ </div>
304
+ ) : (
305
+ companiesLoaded &&
306
+ !companyError && (
307
+ <p className="text-sm text-muted-foreground">Choose a company to see its roles.</p>
308
+ )
309
+ )}
185
310
  </div>
186
311
 
187
- <Outlet context={{ onRefresh: load }} />
312
+ <Outlet context={{ onRefresh: load, company }} />
188
313
 
189
314
  <ConfirmDialog
190
315
  open={!!deleteTarget}