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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboxPage — my tenant's pending cross-tenant grant OFFERS: grants another tenant's
|
|
3
|
+
* admin has offered TO my tenant, not yet accepted/revoked, unexpired. Each row has
|
|
4
|
+
* Accept (`crossTenant.accept`) and Decline (`crossTenant.revoke` — the grantee side
|
|
5
|
+
* revoking an `offered` grant IS the decline action, per the locked design; no separate
|
|
6
|
+
* endpoint/status exists).
|
|
7
|
+
*
|
|
8
|
+
* Mirrors GrantsPage's list shape (custom-render columns + a themed ConfirmDialog for
|
|
9
|
+
* the destructive action — Decline is framed as a confirm, matching the "destructive
|
|
10
|
+
* operations MUST confirm first" rule, since declining an offer is a one-way action from
|
|
11
|
+
* the grantee's perspective).
|
|
12
|
+
*
|
|
13
|
+
* ⛔ No raw UUIDs: offering company name, type label, action chips, dates only;
|
|
14
|
+
* `row.uuid` is a key/API-arg only.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
17
|
+
import { Inbox } from 'lucide-react';
|
|
18
|
+
import { toast } from 'sonner';
|
|
19
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
20
|
+
import AppShell from '@/shell/AppShell';
|
|
21
|
+
import PageHeader from '@/components/PageHeader';
|
|
22
|
+
import EntityList from '@/components/EntityList';
|
|
23
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
24
|
+
import { Badge } from '@/components/ui/badge';
|
|
25
|
+
import { Button } from '@/components/ui/button';
|
|
26
|
+
|
|
27
|
+
/** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
|
|
28
|
+
function formatDate(ms) {
|
|
29
|
+
if (!ms && ms !== 0) return '—';
|
|
30
|
+
const d = new Date(ms);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
32
|
+
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ActionChips({ actions }) {
|
|
36
|
+
if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex flex-wrap gap-1">
|
|
39
|
+
{actions.map((a) => (
|
|
40
|
+
<Badge key={a} variant="secondary" className="font-normal">
|
|
41
|
+
{a}
|
|
42
|
+
</Badge>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function InboxPage() {
|
|
49
|
+
const { tokens, crossTenant } = useAuth();
|
|
50
|
+
const token = tokens?.accessToken;
|
|
51
|
+
|
|
52
|
+
const [rows, setRows] = useState([]);
|
|
53
|
+
const [loading, setLoading] = useState(true);
|
|
54
|
+
const [error, setError] = useState('');
|
|
55
|
+
|
|
56
|
+
const [busyUuid, setBusyUuid] = useState(null);
|
|
57
|
+
const [declineTarget, setDeclineTarget] = useState(null);
|
|
58
|
+
const [declineBusy, setDeclineBusy] = useState(false);
|
|
59
|
+
|
|
60
|
+
// Abort a superseded list request — mirrors UsersPage/AuditPage.
|
|
61
|
+
const loadAbortRef = useRef(/** @type {AbortController | null} */ (null));
|
|
62
|
+
|
|
63
|
+
const load = useCallback(async () => {
|
|
64
|
+
if (!token) return;
|
|
65
|
+
loadAbortRef.current?.abort();
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
loadAbortRef.current = controller;
|
|
68
|
+
|
|
69
|
+
setLoading(true);
|
|
70
|
+
const r = await crossTenant.listInbox(token, { signal: controller.signal });
|
|
71
|
+
|
|
72
|
+
if (controller.signal.aborted || loadAbortRef.current !== controller) return;
|
|
73
|
+
|
|
74
|
+
if (r.ok) {
|
|
75
|
+
setRows(r.rows || []);
|
|
76
|
+
setError('');
|
|
77
|
+
} else {
|
|
78
|
+
setRows([]);
|
|
79
|
+
setError(r.error || 'Could not load your inbox.');
|
|
80
|
+
}
|
|
81
|
+
setLoading(false);
|
|
82
|
+
}, [crossTenant, token]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
load();
|
|
86
|
+
return () => loadAbortRef.current?.abort();
|
|
87
|
+
}, [load]);
|
|
88
|
+
|
|
89
|
+
const onAccept = async (row) => {
|
|
90
|
+
setBusyUuid(row.uuid);
|
|
91
|
+
const r = await crossTenant.accept(token, row.uuid);
|
|
92
|
+
setBusyUuid(null);
|
|
93
|
+
if (!r.ok) {
|
|
94
|
+
toast.error(r.error || 'Could not accept the offer.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
toast.success(`Accepted the offer from ${row.owningTenantName}`);
|
|
98
|
+
await load();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const onConfirmDecline = async () => {
|
|
102
|
+
if (!declineTarget) return;
|
|
103
|
+
setDeclineBusy(true);
|
|
104
|
+
const r = await crossTenant.revoke(token, declineTarget.uuid);
|
|
105
|
+
setDeclineBusy(false);
|
|
106
|
+
if (!r.ok) {
|
|
107
|
+
toast.error(r.error || 'Could not decline the offer.');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setDeclineTarget(null);
|
|
111
|
+
toast.success('Offer declined');
|
|
112
|
+
await load();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const columns = [
|
|
116
|
+
{
|
|
117
|
+
key: 'owningTenantName',
|
|
118
|
+
label: 'Offering company',
|
|
119
|
+
render: (row) => <span className="font-medium text-foreground">{row.owningTenantName}</span>,
|
|
120
|
+
},
|
|
121
|
+
{ key: 'typeLabel', label: 'Object type' },
|
|
122
|
+
{ key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
|
|
123
|
+
{ key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
|
|
124
|
+
{ key: 'offeredByName', label: 'Offered by' },
|
|
125
|
+
{
|
|
126
|
+
key: 'rowActions',
|
|
127
|
+
label: '',
|
|
128
|
+
render: (row) => (
|
|
129
|
+
<div className="flex justify-end gap-1">
|
|
130
|
+
<Button
|
|
131
|
+
type="button"
|
|
132
|
+
size="sm"
|
|
133
|
+
disabled={busyUuid === row.uuid}
|
|
134
|
+
onClick={() => onAccept(row)}
|
|
135
|
+
>
|
|
136
|
+
Accept
|
|
137
|
+
</Button>
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="sm"
|
|
142
|
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
143
|
+
disabled={busyUuid === row.uuid}
|
|
144
|
+
onClick={() => setDeclineTarget(row)}
|
|
145
|
+
>
|
|
146
|
+
Decline
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<AppShell section="Identity" title="Grant inbox">
|
|
155
|
+
<div className="p-4">
|
|
156
|
+
<PageHeader section="Identity" title="Grant inbox" icon={Inbox} />
|
|
157
|
+
|
|
158
|
+
<EntityList
|
|
159
|
+
columns={columns}
|
|
160
|
+
rows={rows}
|
|
161
|
+
loading={loading}
|
|
162
|
+
error={error}
|
|
163
|
+
emptyLabel="No pending cross-tenant grant offers."
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<ConfirmDialog
|
|
168
|
+
open={!!declineTarget}
|
|
169
|
+
title={declineTarget ? `Decline the offer from “${declineTarget.owningTenantName}”?` : 'Decline this offer?'}
|
|
170
|
+
message="This revokes the offer. The offering tenant would need to offer again for you to reconsider. This cannot be undone."
|
|
171
|
+
confirmLabel="Decline"
|
|
172
|
+
destructive
|
|
173
|
+
busy={declineBusy}
|
|
174
|
+
onConfirm={onConfirmDecline}
|
|
175
|
+
onCancel={() => setDeclineTarget(null)}
|
|
176
|
+
/>
|
|
177
|
+
</AppShell>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoleForm — the Add/Edit role form (a single Name field), rendered as a routed-modal
|
|
3
|
+
* overlay by /roles/add and /roles/edit/:uuid under RolesPage's layout. The SAME
|
|
4
|
+
* component serves add + edit via `useParams()` (a `:uuid` → edit, load first; else add).
|
|
5
|
+
* Closes via `navigate(-1)`; on success toasts + calls the layout's `onRefresh`.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ The `:uuid` is a route param only — never rendered as visible text.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'react';
|
|
10
|
+
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
|
11
|
+
import { toast } from 'sonner';
|
|
12
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
13
|
+
import EntityFormModal from '@/components/EntityFormModal';
|
|
14
|
+
import { Input } from '@/components/ui/input';
|
|
15
|
+
import { Label } from '@/components/ui/label';
|
|
16
|
+
|
|
17
|
+
export default function RoleForm() {
|
|
18
|
+
const { tokens, identity } = useAuth();
|
|
19
|
+
const token = tokens?.accessToken;
|
|
20
|
+
const { uuid } = useParams();
|
|
21
|
+
const isEdit = Boolean(uuid);
|
|
22
|
+
const navigate = useNavigate();
|
|
23
|
+
const { onRefresh } = useOutletContext() ?? {};
|
|
24
|
+
|
|
25
|
+
const [name, setName] = useState('');
|
|
26
|
+
const [loading, setLoading] = useState(isEdit);
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
const [error, setError] = useState('');
|
|
29
|
+
|
|
30
|
+
// On edit, load the current record so the form pre-fills with its display fields.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!isEdit || !token) return undefined;
|
|
33
|
+
let active = true;
|
|
34
|
+
(async () => {
|
|
35
|
+
const r = await identity.getRole(token, uuid);
|
|
36
|
+
if (!active) return;
|
|
37
|
+
if (r.ok) {
|
|
38
|
+
setName(r.record?.fields?.name || '');
|
|
39
|
+
} else {
|
|
40
|
+
setError(r.error || 'Could not load the role.');
|
|
41
|
+
}
|
|
42
|
+
setLoading(false);
|
|
43
|
+
})();
|
|
44
|
+
return () => {
|
|
45
|
+
active = false;
|
|
46
|
+
};
|
|
47
|
+
}, [identity, token, uuid, isEdit]);
|
|
48
|
+
|
|
49
|
+
const close = () => navigate(-1);
|
|
50
|
+
|
|
51
|
+
const onSubmit = async () => {
|
|
52
|
+
const trimmed = name.trim();
|
|
53
|
+
if (!trimmed) {
|
|
54
|
+
setError('Enter a role name.');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setError('');
|
|
58
|
+
setBusy(true);
|
|
59
|
+
const r = isEdit
|
|
60
|
+
? await identity.updateRole(token, uuid, { name: trimmed })
|
|
61
|
+
: await identity.createRole(token, trimmed);
|
|
62
|
+
setBusy(false);
|
|
63
|
+
if (!r.ok) {
|
|
64
|
+
setError(r.error || 'Could not save the role.');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
toast.success(isEdit ? 'Role updated' : 'Role created');
|
|
68
|
+
onRefresh?.();
|
|
69
|
+
close();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<EntityFormModal
|
|
74
|
+
title={isEdit ? 'Edit role' : 'Add role'}
|
|
75
|
+
description={isEdit ? undefined : 'Create a named grant.'}
|
|
76
|
+
open
|
|
77
|
+
onClose={close}
|
|
78
|
+
onSubmit={onSubmit}
|
|
79
|
+
submitLabel={isEdit ? 'Save changes' : 'Create role'}
|
|
80
|
+
busy={busy || loading}
|
|
81
|
+
error={error}
|
|
82
|
+
>
|
|
83
|
+
<div className="space-y-1.5">
|
|
84
|
+
<Label htmlFor="role-name" variant="field">
|
|
85
|
+
Name
|
|
86
|
+
</Label>
|
|
87
|
+
<Input
|
|
88
|
+
id="role-name"
|
|
89
|
+
autoFocus
|
|
90
|
+
maxLength={120}
|
|
91
|
+
placeholder="e.g. Admin, Approver, Viewer"
|
|
92
|
+
value={name}
|
|
93
|
+
disabled={loading}
|
|
94
|
+
onChange={(e) => setName(e.target.value)}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
</EntityFormModal>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ No raw UUIDs: the single column renders `fields.name`; `row.id` is a key/route param only.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
11
|
+
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
|
|
12
|
+
import { ShieldCheck } from 'lucide-react';
|
|
13
|
+
import { toast } from 'sonner';
|
|
14
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
15
|
+
import AppShell from '@/shell/AppShell';
|
|
16
|
+
import PageHeader from '@/components/PageHeader';
|
|
17
|
+
import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
|
|
18
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
19
|
+
|
|
20
|
+
/** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
|
|
21
|
+
function formatDate(v) {
|
|
22
|
+
if (!v) return '—';
|
|
23
|
+
try {
|
|
24
|
+
return new Date(v).toLocaleDateString(undefined, {
|
|
25
|
+
day: 'numeric',
|
|
26
|
+
month: 'short',
|
|
27
|
+
year: 'numeric',
|
|
28
|
+
});
|
|
29
|
+
} catch {
|
|
30
|
+
return '—';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Sortable columns (server-side): Name, Created.
|
|
35
|
+
const COLUMNS = [
|
|
36
|
+
{
|
|
37
|
+
key: 'name',
|
|
38
|
+
label: 'Name',
|
|
39
|
+
sortable: true,
|
|
40
|
+
sortKey: 'name',
|
|
41
|
+
render: (row) => row.fields?.name || '(unnamed)',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: 'createdAt',
|
|
45
|
+
label: 'Created',
|
|
46
|
+
sortable: true,
|
|
47
|
+
sortKey: 'createdAt',
|
|
48
|
+
render: (row) => formatDate(row.fields?.createdAt),
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const DEFAULT_LIMIT = 25;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Seed the initial DataTable query from the URL so a reload / shared link restores
|
|
56
|
+
* EXACTLY the view (page + limit + search + sortBy + sortOrder). Absent params fall
|
|
57
|
+
* back to the defaults; values are clamped/guarded so a hand-edited URL can't poison state.
|
|
58
|
+
*/
|
|
59
|
+
function readQueryFromParams(sp) {
|
|
60
|
+
const page = Number.parseInt(sp.get('page') ?? '', 10);
|
|
61
|
+
const limit = Number.parseInt(sp.get('limit') ?? '', 10);
|
|
62
|
+
const sortOrder = (sp.get('sortOrder') || '').toLowerCase();
|
|
63
|
+
return {
|
|
64
|
+
page: Number.isFinite(page) && page >= 1 ? page : 1,
|
|
65
|
+
limit: Number.isFinite(limit) && limit >= 1 && limit <= 200 ? limit : DEFAULT_LIMIT,
|
|
66
|
+
search: sp.get('search') || '',
|
|
67
|
+
sortBy: sp.get('sortBy') || 'createdAt',
|
|
68
|
+
sortOrder: sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'desc',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default function RolesPage() {
|
|
73
|
+
const { tokens, identity } = useAuth();
|
|
74
|
+
const token = tokens?.accessToken;
|
|
75
|
+
const navigate = useNavigate();
|
|
76
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
77
|
+
|
|
78
|
+
const [rows, setRows] = useState([]);
|
|
79
|
+
const [total, setTotal] = useState(0);
|
|
80
|
+
const [loading, setLoading] = useState(true);
|
|
81
|
+
const [error, setError] = useState('');
|
|
82
|
+
|
|
83
|
+
// Seed ONCE from the URL (initializer runs only on mount) → single source of truth.
|
|
84
|
+
const [query, setQuery] = useState(() => readQueryFromParams(searchParams));
|
|
85
|
+
|
|
86
|
+
// Sync query → URL on every change (replace:true — no history spam). Only non-default
|
|
87
|
+
// page/limit/search are emitted to keep the URL clean; sortBy/sortOrder are always
|
|
88
|
+
// meaningful so they're always emitted. Reads only `query` — never the URL — so no loop.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const p = new URLSearchParams();
|
|
91
|
+
if (query.page > 1) p.set('page', String(query.page));
|
|
92
|
+
if (query.limit !== DEFAULT_LIMIT) p.set('limit', String(query.limit));
|
|
93
|
+
if (query.search) p.set('search', query.search);
|
|
94
|
+
if (query.sortBy) p.set('sortBy', query.sortBy);
|
|
95
|
+
if (query.sortOrder) p.set('sortOrder', query.sortOrder);
|
|
96
|
+
setSearchParams(p, { replace: true });
|
|
97
|
+
}, [query, setSearchParams]);
|
|
98
|
+
|
|
99
|
+
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
100
|
+
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
101
|
+
|
|
102
|
+
const load = useCallback(async () => {
|
|
103
|
+
if (!token) return;
|
|
104
|
+
setLoading(true);
|
|
105
|
+
const r = await identity.listRoles(token, query);
|
|
106
|
+
if (r.ok) {
|
|
107
|
+
setRows(r.rows || []);
|
|
108
|
+
setTotal(r.total || 0);
|
|
109
|
+
setError('');
|
|
110
|
+
} else {
|
|
111
|
+
setRows([]);
|
|
112
|
+
setTotal(0);
|
|
113
|
+
setError(r.error || 'Could not load roles.');
|
|
114
|
+
}
|
|
115
|
+
setLoading(false);
|
|
116
|
+
}, [identity, token, query]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
load();
|
|
120
|
+
}, [load]);
|
|
121
|
+
|
|
122
|
+
const onSearchChange = useCallback(
|
|
123
|
+
(search) => setQuery((q) => ({ ...q, search, page: 1 })),
|
|
124
|
+
[],
|
|
125
|
+
);
|
|
126
|
+
const onSortChange = useCallback(
|
|
127
|
+
(sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
|
|
128
|
+
[],
|
|
129
|
+
);
|
|
130
|
+
const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
|
|
131
|
+
const onLimitChange = useCallback(
|
|
132
|
+
(limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
|
|
133
|
+
[],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const onConfirmDelete = async () => {
|
|
137
|
+
if (!deleteTarget) return;
|
|
138
|
+
setDeleteBusy(true);
|
|
139
|
+
const r = await identity.deleteRole(token, deleteTarget.id);
|
|
140
|
+
setDeleteBusy(false);
|
|
141
|
+
if (!r.ok) {
|
|
142
|
+
toast.error(r.error || 'Could not delete the role.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
setDeleteTarget(null);
|
|
146
|
+
toast.success('Role deleted');
|
|
147
|
+
await load();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<AppShell section="Identity" title="Roles">
|
|
152
|
+
<div className="p-4">
|
|
153
|
+
<PageHeader
|
|
154
|
+
section="Identity"
|
|
155
|
+
title="Roles"
|
|
156
|
+
icon={ShieldCheck}
|
|
157
|
+
actions={
|
|
158
|
+
<EntityListControls
|
|
159
|
+
search={query.search}
|
|
160
|
+
onSearchChange={onSearchChange}
|
|
161
|
+
searchPlaceholder="Search roles…"
|
|
162
|
+
addLabel="Add role"
|
|
163
|
+
onAdd={() => navigate('/roles/add')}
|
|
164
|
+
/>
|
|
165
|
+
}
|
|
166
|
+
/>
|
|
167
|
+
|
|
168
|
+
<EntityList
|
|
169
|
+
columns={COLUMNS}
|
|
170
|
+
rows={rows}
|
|
171
|
+
search={query.search}
|
|
172
|
+
sortBy={query.sortBy}
|
|
173
|
+
sortOrder={query.sortOrder}
|
|
174
|
+
loading={loading}
|
|
175
|
+
error={error}
|
|
176
|
+
emptyLabel="No roles yet. Add one to get started."
|
|
177
|
+
onEdit={(row) => navigate(`/roles/edit/${row.id}`)}
|
|
178
|
+
onDelete={(row) => setDeleteTarget(row)}
|
|
179
|
+
onSortChange={onSortChange}
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
<EntityListPager
|
|
183
|
+
total={total}
|
|
184
|
+
page={query.page}
|
|
185
|
+
limit={query.limit}
|
|
186
|
+
loading={loading}
|
|
187
|
+
error={error}
|
|
188
|
+
onPageChange={onPageChange}
|
|
189
|
+
onLimitChange={onLimitChange}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<Outlet context={{ onRefresh: load }} />
|
|
194
|
+
|
|
195
|
+
<ConfirmDialog
|
|
196
|
+
open={!!deleteTarget}
|
|
197
|
+
title={deleteTarget ? `Delete “${deleteTarget.fields?.name || 'this role'}”?` : 'Delete role?'}
|
|
198
|
+
message="This permanently removes the role. Users currently assigned it will lose the grant. This cannot be undone."
|
|
199
|
+
confirmLabel="Delete"
|
|
200
|
+
destructive
|
|
201
|
+
busy={deleteBusy}
|
|
202
|
+
onConfirm={onConfirmDelete}
|
|
203
|
+
onCancel={() => setDeleteTarget(null)}
|
|
204
|
+
/>
|
|
205
|
+
</AppShell>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssignmentsEditor — the centerpiece of the user edit page: the user's company
|
|
3
|
+
* memberships and their role/department/branch postings. One MembershipCard per company
|
|
4
|
+
* the user belongs to; each card owns its multi-row posting editor + active toggle +
|
|
5
|
+
* remove. A "+ add to another company" control at the bottom adds the user to a company
|
|
6
|
+
* they aren't already in (a fresh, inactive membership card).
|
|
7
|
+
*
|
|
8
|
+
* Data: loads `getUserMemberships` on mount (cards + postings, NAMES resolved server-side).
|
|
9
|
+
* The "add to another company" picker is a growable SearchableSelect (server-paginated +
|
|
10
|
+
* searchable via `listCompanies`), filtered client-side to hide companies the user is already
|
|
11
|
+
* in. Each card lazily loads its own company's department/branch option lists; its Role
|
|
12
|
+
* dropdown self-fetches the (growable) role catalog.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ No raw UUIDs anywhere: company/role/department/branch all render as resolved NAMES;
|
|
15
|
+
* uuids live only in dropdown values + as keys/params.
|
|
16
|
+
*
|
|
17
|
+
* Props:
|
|
18
|
+
* userUuid — the user being edited (memberships hang off the user, not the tenant).
|
|
19
|
+
*/
|
|
20
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
21
|
+
import { Loader2, Plus, Users } from 'lucide-react';
|
|
22
|
+
import { toast } from 'sonner';
|
|
23
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
24
|
+
import { Button } from '@/components/ui/button';
|
|
25
|
+
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
26
|
+
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
27
|
+
import MembershipCard from './MembershipCard';
|
|
28
|
+
|
|
29
|
+
export default function AssignmentsEditor({ userUuid }) {
|
|
30
|
+
const { tokens, identity } = useAuth();
|
|
31
|
+
const token = tokens?.accessToken;
|
|
32
|
+
|
|
33
|
+
const [memberships, setMemberships] = useState([]);
|
|
34
|
+
const [loading, setLoading] = useState(true);
|
|
35
|
+
const [error, setError] = useState('');
|
|
36
|
+
|
|
37
|
+
// "Add to another company" picker state — the chosen company's uuid + its display NAME
|
|
38
|
+
// (the SearchableSelect trigger shows the name; the uuid is never rendered).
|
|
39
|
+
const [pickCompany, setPickCompany] = useState(null);
|
|
40
|
+
const [pickCompanyName, setPickCompanyName] = useState('');
|
|
41
|
+
const [adding, setAdding] = useState(false);
|
|
42
|
+
|
|
43
|
+
const loadMemberships = useCallback(async () => {
|
|
44
|
+
if (!token || !userUuid) return;
|
|
45
|
+
const r = await identity.getUserMemberships(token, userUuid);
|
|
46
|
+
if (r.ok) {
|
|
47
|
+
setMemberships(r.memberships || []);
|
|
48
|
+
setError('');
|
|
49
|
+
} else {
|
|
50
|
+
setMemberships([]);
|
|
51
|
+
setError(r.error || 'Could not load the user’s assignments.');
|
|
52
|
+
}
|
|
53
|
+
}, [identity, token, userUuid]);
|
|
54
|
+
|
|
55
|
+
// Initial load: just the memberships. Roles + companies are now fetched on demand by the
|
|
56
|
+
// growable dropdowns (SearchableSelect), so nothing else is preloaded here.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!token || !userUuid) return undefined;
|
|
59
|
+
let active = true;
|
|
60
|
+
(async () => {
|
|
61
|
+
setLoading(true);
|
|
62
|
+
const mr = await identity.getUserMemberships(token, userUuid);
|
|
63
|
+
if (!active) return;
|
|
64
|
+
if (mr.ok) {
|
|
65
|
+
setMemberships(mr.memberships || []);
|
|
66
|
+
setError('');
|
|
67
|
+
} else {
|
|
68
|
+
setError(mr.error || 'Could not load the user’s assignments.');
|
|
69
|
+
}
|
|
70
|
+
setLoading(false);
|
|
71
|
+
})();
|
|
72
|
+
return () => {
|
|
73
|
+
active = false;
|
|
74
|
+
};
|
|
75
|
+
}, [identity, token, userUuid]);
|
|
76
|
+
|
|
77
|
+
// Companies the user is already in — excluded client-side from the picker (the server
|
|
78
|
+
// list endpoint has no exclusion filter, so we drop taken rows after fetching a page).
|
|
79
|
+
const taken = new Set(memberships.map((m) => m.companyUuid));
|
|
80
|
+
|
|
81
|
+
const fetchCompanies = useCallback(
|
|
82
|
+
async ({ search, page, limit }) => {
|
|
83
|
+
const r = await identity.listCompanies(token, { search, page, limit });
|
|
84
|
+
if (!r.ok) return { options: [], total: 0, page };
|
|
85
|
+
const options = (r.rows || [])
|
|
86
|
+
.filter((row) => !taken.has(row.id))
|
|
87
|
+
.map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' }));
|
|
88
|
+
return { options, total: r.total ?? options.length, page: r.page ?? page };
|
|
89
|
+
},
|
|
90
|
+
// `taken` derives from `memberships`; recompute the fetcher when they change.
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
[identity, token, memberships],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const onAddCompany = async () => {
|
|
96
|
+
if (!pickCompany) return;
|
|
97
|
+
setAdding(true);
|
|
98
|
+
const r = await identity.addMembership(token, userUuid, pickCompany);
|
|
99
|
+
setAdding(false);
|
|
100
|
+
if (!r.ok) {
|
|
101
|
+
toast.error(r.error || 'Could not add the membership.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
setPickCompany(null);
|
|
105
|
+
setPickCompanyName('');
|
|
106
|
+
toast.success('Added to company');
|
|
107
|
+
await loadMemberships();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="min-w-0 space-y-3">
|
|
112
|
+
<div className="flex items-center gap-2">
|
|
113
|
+
<Users className="size-4 text-primary" />
|
|
114
|
+
<div>
|
|
115
|
+
<p className="text-sm font-medium text-foreground">Assignments</p>
|
|
116
|
+
<p className="text-xs text-muted-foreground">
|
|
117
|
+
Company memberships and their role / department / branch postings.
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{error && (
|
|
123
|
+
<Alert variant="destructive">
|
|
124
|
+
<AlertDescription>{error}</AlertDescription>
|
|
125
|
+
</Alert>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{loading ? (
|
|
129
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
130
|
+
<Loader2 className="size-4 animate-spin" />
|
|
131
|
+
Loading assignments…
|
|
132
|
+
</div>
|
|
133
|
+
) : (
|
|
134
|
+
<>
|
|
135
|
+
{memberships.length === 0 ? (
|
|
136
|
+
<div className="rounded-lg border border-dashed bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
|
|
137
|
+
Not a member of any company yet. Add the user to a company below.
|
|
138
|
+
</div>
|
|
139
|
+
) : (
|
|
140
|
+
<div className="min-w-0 space-y-3">
|
|
141
|
+
{memberships.map((m) => (
|
|
142
|
+
<MembershipCard
|
|
143
|
+
key={m.membershipUuid}
|
|
144
|
+
membership={m}
|
|
145
|
+
userUuid={userUuid}
|
|
146
|
+
token={token}
|
|
147
|
+
identity={identity}
|
|
148
|
+
onActivate={loadMemberships}
|
|
149
|
+
onRemoved={loadMemberships}
|
|
150
|
+
onSaved={loadMemberships}
|
|
151
|
+
/>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{/* Add to another company — a growable, searchable picker (companies the user is
|
|
157
|
+
already in are filtered out client-side inside `fetchCompanies`). */}
|
|
158
|
+
<div className="flex flex-col gap-2 pt-1 sm:flex-row sm:items-center">
|
|
159
|
+
<div className="w-full sm:w-64">
|
|
160
|
+
<SearchableSelect
|
|
161
|
+
aria-label="Add to another company"
|
|
162
|
+
value={pickCompany}
|
|
163
|
+
selectedLabel={pickCompanyName}
|
|
164
|
+
onChange={(v) => {
|
|
165
|
+
setPickCompany(v);
|
|
166
|
+
if (!v) setPickCompanyName('');
|
|
167
|
+
}}
|
|
168
|
+
fetchPage={fetchCompanies}
|
|
169
|
+
placeholder="Add to another company…"
|
|
170
|
+
searchPlaceholder="Search companies…"
|
|
171
|
+
disabled={adding}
|
|
172
|
+
allowClear
|
|
173
|
+
// Capture the chosen label so the trigger shows the name (never the uuid).
|
|
174
|
+
onLabelChange={(label) => setPickCompanyName(label || '')}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
<Button
|
|
178
|
+
type="button"
|
|
179
|
+
variant="soft"
|
|
180
|
+
size="sm"
|
|
181
|
+
className="w-full sm:w-auto"
|
|
182
|
+
onClick={onAddCompany}
|
|
183
|
+
disabled={!pickCompany || adding}
|
|
184
|
+
>
|
|
185
|
+
{adding ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
|
186
|
+
Add company
|
|
187
|
+
</Button>
|
|
188
|
+
</div>
|
|
189
|
+
</>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|