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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserForm — the Add/Edit user form, rendered as a routed-modal overlay by /users/add
|
|
3
|
+
* and /users/edit/:uuid under UsersPage's layout. The SAME component serves add + edit
|
|
4
|
+
* via `useParams()` (a `:uuid` → edit, load first; else add). Closes via `navigate(-1)`;
|
|
5
|
+
* on success toasts + calls the layout's `onRefresh`.
|
|
6
|
+
*
|
|
7
|
+
* Fields: Username (required) + Email. On edit, Status is also editable.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ The `:uuid` is a route param only — never rendered as visible text.
|
|
10
|
+
*
|
|
11
|
+
* PHASE 5 — the Assignments (membership + posting) editor is rendered on the EDIT path,
|
|
12
|
+
* BELOW these profile fields (a user must exist before it can be assigned into a company).
|
|
13
|
+
* On the ADD path it stays hidden (create the user first, then edit to assign). The edit
|
|
14
|
+
* modal widens to fit the multi-row posting tables.
|
|
15
|
+
*/
|
|
16
|
+
import { useEffect, useState } from 'react';
|
|
17
|
+
import { useNavigate, useOutletContext, useParams } 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 { Label } from '@/components/ui/label';
|
|
23
|
+
import { Separator } from '@/components/ui/separator';
|
|
24
|
+
import {
|
|
25
|
+
Select,
|
|
26
|
+
SelectContent,
|
|
27
|
+
SelectItem,
|
|
28
|
+
SelectTrigger,
|
|
29
|
+
SelectValue,
|
|
30
|
+
} from '@/components/ui/select';
|
|
31
|
+
import AssignmentsEditor from './AssignmentsEditor';
|
|
32
|
+
|
|
33
|
+
export default function UserForm() {
|
|
34
|
+
const { tokens, identity } = useAuth();
|
|
35
|
+
const token = tokens?.accessToken;
|
|
36
|
+
const { uuid } = useParams();
|
|
37
|
+
const isEdit = Boolean(uuid);
|
|
38
|
+
const navigate = useNavigate();
|
|
39
|
+
const { onRefresh } = useOutletContext() ?? {};
|
|
40
|
+
|
|
41
|
+
const [username, setUsername] = useState('');
|
|
42
|
+
const [email, setEmail] = useState('');
|
|
43
|
+
const [status, setStatus] = useState('active');
|
|
44
|
+
const [loading, setLoading] = useState(isEdit);
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
const [error, setError] = useState('');
|
|
47
|
+
|
|
48
|
+
// On edit, load the current record so the form pre-fills with its display fields.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!isEdit || !token) return undefined;
|
|
51
|
+
let active = true;
|
|
52
|
+
(async () => {
|
|
53
|
+
const r = await identity.getUser(token, uuid);
|
|
54
|
+
if (!active) return;
|
|
55
|
+
if (r.ok) {
|
|
56
|
+
setUsername(r.record?.fields?.username || '');
|
|
57
|
+
setEmail(r.record?.fields?.email || '');
|
|
58
|
+
setStatus(r.record?.fields?.status || 'active');
|
|
59
|
+
} else {
|
|
60
|
+
setError(r.error || 'Could not load the user.');
|
|
61
|
+
}
|
|
62
|
+
setLoading(false);
|
|
63
|
+
})();
|
|
64
|
+
return () => {
|
|
65
|
+
active = false;
|
|
66
|
+
};
|
|
67
|
+
}, [identity, token, uuid, isEdit]);
|
|
68
|
+
|
|
69
|
+
const close = () => navigate(-1);
|
|
70
|
+
|
|
71
|
+
const onSubmit = async () => {
|
|
72
|
+
const trimmedUser = username.trim();
|
|
73
|
+
const trimmedEmail = email.trim();
|
|
74
|
+
if (!trimmedUser) {
|
|
75
|
+
setError('Enter a username.');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setError('');
|
|
79
|
+
setBusy(true);
|
|
80
|
+
const r = isEdit
|
|
81
|
+
? await identity.updateUser(token, uuid, {
|
|
82
|
+
username: trimmedUser,
|
|
83
|
+
email: trimmedEmail,
|
|
84
|
+
status,
|
|
85
|
+
})
|
|
86
|
+
: await identity.createUser(token, { username: trimmedUser, email: trimmedEmail });
|
|
87
|
+
setBusy(false);
|
|
88
|
+
if (!r.ok) {
|
|
89
|
+
setError(r.error || 'Could not save the user.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
toast.success(isEdit ? 'User updated' : 'User created');
|
|
93
|
+
onRefresh?.();
|
|
94
|
+
close();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<EntityFormModal
|
|
99
|
+
title={isEdit ? 'Edit user' : 'Add user'}
|
|
100
|
+
description={isEdit ? undefined : 'Create a new user.'}
|
|
101
|
+
open
|
|
102
|
+
onClose={close}
|
|
103
|
+
onSubmit={onSubmit}
|
|
104
|
+
submitLabel={isEdit ? 'Save changes' : 'Create user'}
|
|
105
|
+
busy={busy || loading}
|
|
106
|
+
error={error}
|
|
107
|
+
contentClassName={isEdit ? 'sm:max-w-2xl' : 'sm:max-w-md'}
|
|
108
|
+
>
|
|
109
|
+
<div className="space-y-1.5">
|
|
110
|
+
<Label htmlFor="user-username" variant="field">
|
|
111
|
+
Username
|
|
112
|
+
</Label>
|
|
113
|
+
<Input
|
|
114
|
+
id="user-username"
|
|
115
|
+
autoFocus
|
|
116
|
+
maxLength={120}
|
|
117
|
+
placeholder="e.g. jsmith"
|
|
118
|
+
value={username}
|
|
119
|
+
disabled={loading}
|
|
120
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div className="space-y-1.5">
|
|
125
|
+
<Label htmlFor="user-email" variant="field">
|
|
126
|
+
Email
|
|
127
|
+
</Label>
|
|
128
|
+
<Input
|
|
129
|
+
id="user-email"
|
|
130
|
+
type="email"
|
|
131
|
+
maxLength={200}
|
|
132
|
+
placeholder="e.g. jsmith@acme.com"
|
|
133
|
+
value={email}
|
|
134
|
+
disabled={loading}
|
|
135
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{isEdit && (
|
|
140
|
+
<div className="space-y-1.5">
|
|
141
|
+
<Label htmlFor="user-status" variant="field">
|
|
142
|
+
Status
|
|
143
|
+
</Label>
|
|
144
|
+
<Select value={status} disabled={loading} onValueChange={setStatus}>
|
|
145
|
+
<SelectTrigger id="user-status">
|
|
146
|
+
<SelectValue />
|
|
147
|
+
</SelectTrigger>
|
|
148
|
+
<SelectContent>
|
|
149
|
+
<SelectItem value="active">Active</SelectItem>
|
|
150
|
+
<SelectItem value="inactive">Inactive</SelectItem>
|
|
151
|
+
</SelectContent>
|
|
152
|
+
</Select>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{/* The Assignments (membership + posting) editor — edit path only, since a user must
|
|
157
|
+
exist before it can be assigned into a company. It manages its own data + saves
|
|
158
|
+
(per-membership atomic replacePostings), independent of this form's submit. */}
|
|
159
|
+
{isEdit && !loading && (
|
|
160
|
+
<>
|
|
161
|
+
<Separator className="my-1" />
|
|
162
|
+
<AssignmentsEditor userUuid={uuid} />
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</EntityFormModal>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UsersPage — the Users management screen: a list of users with routed-modal Add/Edit
|
|
3
|
+
* and a themed delete confirm. Same layout-route shape as CompaniesPage/RolesPage
|
|
4
|
+
* (list + `<Outlet context={{ onRefresh }} />` for /users/add and /users/edit/:uuid).
|
|
5
|
+
* Loads on mount via the shared identity client service; a 403 (users are root-only
|
|
6
|
+
* server-side for now) degrades to a graceful "need platform-admin access" message.
|
|
7
|
+
*
|
|
8
|
+
* The user EDIT page (/users/edit/:uuid) is where the Assignments (membership + posting)
|
|
9
|
+
* editor will live in Phase 5 — UserForm leaves a clear placeholder for it.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ No raw UUIDs: columns render username/email/status; `row.id` is a key/route param only.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
14
|
+
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
|
|
15
|
+
import { Users } 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
|
+
|
|
24
|
+
/** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
|
|
25
|
+
function formatDate(v) {
|
|
26
|
+
if (!v) return '—';
|
|
27
|
+
try {
|
|
28
|
+
return new Date(v).toLocaleDateString(undefined, {
|
|
29
|
+
day: 'numeric',
|
|
30
|
+
month: 'short',
|
|
31
|
+
year: 'numeric',
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
return '—';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Sortable columns (server-side): Username, Email, Status, Created.
|
|
39
|
+
const COLUMNS = [
|
|
40
|
+
{
|
|
41
|
+
key: 'username',
|
|
42
|
+
label: 'Username',
|
|
43
|
+
sortable: true,
|
|
44
|
+
sortKey: 'username',
|
|
45
|
+
render: (row) => row.fields?.username || '(unnamed)',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: 'email',
|
|
49
|
+
label: 'Email',
|
|
50
|
+
sortable: true,
|
|
51
|
+
sortKey: 'email',
|
|
52
|
+
render: (row) => row.fields?.email || '—',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: 'status',
|
|
56
|
+
label: 'Status',
|
|
57
|
+
sortable: true,
|
|
58
|
+
sortKey: 'status',
|
|
59
|
+
render: (row) => {
|
|
60
|
+
const status = row.fields?.status || 'active';
|
|
61
|
+
return (
|
|
62
|
+
<Badge variant={status === 'active' ? 'success' : 'secondary'} className="capitalize">
|
|
63
|
+
{status}
|
|
64
|
+
</Badge>
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'createdAt',
|
|
70
|
+
label: 'Created',
|
|
71
|
+
sortable: true,
|
|
72
|
+
sortKey: 'createdAt',
|
|
73
|
+
render: (row) => formatDate(row.fields?.createdAt),
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const DEFAULT_LIMIT = 25;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Seed the initial DataTable query from the URL so a reload / shared link restores
|
|
81
|
+
* EXACTLY the view (page + limit + search + sortBy + sortOrder). Absent params fall
|
|
82
|
+
* back to the defaults; values are clamped/guarded so a hand-edited URL can't poison state.
|
|
83
|
+
*/
|
|
84
|
+
function readQueryFromParams(sp) {
|
|
85
|
+
const page = Number.parseInt(sp.get('page') ?? '', 10);
|
|
86
|
+
const limit = Number.parseInt(sp.get('limit') ?? '', 10);
|
|
87
|
+
const sortOrder = (sp.get('sortOrder') || '').toLowerCase();
|
|
88
|
+
return {
|
|
89
|
+
page: Number.isFinite(page) && page >= 1 ? page : 1,
|
|
90
|
+
limit: Number.isFinite(limit) && limit >= 1 && limit <= 200 ? limit : DEFAULT_LIMIT,
|
|
91
|
+
search: sp.get('search') || '',
|
|
92
|
+
sortBy: sp.get('sortBy') || 'createdAt',
|
|
93
|
+
sortOrder: sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'desc',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default function UsersPage() {
|
|
98
|
+
const { tokens, identity } = useAuth();
|
|
99
|
+
const token = tokens?.accessToken;
|
|
100
|
+
const navigate = useNavigate();
|
|
101
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
102
|
+
|
|
103
|
+
const [rows, setRows] = useState([]);
|
|
104
|
+
const [total, setTotal] = useState(0);
|
|
105
|
+
const [loading, setLoading] = useState(true);
|
|
106
|
+
const [error, setError] = useState('');
|
|
107
|
+
|
|
108
|
+
// Seed ONCE from the URL (initializer runs only on mount) → single source of truth.
|
|
109
|
+
const [query, setQuery] = useState(() => readQueryFromParams(searchParams));
|
|
110
|
+
|
|
111
|
+
// Sync query → URL on every change (replace:true — no history spam). Only non-default
|
|
112
|
+
// page/limit/search are emitted to keep the URL clean; sortBy/sortOrder are always
|
|
113
|
+
// meaningful so they're always emitted. Reads only `query` — never the URL — so no loop.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const p = new URLSearchParams();
|
|
116
|
+
if (query.page > 1) p.set('page', String(query.page));
|
|
117
|
+
if (query.limit !== DEFAULT_LIMIT) p.set('limit', String(query.limit));
|
|
118
|
+
if (query.search) p.set('search', query.search);
|
|
119
|
+
if (query.sortBy) p.set('sortBy', query.sortBy);
|
|
120
|
+
if (query.sortOrder) p.set('sortOrder', query.sortOrder);
|
|
121
|
+
setSearchParams(p, { replace: true });
|
|
122
|
+
}, [query, setSearchParams]);
|
|
123
|
+
|
|
124
|
+
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
125
|
+
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
126
|
+
|
|
127
|
+
const load = useCallback(async () => {
|
|
128
|
+
if (!token) return;
|
|
129
|
+
setLoading(true);
|
|
130
|
+
const r = await identity.listUsers(token, query);
|
|
131
|
+
if (r.ok) {
|
|
132
|
+
setRows(r.rows || []);
|
|
133
|
+
setTotal(r.total || 0);
|
|
134
|
+
setError('');
|
|
135
|
+
} else {
|
|
136
|
+
setRows([]);
|
|
137
|
+
setTotal(0);
|
|
138
|
+
setError(r.error || 'Could not load users.');
|
|
139
|
+
}
|
|
140
|
+
setLoading(false);
|
|
141
|
+
}, [identity, token, query]);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
load();
|
|
145
|
+
}, [load]);
|
|
146
|
+
|
|
147
|
+
const onSearchChange = useCallback(
|
|
148
|
+
(search) => setQuery((q) => ({ ...q, search, page: 1 })),
|
|
149
|
+
[],
|
|
150
|
+
);
|
|
151
|
+
const onSortChange = useCallback(
|
|
152
|
+
(sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
|
|
153
|
+
[],
|
|
154
|
+
);
|
|
155
|
+
const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
|
|
156
|
+
const onLimitChange = useCallback(
|
|
157
|
+
(limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
|
|
158
|
+
[],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const onConfirmDelete = async () => {
|
|
162
|
+
if (!deleteTarget) return;
|
|
163
|
+
setDeleteBusy(true);
|
|
164
|
+
const r = await identity.deleteUser(token, deleteTarget.id);
|
|
165
|
+
setDeleteBusy(false);
|
|
166
|
+
if (!r.ok) {
|
|
167
|
+
toast.error(r.error || 'Could not delete the user.');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
setDeleteTarget(null);
|
|
171
|
+
toast.success('User deleted');
|
|
172
|
+
await load();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<AppShell section="Identity" title="Users">
|
|
177
|
+
<div className="p-4">
|
|
178
|
+
<PageHeader
|
|
179
|
+
section="Identity"
|
|
180
|
+
title="Users"
|
|
181
|
+
icon={Users}
|
|
182
|
+
actions={
|
|
183
|
+
<EntityListControls
|
|
184
|
+
search={query.search}
|
|
185
|
+
onSearchChange={onSearchChange}
|
|
186
|
+
searchPlaceholder="Search users…"
|
|
187
|
+
addLabel="Add user"
|
|
188
|
+
onAdd={() => navigate('/users/add')}
|
|
189
|
+
/>
|
|
190
|
+
}
|
|
191
|
+
/>
|
|
192
|
+
|
|
193
|
+
<EntityList
|
|
194
|
+
columns={COLUMNS}
|
|
195
|
+
rows={rows}
|
|
196
|
+
search={query.search}
|
|
197
|
+
sortBy={query.sortBy}
|
|
198
|
+
sortOrder={query.sortOrder}
|
|
199
|
+
loading={loading}
|
|
200
|
+
error={error}
|
|
201
|
+
emptyLabel="No users yet. Add one to get started."
|
|
202
|
+
onEdit={(row) => navigate(`/users/edit/${row.id}`)}
|
|
203
|
+
onDelete={(row) => setDeleteTarget(row)}
|
|
204
|
+
onSortChange={onSortChange}
|
|
205
|
+
/>
|
|
206
|
+
|
|
207
|
+
<EntityListPager
|
|
208
|
+
total={total}
|
|
209
|
+
page={query.page}
|
|
210
|
+
limit={query.limit}
|
|
211
|
+
loading={loading}
|
|
212
|
+
error={error}
|
|
213
|
+
onPageChange={onPageChange}
|
|
214
|
+
onLimitChange={onLimitChange}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<Outlet context={{ onRefresh: load }} />
|
|
219
|
+
|
|
220
|
+
<ConfirmDialog
|
|
221
|
+
open={!!deleteTarget}
|
|
222
|
+
title={deleteTarget ? `Delete “${deleteTarget.fields?.username || 'this user'}”?` : 'Delete user?'}
|
|
223
|
+
message="This permanently removes the user and all their company memberships. This cannot be undone."
|
|
224
|
+
confirmLabel="Delete"
|
|
225
|
+
destructive
|
|
226
|
+
busy={deleteBusy}
|
|
227
|
+
onConfirm={onConfirmDelete}
|
|
228
|
+
onCancel={() => setDeleteTarget(null)}
|
|
229
|
+
/>
|
|
230
|
+
</AppShell>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WEB HttpClient adapter — the platform half of @astram/frontend-core's injected HTTP
|
|
3
|
+
* contract. Uses the browser `fetch`, same-origin `/api` (proxied by Vite to the
|
|
4
|
+
* server's /auth/* routes). A mobile app provides its OWN adapter (RN fetch + its
|
|
5
|
+
* base URL) implementing the same shape; the shared logic is identical for both.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const BASE = '/api';
|
|
9
|
+
|
|
10
|
+
async function toResponse(res) {
|
|
11
|
+
let data = null;
|
|
12
|
+
try {
|
|
13
|
+
data = res.status === 204 ? null : await res.json();
|
|
14
|
+
} catch {
|
|
15
|
+
data = null;
|
|
16
|
+
}
|
|
17
|
+
return { ok: res.ok, status: res.status, data };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The app-wide enforced-re-enroll interceptor. When a request is rejected with a 403
|
|
22
|
+
* whose body is EXACTLY `{ error: 'reenrollment_required' }` (the server's re-enroll
|
|
23
|
+
* gate, §4), fire the supplied callback so the app can force the re-enroll screen.
|
|
24
|
+
* Matched narrowly on that single error code so an unrelated future permission-deny 403
|
|
25
|
+
* never trips it. Best-effort: a throwing callback must not break the HTTP result.
|
|
26
|
+
*/
|
|
27
|
+
function maybeSignalReenroll(result, onReenrollRequired) {
|
|
28
|
+
if (
|
|
29
|
+
typeof onReenrollRequired === 'function' &&
|
|
30
|
+
result.status === 403 &&
|
|
31
|
+
result.data &&
|
|
32
|
+
result.data.error === 'reenrollment_required'
|
|
33
|
+
) {
|
|
34
|
+
try {
|
|
35
|
+
onReenrollRequired();
|
|
36
|
+
} catch {
|
|
37
|
+
/* the signal is best-effort — never let it break the request result */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{ onReenrollRequired?: () => void }} [config]
|
|
45
|
+
* `onReenrollRequired` — invoked when any response is a 403 `reenrollment_required`
|
|
46
|
+
* (the enforced-re-enroll gate). The consumer (AuthContext) uses it to flip the app
|
|
47
|
+
* into the forced re-enroll flow app-wide, regardless of which call triggered it.
|
|
48
|
+
* @returns {import('@astram/frontend-core').HttpClient}
|
|
49
|
+
*/
|
|
50
|
+
export function createWebHttpClient(config = {}) {
|
|
51
|
+
const { onReenrollRequired } = config;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async post(path, body, opts = {}) {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'content-type': 'application/json',
|
|
60
|
+
...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify(body),
|
|
63
|
+
});
|
|
64
|
+
return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
|
|
65
|
+
} catch {
|
|
66
|
+
return { ok: false, status: 0, data: null };
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async patch(path, body, opts = {}) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
72
|
+
method: 'PATCH',
|
|
73
|
+
headers: {
|
|
74
|
+
'content-type': 'application/json',
|
|
75
|
+
...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
});
|
|
79
|
+
return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
|
|
80
|
+
} catch {
|
|
81
|
+
return { ok: false, status: 0, data: null };
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async put(path, body, opts = {}) {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
87
|
+
method: 'PUT',
|
|
88
|
+
headers: {
|
|
89
|
+
'content-type': 'application/json',
|
|
90
|
+
...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
});
|
|
94
|
+
return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
|
|
95
|
+
} catch {
|
|
96
|
+
return { ok: false, status: 0, data: null };
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async delete(path, body, opts = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
102
|
+
method: 'DELETE',
|
|
103
|
+
headers: {
|
|
104
|
+
'content-type': 'application/json',
|
|
105
|
+
...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
});
|
|
109
|
+
return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
|
|
110
|
+
} catch {
|
|
111
|
+
return { ok: false, status: 0, data: null };
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async get(path, opts = {}) {
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
117
|
+
headers: opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {},
|
|
118
|
+
});
|
|
119
|
+
return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, status: 0, data: null };
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WEB return-path store — the localStorage half of the return-path feature. The
|
|
3
|
+
* PURE logic (which paths are restorable + obfuscation) lives in @astram/frontend-core; this file
|
|
4
|
+
* is only the platform glue (localStorage), mirroring platform/sessionStore.js. A mobile
|
|
5
|
+
* app would provide its own store over AsyncStorage using the same shared encode/decode.
|
|
6
|
+
*
|
|
7
|
+
* Stored in localStorage ALWAYS (independent of "Remember me"), so a return works across a
|
|
8
|
+
* full page reload / browser restart — the reason this isn't just React Router location
|
|
9
|
+
* state (which is in-memory and lost on reload).
|
|
10
|
+
*/
|
|
11
|
+
import { encodeReturnPath, decodeReturnPath } from '@astram/frontend-core';
|
|
12
|
+
|
|
13
|
+
const KEY = 'app-return-path';
|
|
14
|
+
|
|
15
|
+
/** Persist `path` (obfuscated) as the place to return to after the next login. No-op for
|
|
16
|
+
* non-restorable paths or when storage is unavailable. */
|
|
17
|
+
export function saveReturnPath(path) {
|
|
18
|
+
const blob = encodeReturnPath(path);
|
|
19
|
+
if (!blob) return;
|
|
20
|
+
try {
|
|
21
|
+
localStorage.setItem(KEY, blob);
|
|
22
|
+
} catch {
|
|
23
|
+
/* storage full / disabled — restore just falls back to /dashboard */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The saved return path, or null if none / corrupt / storage unavailable. */
|
|
28
|
+
export function loadReturnPath() {
|
|
29
|
+
try {
|
|
30
|
+
return decodeReturnPath(localStorage.getItem(KEY));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Forget the saved return path (called once it's been consumed at login). */
|
|
37
|
+
export function clearReturnPath() {
|
|
38
|
+
try {
|
|
39
|
+
localStorage.removeItem(KEY);
|
|
40
|
+
} catch {
|
|
41
|
+
/* ignore */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WEB session store — the platform half of @astram/frontend-core's SessionStore contract,
|
|
3
|
+
* backed by localStorage. A mobile app provides its own (AsyncStorage). The shared
|
|
4
|
+
* session helpers never touch storage directly; the app injects this.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const KEY = 'app-session';
|
|
8
|
+
|
|
9
|
+
/** @returns {import('@astram/frontend-core').SessionStore} */
|
|
10
|
+
export function createWebSessionStore() {
|
|
11
|
+
return {
|
|
12
|
+
load() {
|
|
13
|
+
try {
|
|
14
|
+
const raw = localStorage.getItem(KEY);
|
|
15
|
+
return raw ? JSON.parse(raw) : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
save(state) {
|
|
21
|
+
try {
|
|
22
|
+
if (state) localStorage.setItem(KEY, JSON.stringify(state));
|
|
23
|
+
else localStorage.removeItem(KEY);
|
|
24
|
+
} catch {
|
|
25
|
+
/* storage unavailable — session stays in memory only */
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|