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.
- package/dist/index.js +98 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/template/frontend/_env.example +7 -0
- package/template/frontend/package.json +2 -2
- package/template/frontend/src/App.jsx +24 -0
- package/template/frontend/src/auth/AuthContext.jsx +73 -30
- package/template/frontend/src/auth/CaptchaWidget.jsx +108 -0
- package/template/frontend/src/auth/captchaProvider.js +23 -0
- package/template/frontend/src/auth/captchaProvider.test.mjs +44 -0
- package/template/frontend/src/auth/captchaRender.js +125 -0
- package/template/frontend/src/auth/captchaRender.test.mjs +182 -0
- package/template/frontend/src/auth/passwordVisibility.js +9 -0
- package/template/frontend/src/auth/passwordVisibility.test.mjs +12 -0
- package/template/frontend/src/auth/reenrollWording.js +29 -0
- package/template/frontend/src/auth/reenrollWording.test.mjs +23 -0
- package/template/frontend/src/auth/routeGate.js +10 -0
- package/template/frontend/src/auth/routeGate.test.mjs +22 -1
- package/template/frontend/src/auth/sessionFlow.js +80 -4
- package/template/frontend/src/auth/sessionFlow.test.mjs +212 -1
- package/template/frontend/src/auth/signInOptions.js +64 -0
- package/template/frontend/src/auth/signInOptions.test.mjs +94 -0
- package/template/frontend/src/auth/useSignInOptions.js +34 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +49 -3
- package/template/frontend/src/pages/Login.jsx +54 -4
- package/template/frontend/src/pages/ResetPassword.jsx +83 -11
- package/template/frontend/src/pages/account/ReenrollPage.jsx +17 -11
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +9 -6
- package/template/frontend/src/pages/audit/AuditPage.jsx +17 -3
- package/template/frontend/src/pages/audit/actorKind.js +17 -0
- package/template/frontend/src/pages/audit/actorKind.test.mjs +23 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +104 -4
- package/template/frontend/src/pages/companies/moveAction.js +61 -0
- package/template/frontend/src/pages/companies/moveAction.test.mjs +91 -0
- package/template/frontend/src/pages/org-structure/OrgStructurePage.jsx +408 -0
- package/template/frontend/src/pages/org-structure/orgStructure.js +140 -0
- package/template/frontend/src/pages/org-structure/orgStructure.test.mjs +199 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +32 -14
- package/template/frontend/src/pages/roles/RolesPage.jsx +167 -42
- package/template/frontend/src/pages/roles/roleRules.js +104 -0
- package/template/frontend/src/pages/roles/roleRules.test.mjs +178 -0
- package/template/frontend/src/pages/security/PolicyFormDialog.jsx +243 -0
- package/template/frontend/src/pages/security/SecuritySettingsPage.jsx +229 -0
- package/template/frontend/src/pages/security/securityPolicies.js +246 -0
- package/template/frontend/src/pages/security/securityPolicies.test.mjs +429 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +5 -2
- package/template/frontend/src/pages/users/PostingRow.jsx +13 -10
- package/template/frontend/src/pages/users/SignInActionDialog.jsx +98 -0
- package/template/frontend/src/pages/users/UserForm.jsx +65 -3
- package/template/frontend/src/pages/users/UsersPage.jsx +91 -3
- package/template/frontend/src/pages/users/loginName.js +108 -0
- package/template/frontend/src/pages/users/loginName.test.mjs +133 -0
- package/template/frontend/src/pages/users/signInAdmin.js +122 -0
- package/template/frontend/src/pages/users/signInAdmin.test.mjs +183 -0
- package/template/frontend/src/platform/httpClient.js +40 -28
- package/template/frontend/src/platform/httpClient.test.mjs +71 -0
- package/template/frontend/src/platform/shieldTransport.js +12 -12
- package/template/frontend/src/shell/AppShell.jsx +4 -0
- package/template/frontend/src/shell/GraceBanner.jsx +28 -0
- package/template/frontend/src/shell/graceDeadline.js +50 -0
- package/template/frontend/src/shell/graceDeadline.test.mjs +59 -0
- package/template/frontend/src/shell/nav.js +30 -1
- package/template/frontend/src/viteConfig.test.mjs +63 -0
- package/template/frontend/vite/captchaCsp.js +99 -0
- package/template/frontend/vite.config.js +14 -3
- package/template/server/_env.example +25 -0
- package/template/server/package.json +9 -6
- 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`
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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:
|
|
61
|
-
: await identity.
|
|
70
|
+
? await identity.updateRole(token, uuid, { name: checked.value })
|
|
71
|
+
: await identity.createCompanyRole(token, companyUuid, checked.value);
|
|
62
72
|
setBusy(false);
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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={
|
|
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={
|
|
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)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
214
|
+
const o = roleOutcome(await identity.deleteRole(token, deleteTarget.id), 'delete');
|
|
134
215
|
setDeleteBusy(false);
|
|
135
|
-
if (
|
|
136
|
-
toast.error(
|
|
216
|
+
if (o.kind === 'error') {
|
|
217
|
+
toast.error(o.text);
|
|
137
218
|
return;
|
|
138
219
|
}
|
|
139
220
|
setDeleteTarget(null);
|
|
140
|
-
toast.success(
|
|
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
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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}
|