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.
Files changed (88) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +122 -25
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/frontend/FRONTEND.md +92 -0
  7. package/template/frontend/_env.example +6 -0
  8. package/template/frontend/_gitignore +9 -0
  9. package/template/frontend/eslint.config.js +71 -0
  10. package/template/frontend/index.html +13 -0
  11. package/template/frontend/package.json +52 -0
  12. package/template/frontend/public/astram-logo-full.png +0 -0
  13. package/template/frontend/public/astram-logo-white.png +0 -0
  14. package/template/frontend/public/favicon.png +0 -0
  15. package/template/frontend/src/App.jsx +191 -0
  16. package/template/frontend/src/auth/AuthContext.jsx +343 -0
  17. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  18. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  19. package/template/frontend/src/components/EntityList.jsx +480 -0
  20. package/template/frontend/src/components/PageHeader.jsx +54 -0
  21. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  22. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  23. package/template/frontend/src/components/ui/alert.tsx +83 -0
  24. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  25. package/template/frontend/src/components/ui/badge.tsx +56 -0
  26. package/template/frontend/src/components/ui/button.tsx +67 -0
  27. package/template/frontend/src/components/ui/card.tsx +92 -0
  28. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  29. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  30. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  31. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  32. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  33. package/template/frontend/src/components/ui/input.tsx +21 -0
  34. package/template/frontend/src/components/ui/label.tsx +46 -0
  35. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  36. package/template/frontend/src/components/ui/select.tsx +199 -0
  37. package/template/frontend/src/components/ui/separator.tsx +26 -0
  38. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  39. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  40. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  41. package/template/frontend/src/components/ui/table.tsx +181 -0
  42. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  43. package/template/frontend/src/config/branding.js +80 -0
  44. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  45. package/template/frontend/src/index.css +265 -0
  46. package/template/frontend/src/lib/utils.js +7 -0
  47. package/template/frontend/src/main.jsx +27 -0
  48. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  49. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  50. package/template/frontend/src/pages/Login.jsx +393 -0
  51. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  52. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  53. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  54. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  55. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  56. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  57. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  58. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  59. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  60. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  61. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  62. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  63. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
  64. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
  65. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
  66. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  67. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  68. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  69. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  70. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  71. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  72. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  73. package/template/frontend/src/platform/httpClient.js +125 -0
  74. package/template/frontend/src/platform/returnPathStore.js +43 -0
  75. package/template/frontend/src/platform/sessionStore.js +29 -0
  76. package/template/frontend/src/platform/shieldTransport.js +172 -0
  77. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  78. package/template/frontend/src/shell/AppShell.jsx +237 -0
  79. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  80. package/template/frontend/src/shell/nav.js +103 -0
  81. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  82. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  83. package/template/frontend/tsconfig.json +22 -0
  84. package/template/frontend/vite.config.js +30 -0
  85. package/template/server/_env.example +27 -15
  86. package/template/server/package.json +7 -4
  87. package/template/server/src/app.module.ts +28 -2
  88. 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
+ }