create-astram-app 0.1.3 → 0.2.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 +167 -0
- package/template/frontend/src/auth/AuthContext.jsx +338 -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/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 +90 -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
|
@@ -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
|
+
}
|