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,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
+ }