create-astram-app 0.1.3 → 0.3.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/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +122 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +191 -0
- package/template/frontend/src/auth/AuthContext.jsx +343 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +103 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +27 -15
- package/template/server/package.json +7 -4
- package/template/server/src/app.module.ts +28 -2
- package/template/server/src/health/demo.controller.ts +23 -12
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AccessForm — the routed Assign / Edit / Duplicate access form (the two-pane guided UI:
|
|
3
|
+
* preset rail + paginated role select + searchable grouped CRUD matrix). Rendered as a
|
|
4
|
+
* routed-modal overlay by /access/add and /access/edit/:uuid under AccessPage's layout —
|
|
5
|
+
* the same routed-modal pattern as RoleForm/UserForm.
|
|
6
|
+
*
|
|
7
|
+
* Mode (from the URL):
|
|
8
|
+
* /access/edit/:uuid → edit (role fixed = :uuid, matrix seeded from its grants)
|
|
9
|
+
* /access/add?from=<uuid> → duplicate (matrix seeded from the source, role empty → pick a target)
|
|
10
|
+
* /access/add → assign (empty role, empty matrix)
|
|
11
|
+
*
|
|
12
|
+
* Save diffs the working matrix vs. the role's loaded grants → grant/revokePermission per
|
|
13
|
+
* key. decide()/grant model unchanged. ⛔ No raw UUIDs: roles shown by name; a uuid is a
|
|
14
|
+
* route param / select value / API arg only.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
17
|
+
import { useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
|
|
18
|
+
import { toast } from 'sonner';
|
|
19
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
20
|
+
import EntityFormModal from '@/components/EntityFormModal';
|
|
21
|
+
import { Input } from '@/components/ui/input';
|
|
22
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
23
|
+
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
24
|
+
import CollapsibleGroup from '@/components/access/CollapsibleGroup';
|
|
25
|
+
|
|
26
|
+
const ACTIONS = ['read', 'create', 'update', 'delete'];
|
|
27
|
+
|
|
28
|
+
// Object → group mapping. An unmapped typeKey falls into "Other" (fail-safe).
|
|
29
|
+
const GROUPS = [
|
|
30
|
+
{ label: 'Organization', types: ['company', 'company_branch', 'company_department', 'department', 'branch'] },
|
|
31
|
+
{ label: 'People & Access', types: ['user', 'role', 'company_membership', 'membership_posting'] },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export default function AccessForm() {
|
|
35
|
+
const { tokens, identity } = useAuth();
|
|
36
|
+
const token = tokens?.accessToken;
|
|
37
|
+
const navigate = useNavigate();
|
|
38
|
+
const { uuid } = useParams();
|
|
39
|
+
const [searchParams] = useSearchParams();
|
|
40
|
+
const { onRefresh } = useOutletContext() ?? {};
|
|
41
|
+
|
|
42
|
+
const fromUuid = searchParams.get('from');
|
|
43
|
+
const mode = uuid ? 'edit' : fromUuid ? 'duplicate' : 'assign';
|
|
44
|
+
const sourceUuid = uuid || fromUuid || null;
|
|
45
|
+
|
|
46
|
+
const [catalog, setCatalog] = useState([]);
|
|
47
|
+
const [bundles, setBundles] = useState([]);
|
|
48
|
+
const [roleUuid, setRoleUuid] = useState('');
|
|
49
|
+
const [roleLabel, setRoleLabel] = useState(null);
|
|
50
|
+
const [loaded, setLoaded] = useState(new Set()); // diff baseline
|
|
51
|
+
const [working, setWorking] = useState(new Set()); // being edited
|
|
52
|
+
const [objectSearch, setObjectSearch] = useState('');
|
|
53
|
+
const [openGroups, setOpenGroups] = useState(() =>
|
|
54
|
+
Object.fromEntries(GROUPS.map((g) => [g.label, true])),
|
|
55
|
+
);
|
|
56
|
+
const [loading, setLoading] = useState(true);
|
|
57
|
+
const [saving, setSaving] = useState(false);
|
|
58
|
+
const [error, setError] = useState('');
|
|
59
|
+
|
|
60
|
+
const moduleName = catalog[0]?.module || 'identity';
|
|
61
|
+
|
|
62
|
+
const typeKeys = useMemo(() => {
|
|
63
|
+
const seen = [];
|
|
64
|
+
for (const c of catalog) if (!seen.includes(c.typeKey)) seen.push(c.typeKey);
|
|
65
|
+
return seen;
|
|
66
|
+
}, [catalog]);
|
|
67
|
+
const groups = useMemo(() => {
|
|
68
|
+
const mapped = new Set(GROUPS.flatMap((g) => g.types));
|
|
69
|
+
const out = GROUPS.map((g) => ({ label: g.label, types: g.types.filter((t) => typeKeys.includes(t)) }));
|
|
70
|
+
const other = typeKeys.filter((t) => !mapped.has(t));
|
|
71
|
+
if (other.length) out.push({ label: 'Other', types: other });
|
|
72
|
+
return out.filter((g) => g.types.length > 0);
|
|
73
|
+
}, [typeKeys]);
|
|
74
|
+
|
|
75
|
+
const labelOf = useMemo(() => {
|
|
76
|
+
const m = {};
|
|
77
|
+
for (const c of catalog) m[c.key] = c.label;
|
|
78
|
+
return m;
|
|
79
|
+
}, [catalog]);
|
|
80
|
+
const keyExists = useCallback((key) => Boolean(labelOf[key]), [labelOf]);
|
|
81
|
+
const cellKey = useCallback((t, a) => `${moduleName}:${t}:${a}`, [moduleName]);
|
|
82
|
+
|
|
83
|
+
const keyHeld = useCallback((key, set) => {
|
|
84
|
+
if (set.has(key)) return true;
|
|
85
|
+
const [mod, type] = key.split(':');
|
|
86
|
+
if (!mod || !type) return false;
|
|
87
|
+
return set.has(`${mod}:*`) || set.has(`${mod}:${type}:*`);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
// Paginated + searchable role fetcher for the SearchableSelect.
|
|
91
|
+
const fetchRoles = useCallback(
|
|
92
|
+
async ({ search, page, limit }) => {
|
|
93
|
+
const r = await identity.listRoles(token, { page, limit, search, sortBy: 'name', sortOrder: 'asc' });
|
|
94
|
+
if (!r.ok) return { options: [], total: 0, page };
|
|
95
|
+
const options = (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' }));
|
|
96
|
+
return { options, total: r.total ?? options.length, page: r.page ?? page };
|
|
97
|
+
},
|
|
98
|
+
[identity, token],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Load catalog + bundles + (for edit/duplicate) the source role's grants, on mount.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!token) return undefined;
|
|
104
|
+
let active = true;
|
|
105
|
+
(async () => {
|
|
106
|
+
setLoading(true);
|
|
107
|
+
const [catRes, bundlesRes, grantsRes, roleRes] = await Promise.all([
|
|
108
|
+
identity.listPermissionCatalog(token),
|
|
109
|
+
identity.listAccessBundles(token),
|
|
110
|
+
sourceUuid ? identity.listRolePermissions(token, sourceUuid) : Promise.resolve({ ok: true, grants: [] }),
|
|
111
|
+
// In EDIT mode the role is fixed — resolve its NAME so the (disabled) role select
|
|
112
|
+
// shows it on the trigger (a uuid is never displayed).
|
|
113
|
+
mode === 'edit' && sourceUuid
|
|
114
|
+
? identity.getRole(token, sourceUuid)
|
|
115
|
+
: Promise.resolve({ ok: false }),
|
|
116
|
+
]);
|
|
117
|
+
if (!active) return;
|
|
118
|
+
if (catRes.ok) setCatalog(catRes.catalog || []);
|
|
119
|
+
else setError(catRes.error || 'Could not load the permission catalog.');
|
|
120
|
+
const bundleList = bundlesRes.ok ? bundlesRes.bundles || [] : [];
|
|
121
|
+
if (bundlesRes.ok) setBundles(bundleList);
|
|
122
|
+
|
|
123
|
+
const seedKeys = new Set(grantsRes.ok ? grantsRes.grants || [] : []);
|
|
124
|
+
if (mode === 'edit') {
|
|
125
|
+
setRoleUuid(sourceUuid);
|
|
126
|
+
setRoleLabel(roleRes.ok ? roleRes.record?.fields?.name || null : null);
|
|
127
|
+
setLoaded(new Set(seedKeys)); // edit diffs against the role's own grants
|
|
128
|
+
} else {
|
|
129
|
+
// assign/duplicate: role empty, baseline empty (all keys are new to the target).
|
|
130
|
+
setLoaded(new Set());
|
|
131
|
+
}
|
|
132
|
+
setWorking(new Set(seedKeys));
|
|
133
|
+
// (No preset state to set — a preset's "Applied" badge is DERIVED from whether its
|
|
134
|
+
// keys are all held in `working`, so any preset the seeded grants cover shows
|
|
135
|
+
// Applied automatically. That's what makes Edit reflect the role's existing preset.)
|
|
136
|
+
setLoading(false);
|
|
137
|
+
})();
|
|
138
|
+
return () => {
|
|
139
|
+
active = false;
|
|
140
|
+
};
|
|
141
|
+
}, [identity, token, sourceUuid, mode]);
|
|
142
|
+
|
|
143
|
+
const onPickRole = async (picked) => {
|
|
144
|
+
setRoleUuid(picked || '');
|
|
145
|
+
if (!picked) {
|
|
146
|
+
setLoaded(new Set());
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (mode !== 'edit') {
|
|
150
|
+
const r = await identity.listRolePermissions(token, picked);
|
|
151
|
+
setLoaded(new Set(r.ok ? r.grants : []));
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const toggle = (key) => {
|
|
156
|
+
setWorking((prev) => {
|
|
157
|
+
const next = new Set(prev);
|
|
158
|
+
if (next.has(key)) next.delete(key);
|
|
159
|
+
else next.add(key);
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const toggleGroupAll = (group) => {
|
|
165
|
+
const groupKeys = group.types.flatMap((t) => ACTIONS.map((a) => cellKey(t, a))).filter(keyExists);
|
|
166
|
+
const allHeld = groupKeys.every((k) => keyHeld(k, working));
|
|
167
|
+
setWorking((prev) => {
|
|
168
|
+
const next = new Set(prev);
|
|
169
|
+
for (const k of groupKeys) {
|
|
170
|
+
if (allHeld) next.delete(k);
|
|
171
|
+
else next.add(k);
|
|
172
|
+
}
|
|
173
|
+
return next;
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// A preset is APPLIED iff every one of its keys is currently held — CHECKBOX-like +
|
|
178
|
+
// derived from the working set, so multiple presets can be applied at once (they may
|
|
179
|
+
// overlap) and toggling matrix cells keeps each preset's applied state in sync.
|
|
180
|
+
const isBundleApplied = useCallback(
|
|
181
|
+
(bundle) =>
|
|
182
|
+
bundle.permissionKeys.length > 0 && bundle.permissionKeys.every((k) => keyHeld(k, working)),
|
|
183
|
+
[keyHeld, working],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Toggle a preset: not fully applied → ADD its keys (union); fully applied → REMOVE its
|
|
187
|
+
// keys. Independent per preset (no radio semantics).
|
|
188
|
+
const togglePreset = (bundle) => {
|
|
189
|
+
const applied = isBundleApplied(bundle);
|
|
190
|
+
setWorking((prev) => {
|
|
191
|
+
const next = new Set(prev);
|
|
192
|
+
for (const k of bundle.permissionKeys) {
|
|
193
|
+
if (applied) next.delete(k);
|
|
194
|
+
else next.add(k);
|
|
195
|
+
}
|
|
196
|
+
return next;
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const { permCount, objectCount } = useMemo(() => {
|
|
201
|
+
const held = new Set();
|
|
202
|
+
for (const t of typeKeys) {
|
|
203
|
+
for (const a of ACTIONS) {
|
|
204
|
+
const k = cellKey(t, a);
|
|
205
|
+
if (keyExists(k) && keyHeld(k, working)) held.add(k);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const types = new Set([...held].map((k) => k.split(':')[1]));
|
|
209
|
+
return { permCount: held.size, objectCount: types.size };
|
|
210
|
+
}, [working, typeKeys, cellKey, keyExists, keyHeld]);
|
|
211
|
+
|
|
212
|
+
// The applied presets (derived) — shown in the footer. Multiple can be applied.
|
|
213
|
+
const appliedPresetLabels = bundles.filter(isBundleApplied).map((b) => b.label);
|
|
214
|
+
|
|
215
|
+
const close = () => navigate(-1);
|
|
216
|
+
|
|
217
|
+
const save = async () => {
|
|
218
|
+
if (!roleUuid) {
|
|
219
|
+
setError('Select a role first.');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
setError('');
|
|
223
|
+
setSaving(true);
|
|
224
|
+
const toGrant = [...working].filter((k) => !keyHeld(k, loaded));
|
|
225
|
+
const toRevoke = [...loaded].filter((k) => !working.has(k));
|
|
226
|
+
let failed = 0;
|
|
227
|
+
for (const key of toGrant) {
|
|
228
|
+
const r = await identity.grantPermission(token, roleUuid, key);
|
|
229
|
+
if (!r.ok) failed += 1;
|
|
230
|
+
}
|
|
231
|
+
for (const key of toRevoke) {
|
|
232
|
+
const r = await identity.revokePermission(token, roleUuid, key);
|
|
233
|
+
if (!r.ok) failed += 1;
|
|
234
|
+
}
|
|
235
|
+
setSaving(false);
|
|
236
|
+
if (failed === 0) {
|
|
237
|
+
toast.success('Access saved');
|
|
238
|
+
onRefresh?.();
|
|
239
|
+
close();
|
|
240
|
+
} else {
|
|
241
|
+
setError('Some permissions could not be saved.');
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const matchesSearch = (t) =>
|
|
246
|
+
!objectSearch.trim() || t.toLowerCase().includes(objectSearch.trim().toLowerCase());
|
|
247
|
+
|
|
248
|
+
const title = mode === 'edit' ? 'Edit permissions' : mode === 'duplicate' ? 'Duplicate access' : 'Assign access';
|
|
249
|
+
const description =
|
|
250
|
+
mode === 'edit'
|
|
251
|
+
? 'Adjust the permissions granted to this role.'
|
|
252
|
+
: mode === 'duplicate'
|
|
253
|
+
? 'Copy this permission set to another role.'
|
|
254
|
+
: 'Pick a role, apply a preset, then fine-tune the permissions.';
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<EntityFormModal
|
|
258
|
+
title={title}
|
|
259
|
+
description={description}
|
|
260
|
+
open
|
|
261
|
+
onClose={close}
|
|
262
|
+
onSubmit={save}
|
|
263
|
+
submitLabel="Save access"
|
|
264
|
+
busy={saving || loading}
|
|
265
|
+
error={error}
|
|
266
|
+
contentClassName="sm:max-w-4xl"
|
|
267
|
+
>
|
|
268
|
+
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
|
|
269
|
+
{/* Left rail — presets */}
|
|
270
|
+
<div className="flex flex-col gap-2 md:border-r md:border-border md:pr-4">
|
|
271
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Presets</div>
|
|
272
|
+
{bundles.length === 0 && <p className="text-xs text-muted-foreground">No presets available.</p>}
|
|
273
|
+
{bundles.map((b) => {
|
|
274
|
+
const isApplied = isBundleApplied(b);
|
|
275
|
+
return (
|
|
276
|
+
<button
|
|
277
|
+
key={b.key}
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={() => togglePreset(b)}
|
|
280
|
+
className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors ${
|
|
281
|
+
isApplied ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'
|
|
282
|
+
}`}
|
|
283
|
+
>
|
|
284
|
+
{/* Checkbox-like: presets stack (multiple can be applied); this reflects +
|
|
285
|
+
toggles whether ALL of the preset's keys are held. Non-interactive
|
|
286
|
+
itself (the whole card is the button) — pointer-events-none. */}
|
|
287
|
+
<Checkbox checked={isApplied} className="pointer-events-none mt-0.5" tabIndex={-1} />
|
|
288
|
+
<div className="min-w-0">
|
|
289
|
+
<span className="text-sm font-medium text-foreground">{b.label}</span>
|
|
290
|
+
{b.description && (
|
|
291
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{b.description}</p>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
</button>
|
|
295
|
+
);
|
|
296
|
+
})}
|
|
297
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
298
|
+
Presets add their permissions. Tick more than one, then fine-tune below.
|
|
299
|
+
</p>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
{/* Right pane — role + matrix */}
|
|
303
|
+
<div className="flex min-w-0 flex-col gap-3">
|
|
304
|
+
<div className="flex flex-col gap-1">
|
|
305
|
+
<label className="text-xs font-medium text-muted-foreground">Role</label>
|
|
306
|
+
<SearchableSelect
|
|
307
|
+
value={roleUuid || null}
|
|
308
|
+
selectedLabel={roleLabel}
|
|
309
|
+
onChange={onPickRole}
|
|
310
|
+
onLabelChange={(label) => setRoleLabel(label)}
|
|
311
|
+
fetchPage={fetchRoles}
|
|
312
|
+
placeholder="Select a role"
|
|
313
|
+
searchPlaceholder="Search roles"
|
|
314
|
+
disabled={mode === 'edit'}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<Input
|
|
319
|
+
value={objectSearch}
|
|
320
|
+
onChange={(e) => setObjectSearch(e.target.value)}
|
|
321
|
+
placeholder="Search objects"
|
|
322
|
+
/>
|
|
323
|
+
|
|
324
|
+
<div className="flex max-h-[46vh] flex-col gap-2 overflow-y-auto pr-1">
|
|
325
|
+
{groups.map((group) => {
|
|
326
|
+
const visibleTypes = group.types.filter(matchesSearch);
|
|
327
|
+
if (visibleTypes.length === 0) return null;
|
|
328
|
+
const groupCells = group.types.flatMap((t) => ACTIONS.map((a) => cellKey(t, a))).filter(keyExists);
|
|
329
|
+
const heldCount = groupCells.filter((k) => keyHeld(k, working)).length;
|
|
330
|
+
return (
|
|
331
|
+
<CollapsibleGroup
|
|
332
|
+
key={group.label}
|
|
333
|
+
title={group.label}
|
|
334
|
+
count={heldCount}
|
|
335
|
+
total={groupCells.length}
|
|
336
|
+
open={openGroups[group.label] ?? true}
|
|
337
|
+
onToggle={() => setOpenGroups((p) => ({ ...p, [group.label]: !(p[group.label] ?? true) }))}
|
|
338
|
+
right={
|
|
339
|
+
<span
|
|
340
|
+
role="button"
|
|
341
|
+
tabIndex={0}
|
|
342
|
+
onClick={(e) => {
|
|
343
|
+
e.stopPropagation();
|
|
344
|
+
toggleGroupAll(group);
|
|
345
|
+
}}
|
|
346
|
+
onKeyDown={(e) => {
|
|
347
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
348
|
+
e.stopPropagation();
|
|
349
|
+
toggleGroupAll(group);
|
|
350
|
+
}
|
|
351
|
+
}}
|
|
352
|
+
className="text-xs text-primary hover:underline"
|
|
353
|
+
>
|
|
354
|
+
Toggle all
|
|
355
|
+
</span>
|
|
356
|
+
}
|
|
357
|
+
>
|
|
358
|
+
<div className="overflow-x-auto">
|
|
359
|
+
<table className="w-full text-sm">
|
|
360
|
+
<thead>
|
|
361
|
+
<tr className="text-left text-xs uppercase tracking-wide text-muted-foreground">
|
|
362
|
+
<th className="py-1 pr-3 font-medium">Object</th>
|
|
363
|
+
{ACTIONS.map((a) => (
|
|
364
|
+
<th key={a} className="px-2 py-1 text-center font-medium capitalize">
|
|
365
|
+
{a}
|
|
366
|
+
</th>
|
|
367
|
+
))}
|
|
368
|
+
</tr>
|
|
369
|
+
</thead>
|
|
370
|
+
<tbody>
|
|
371
|
+
{visibleTypes.map((t) => (
|
|
372
|
+
<tr key={t} className="border-t border-border">
|
|
373
|
+
<td className="py-1.5 pr-3 text-foreground">{t}</td>
|
|
374
|
+
{ACTIONS.map((a) => {
|
|
375
|
+
const key = cellKey(t, a);
|
|
376
|
+
const exists = keyExists(key);
|
|
377
|
+
const covered = keyHeld(key, working) && !working.has(key);
|
|
378
|
+
return (
|
|
379
|
+
<td key={a} className="px-2 py-1.5 text-center">
|
|
380
|
+
{exists ? (
|
|
381
|
+
<Checkbox
|
|
382
|
+
checked={keyHeld(key, working)}
|
|
383
|
+
disabled={covered}
|
|
384
|
+
title={covered ? 'granted via a wildcard' : labelOf[key]}
|
|
385
|
+
onCheckedChange={() => toggle(key)}
|
|
386
|
+
/>
|
|
387
|
+
) : (
|
|
388
|
+
<span className="text-muted-foreground/50">—</span>
|
|
389
|
+
)}
|
|
390
|
+
</td>
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
393
|
+
</tr>
|
|
394
|
+
))}
|
|
395
|
+
</tbody>
|
|
396
|
+
</table>
|
|
397
|
+
</div>
|
|
398
|
+
</CollapsibleGroup>
|
|
399
|
+
);
|
|
400
|
+
})}
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
<div className="text-sm text-muted-foreground">
|
|
404
|
+
<span className="font-medium text-foreground">
|
|
405
|
+
{permCount} permission{permCount === 1 ? '' : 's'}
|
|
406
|
+
</span>{' '}
|
|
407
|
+
across {objectCount} object{objectCount === 1 ? '' : 's'}
|
|
408
|
+
{appliedPresetLabels.length > 0 && (
|
|
409
|
+
<span> · {appliedPresetLabels.join(', ')}</span>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</EntityFormModal>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AccessPage — Roles & permissions. The listing of roles that have been granted access,
|
|
3
|
+
* on the shared server-driven `EntityList` datatable (URL-param paging / sort / debounced
|
|
4
|
+
* search / mobile reflow). Rich cells (avatar + sub-line, access chips, preset pill, ⋯
|
|
5
|
+
* actions) are supplied via column `render` fns — the shared component is untouched.
|
|
6
|
+
*
|
|
7
|
+
* Assign / Edit / Duplicate happen in routed child forms (/access/add, /access/edit/:uuid)
|
|
8
|
+
* rendered through the <Outlet/> — the routed-modal pattern used by /roles & /users.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ No raw UUIDs: role shown by name (+ initial), grantor by name; roleUuid is a route
|
|
11
|
+
* param / API arg only.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
14
|
+
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
|
|
15
|
+
import { KeyRound } from 'lucide-react';
|
|
16
|
+
import { toast } from 'sonner';
|
|
17
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
18
|
+
import AppShell from '@/shell/AppShell';
|
|
19
|
+
import PageHeader from '@/components/PageHeader';
|
|
20
|
+
import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
|
|
21
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
22
|
+
import { Badge } from '@/components/ui/badge';
|
|
23
|
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
24
|
+
import RoleActionsMenu from './RoleActionsMenu';
|
|
25
|
+
|
|
26
|
+
const MAX_CHIPS = 4;
|
|
27
|
+
const DEFAULT_LIMIT = 25;
|
|
28
|
+
|
|
29
|
+
function initialOf(name) {
|
|
30
|
+
return (name || '?').trim().charAt(0).toUpperCase() || '?';
|
|
31
|
+
}
|
|
32
|
+
function shortDate(iso) {
|
|
33
|
+
if (!iso) return null;
|
|
34
|
+
const d = new Date(iso);
|
|
35
|
+
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString(undefined, {
|
|
36
|
+
day: 'numeric',
|
|
37
|
+
month: 'short',
|
|
38
|
+
year: 'numeric',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Seed the datatable query from the URL so a reload / shared link restores the view. */
|
|
43
|
+
function readQueryFromParams(sp) {
|
|
44
|
+
const page = Number.parseInt(sp.get('page') ?? '', 10);
|
|
45
|
+
const limit = Number.parseInt(sp.get('limit') ?? '', 10);
|
|
46
|
+
const sortOrder = (sp.get('sortOrder') || '').toLowerCase();
|
|
47
|
+
return {
|
|
48
|
+
page: Number.isFinite(page) && page >= 1 ? page : 1,
|
|
49
|
+
limit: Number.isFinite(limit) && limit >= 1 && limit <= 200 ? limit : DEFAULT_LIMIT,
|
|
50
|
+
search: sp.get('search') || '',
|
|
51
|
+
sortBy: sp.get('sortBy') || 'name',
|
|
52
|
+
sortOrder: sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default function AccessPage() {
|
|
57
|
+
const { tokens, identity } = useAuth();
|
|
58
|
+
const token = tokens?.accessToken;
|
|
59
|
+
const navigate = useNavigate();
|
|
60
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
61
|
+
|
|
62
|
+
const [rows, setRows] = useState([]);
|
|
63
|
+
const [total, setTotal] = useState(0);
|
|
64
|
+
const [loading, setLoading] = useState(true);
|
|
65
|
+
const [error, setError] = useState('');
|
|
66
|
+
|
|
67
|
+
const [query, setQuery] = useState(() => readQueryFromParams(searchParams));
|
|
68
|
+
|
|
69
|
+
// query → URL (replace, no history spam).
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const p = new URLSearchParams();
|
|
72
|
+
if (query.page > 1) p.set('page', String(query.page));
|
|
73
|
+
if (query.limit !== DEFAULT_LIMIT) p.set('limit', String(query.limit));
|
|
74
|
+
if (query.search) p.set('search', query.search);
|
|
75
|
+
if (query.sortBy) p.set('sortBy', query.sortBy);
|
|
76
|
+
if (query.sortOrder) p.set('sortOrder', query.sortOrder);
|
|
77
|
+
setSearchParams(p, { replace: true });
|
|
78
|
+
}, [query, setSearchParams]);
|
|
79
|
+
|
|
80
|
+
const [revokeTarget, setRevokeTarget] = useState(null);
|
|
81
|
+
const [revokeBusy, setRevokeBusy] = useState(false);
|
|
82
|
+
|
|
83
|
+
const load = useCallback(async () => {
|
|
84
|
+
if (!token) return;
|
|
85
|
+
setLoading(true);
|
|
86
|
+
const r = await identity.listGrantsSummary(token, query);
|
|
87
|
+
if (r.ok) {
|
|
88
|
+
setRows(r.rows || []);
|
|
89
|
+
setTotal(r.total || 0);
|
|
90
|
+
setError('');
|
|
91
|
+
} else {
|
|
92
|
+
setRows([]);
|
|
93
|
+
setTotal(0);
|
|
94
|
+
setError(r.error || 'Could not load the access listing.');
|
|
95
|
+
}
|
|
96
|
+
setLoading(false);
|
|
97
|
+
}, [identity, token, query]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
load();
|
|
101
|
+
}, [load]);
|
|
102
|
+
|
|
103
|
+
const onSearchChange = useCallback((search) => setQuery((q) => ({ ...q, search, page: 1 })), []);
|
|
104
|
+
const onSortChange = useCallback(
|
|
105
|
+
(sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
|
|
106
|
+
[],
|
|
107
|
+
);
|
|
108
|
+
const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
|
|
109
|
+
const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
|
|
110
|
+
|
|
111
|
+
// Row menu actions.
|
|
112
|
+
const onEdit = useCallback((row) => navigate(`/access/edit/${row.roleUuid}`), [navigate]);
|
|
113
|
+
const onDuplicate = useCallback(
|
|
114
|
+
(row) => navigate(`/access/add?from=${row.roleUuid}`),
|
|
115
|
+
[navigate],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const onConfirmRevoke = async () => {
|
|
119
|
+
if (!revokeTarget) return;
|
|
120
|
+
setRevokeBusy(true);
|
|
121
|
+
const g = await identity.listRolePermissions(token, revokeTarget.roleUuid);
|
|
122
|
+
let failed = 0;
|
|
123
|
+
if (g.ok) {
|
|
124
|
+
for (const key of g.grants) {
|
|
125
|
+
const r = await identity.revokePermission(token, revokeTarget.roleUuid, key);
|
|
126
|
+
if (!r.ok) failed += 1;
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
failed += 1;
|
|
130
|
+
}
|
|
131
|
+
setRevokeBusy(false);
|
|
132
|
+
const name = revokeTarget.roleName;
|
|
133
|
+
setRevokeTarget(null);
|
|
134
|
+
if (failed === 0) toast.success(`All access revoked from ${name}`);
|
|
135
|
+
else toast.error(`Could not fully revoke access from ${name}`);
|
|
136
|
+
await load();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const columns = [
|
|
140
|
+
{
|
|
141
|
+
key: 'role',
|
|
142
|
+
label: 'Role',
|
|
143
|
+
sortable: true,
|
|
144
|
+
sortKey: 'name',
|
|
145
|
+
render: (row) => (
|
|
146
|
+
<div className="flex items-center gap-3">
|
|
147
|
+
<Avatar className="size-8 rounded-lg">
|
|
148
|
+
<AvatarFallback className="rounded-lg bg-primary/10 text-primary">
|
|
149
|
+
{initialOf(row.roleName)}
|
|
150
|
+
</AvatarFallback>
|
|
151
|
+
</Avatar>
|
|
152
|
+
<div className="min-w-0">
|
|
153
|
+
<div className="truncate text-sm font-medium text-foreground">{row.roleName}</div>
|
|
154
|
+
<div className="text-xs text-muted-foreground">
|
|
155
|
+
{row.permCount} permission{row.permCount === 1 ? '' : 's'} · {row.objectCount} object
|
|
156
|
+
{row.objectCount === 1 ? '' : 's'}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
key: 'access',
|
|
164
|
+
label: 'Access',
|
|
165
|
+
render: (row) => {
|
|
166
|
+
const extra = (row.chips?.length || 0) - MAX_CHIPS;
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex flex-wrap gap-1">
|
|
169
|
+
{(row.chips || []).slice(0, MAX_CHIPS).map((c) => (
|
|
170
|
+
<Badge key={c} variant="secondary" className="font-normal">
|
|
171
|
+
{c}
|
|
172
|
+
</Badge>
|
|
173
|
+
))}
|
|
174
|
+
{extra > 0 && <span className="text-xs text-muted-foreground">+{extra}</span>}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
key: 'preset',
|
|
181
|
+
label: 'Preset',
|
|
182
|
+
render: (row) =>
|
|
183
|
+
row.preset ? (
|
|
184
|
+
<Badge variant="info" className="font-normal">
|
|
185
|
+
{row.preset}
|
|
186
|
+
</Badge>
|
|
187
|
+
) : (
|
|
188
|
+
<span className="text-xs text-muted-foreground">Custom</span>
|
|
189
|
+
),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: 'modified',
|
|
193
|
+
label: 'Last modified',
|
|
194
|
+
sortable: true,
|
|
195
|
+
sortKey: 'grantedAt',
|
|
196
|
+
render: (row) => {
|
|
197
|
+
const d = shortDate(row.grantedAt);
|
|
198
|
+
if (!d) return <span className="text-xs text-muted-foreground">—</span>;
|
|
199
|
+
return (
|
|
200
|
+
<div className="text-sm text-foreground">
|
|
201
|
+
{d}
|
|
202
|
+
{row.grantedByName && (
|
|
203
|
+
<div className="text-xs text-muted-foreground">by {row.grantedByName}</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
// Actions as a normal column (not EntityList's built-in Edit/Delete slot), so we can
|
|
211
|
+
// render the ⋯ menu (Edit / Duplicate / Revoke all). onEdit/onDelete stay UNSET.
|
|
212
|
+
key: 'actions',
|
|
213
|
+
label: '',
|
|
214
|
+
render: (row) => (
|
|
215
|
+
<div className="flex justify-end">
|
|
216
|
+
<RoleActionsMenu
|
|
217
|
+
role={row}
|
|
218
|
+
onEdit={onEdit}
|
|
219
|
+
onDuplicate={onDuplicate}
|
|
220
|
+
onRevokeAll={setRevokeTarget}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
),
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<AppShell section="Identity" title="Access">
|
|
229
|
+
<div className="p-4">
|
|
230
|
+
<PageHeader
|
|
231
|
+
section="Identity"
|
|
232
|
+
title="Roles & permissions"
|
|
233
|
+
icon={KeyRound}
|
|
234
|
+
actions={
|
|
235
|
+
<EntityListControls
|
|
236
|
+
search={query.search}
|
|
237
|
+
onSearchChange={onSearchChange}
|
|
238
|
+
searchPlaceholder="Search roles…"
|
|
239
|
+
addLabel="Assign access"
|
|
240
|
+
onAdd={() => navigate('/access/add')}
|
|
241
|
+
/>
|
|
242
|
+
}
|
|
243
|
+
/>
|
|
244
|
+
|
|
245
|
+
<EntityList
|
|
246
|
+
columns={columns}
|
|
247
|
+
rows={rows}
|
|
248
|
+
search={query.search}
|
|
249
|
+
sortBy={query.sortBy}
|
|
250
|
+
sortOrder={query.sortOrder}
|
|
251
|
+
loading={loading}
|
|
252
|
+
error={error}
|
|
253
|
+
emptyLabel="No roles have access yet. Assign access to get started."
|
|
254
|
+
onSortChange={onSortChange}
|
|
255
|
+
/>
|
|
256
|
+
|
|
257
|
+
<EntityListPager
|
|
258
|
+
total={total}
|
|
259
|
+
page={query.page}
|
|
260
|
+
limit={query.limit}
|
|
261
|
+
loading={loading}
|
|
262
|
+
error={error}
|
|
263
|
+
onPageChange={onPageChange}
|
|
264
|
+
onLimitChange={onLimitChange}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<Outlet context={{ onRefresh: load }} />
|
|
269
|
+
|
|
270
|
+
<ConfirmDialog
|
|
271
|
+
open={!!revokeTarget}
|
|
272
|
+
title={revokeTarget ? `Revoke all access from “${revokeTarget.roleName}”?` : 'Revoke all access?'}
|
|
273
|
+
message="This removes every permission granted to the role. It will no longer appear in this list. This cannot be undone."
|
|
274
|
+
confirmLabel="Revoke all"
|
|
275
|
+
destructive
|
|
276
|
+
busy={revokeBusy}
|
|
277
|
+
onConfirm={onConfirmRevoke}
|
|
278
|
+
onCancel={() => setRevokeTarget(null)}
|
|
279
|
+
/>
|
|
280
|
+
</AppShell>
|
|
281
|
+
);
|
|
282
|
+
}
|