create-astram-app 0.1.2 → 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.
Files changed (101) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +132 -33
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/CLAUDE.md +51 -0
  7. package/template/README.md +43 -44
  8. package/template/_gitignore +6 -0
  9. package/template/frontend/FRONTEND.md +92 -0
  10. package/template/frontend/README.md +16 -0
  11. package/template/frontend/_env.example +6 -0
  12. package/template/frontend/_gitignore +9 -0
  13. package/template/frontend/eslint.config.js +71 -0
  14. package/template/frontend/index.html +13 -0
  15. package/template/frontend/package.json +52 -0
  16. package/template/frontend/public/astram-logo-full.png +0 -0
  17. package/template/frontend/public/astram-logo-white.png +0 -0
  18. package/template/frontend/public/favicon.png +0 -0
  19. package/template/frontend/src/App.jsx +167 -0
  20. package/template/frontend/src/auth/AuthContext.jsx +338 -0
  21. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  22. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  23. package/template/frontend/src/components/EntityList.jsx +480 -0
  24. package/template/frontend/src/components/PageHeader.jsx +54 -0
  25. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  26. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  27. package/template/frontend/src/components/ui/alert.tsx +83 -0
  28. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  29. package/template/frontend/src/components/ui/badge.tsx +56 -0
  30. package/template/frontend/src/components/ui/button.tsx +67 -0
  31. package/template/frontend/src/components/ui/card.tsx +92 -0
  32. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  33. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  34. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  35. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  36. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  37. package/template/frontend/src/components/ui/input.tsx +21 -0
  38. package/template/frontend/src/components/ui/label.tsx +46 -0
  39. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  40. package/template/frontend/src/components/ui/select.tsx +199 -0
  41. package/template/frontend/src/components/ui/separator.tsx +26 -0
  42. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  43. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  44. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  45. package/template/frontend/src/components/ui/table.tsx +181 -0
  46. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  47. package/template/frontend/src/config/branding.js +80 -0
  48. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  49. package/template/frontend/src/index.css +265 -0
  50. package/template/frontend/src/lib/utils.js +7 -0
  51. package/template/frontend/src/main.jsx +27 -0
  52. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  53. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  54. package/template/frontend/src/pages/Login.jsx +393 -0
  55. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  56. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  57. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  58. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  59. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  60. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  61. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  62. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  63. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  64. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  65. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  66. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  67. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  68. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  69. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  70. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  71. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  72. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  73. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  74. package/template/frontend/src/platform/httpClient.js +125 -0
  75. package/template/frontend/src/platform/returnPathStore.js +43 -0
  76. package/template/frontend/src/platform/sessionStore.js +29 -0
  77. package/template/frontend/src/platform/shieldTransport.js +172 -0
  78. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  79. package/template/frontend/src/shell/AppShell.jsx +237 -0
  80. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  81. package/template/frontend/src/shell/nav.js +90 -0
  82. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  83. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  84. package/template/frontend/tsconfig.json +22 -0
  85. package/template/frontend/vite.config.js +30 -0
  86. package/template/server/_env.example +45 -0
  87. package/template/server/eslint.config.js +95 -0
  88. package/template/server/package.json +49 -0
  89. package/template/server/scripts/dev.mjs +51 -0
  90. package/template/server/scripts/sync-astram-guide.mjs +63 -0
  91. package/template/_env.example +0 -33
  92. package/template/package.json +0 -37
  93. /package/template/{src → server/src}/app.module.ts +0 -0
  94. /package/template/{src → server/src}/features/_gitkeep +0 -0
  95. /package/template/{src → server/src}/health/demo.controller.ts +0 -0
  96. /package/template/{src → server/src}/health/health.controller.ts +0 -0
  97. /package/template/{src → server/src}/main.ts +0 -0
  98. /package/template/{test → server/test}/enforcement.e2e.spec.ts +0 -0
  99. /package/template/{tsconfig.build.json → server/tsconfig.build.json} +0 -0
  100. /package/template/{tsconfig.json → server/tsconfig.json} +0 -0
  101. /package/template/{vitest.config.ts → server/vitest.config.ts} +0 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * AuditPage — the read-only Audit log viewer. Mirrors the structure of
3
+ * `pages/access/AccessPage.jsx` (AppShell + PageHeader + EntityList wiring, the same
4
+ * useAuth()/useSearchParams()/useCallback `load()` shape) but is IMMUTABLE: no add/edit/
5
+ * delete, no row-action menu, no routed-modal child routes — audit events cannot be
6
+ * changed from here.
7
+ *
8
+ * Paging is CURSOR-based (`before` / `nextCursor`), not page-number based — the backend
9
+ * (`@astram/frontend-core`'s `createAuditService().list()`) returns `{ ok, data, nextCursor }`, not
10
+ * a `total`/`page` shape, so `EntityListPager` (which drives page/limit) does not fit; a
11
+ * simple Prev/Next pair built from the existing `Button` primitive drives the cursor
12
+ * instead (no new reusable component — same primitives EntityList itself imports).
13
+ *
14
+ * ⛔ No raw UUIDs: rows carry NONE (the backend already strips ids — `AuditReadService`
15
+ * server-side). Only `actorLabel` / `tenantLabel` / `action` / `occurredAt` / `detail` are
16
+ * ever rendered.
17
+ *
18
+ * Tenant column: audit rows always carry a `tenantLabel` (the server auto-scopes a non-
19
+ * root to their own tenant already), but the column is only USEFUL for a
20
+ * root (whose results can legitimately span tenants) — for anyone else, every row's
21
+ * tenantLabel is trivially their own tenant, so the column is hidden as noise. The current
22
+ * `useAuth().profile` (`GET /auth/me` DTO) does not yet expose an `isRoot`
23
+ * flag (see `profile-resolver.ts`); this reads it defensively as `profile?.isRoot`
24
+ * (safe no-op today) OR'd with a same-page fallback signal that needs no backend change:
25
+ * the column also shows itself the moment the loaded rows actually contain more than one
26
+ * distinct tenantLabel (which can only happen for a root, since a non-root's
27
+ * results are pinned to one tenant server-side) — so real multi-tenant data is never
28
+ * hidden even before the profile flag exists.
29
+ */
30
+ import { useCallback, useEffect, useMemo, useState } from 'react';
31
+ import { useSearchParams } from 'react-router-dom';
32
+ import { History } from 'lucide-react';
33
+ import { useAuth } from '@/auth/AuthContext';
34
+ import AppShell from '@/shell/AppShell';
35
+ import PageHeader from '@/components/PageHeader';
36
+ import EntityList from '@/components/EntityList';
37
+ import { Badge } from '@/components/ui/badge';
38
+ import { Button } from '@/components/ui/button';
39
+ import { Input } from '@/components/ui/input';
40
+
41
+ /** Readable absolute date-time — e.g. "13 Jul 2026, 4:05 PM"; '—' when missing/invalid. */
42
+ function formatDateTime(ms) {
43
+ if (!ms && ms !== 0) return '—';
44
+ const d = new Date(ms);
45
+ return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString();
46
+ }
47
+
48
+ /** Compact "key: value, key: value" rendering of the detail map; '—' when empty. */
49
+ function formatDetail(detail) {
50
+ const entries = Object.entries(detail || {});
51
+ if (entries.length === 0) return '—';
52
+ return entries
53
+ .map(([k, v]) => `${k}: ${typeof v === 'object' && v !== null ? JSON.stringify(v) : v}`)
54
+ .join(', ');
55
+ }
56
+
57
+ /** Seed the filter state from the URL so a reload / shared link restores the view. */
58
+ function readQueryFromParams(sp) {
59
+ return {
60
+ type: sp.get('type') || '',
61
+ actor: sp.get('actor') || '',
62
+ tenant: sp.get('tenant') || '',
63
+ before: sp.get('before') || undefined,
64
+ };
65
+ }
66
+
67
+ const DEFAULT_LIMIT = 25;
68
+
69
+ export default function AuditPage() {
70
+ const { tokens, audit, profile } = useAuth();
71
+ const token = tokens?.accessToken;
72
+ const [searchParams, setSearchParams] = useSearchParams();
73
+
74
+ const [rows, setRows] = useState([]);
75
+ const [nextCursor, setNextCursor] = useState(null);
76
+ const [loading, setLoading] = useState(true);
77
+ const [error, setError] = useState('');
78
+
79
+ // `query` holds the filters + the cursor stack (so "Prev" can pop back). `before` is the
80
+ // cursor for the CURRENT page (undefined = first page).
81
+ const [query, setQuery] = useState(() => readQueryFromParams(searchParams));
82
+ const [cursorStack, setCursorStack] = useState([]); // history of `before` values for Prev
83
+
84
+ // query (minus paging) → URL (replace, no history spam) — mirrors AccessPage.
85
+ useEffect(() => {
86
+ const p = new URLSearchParams();
87
+ if (query.type) p.set('type', query.type);
88
+ if (query.actor) p.set('actor', query.actor);
89
+ if (query.tenant) p.set('tenant', query.tenant);
90
+ setSearchParams(p, { replace: true });
91
+ }, [query.type, query.actor, query.tenant, setSearchParams]);
92
+
93
+ const load = useCallback(async () => {
94
+ if (!token) return;
95
+ setLoading(true);
96
+ const r = await audit.list(token, {
97
+ ...(query.type ? { type: query.type } : {}),
98
+ ...(query.actor ? { actor: query.actor } : {}),
99
+ ...(query.tenant ? { tenant: query.tenant } : {}),
100
+ ...(query.before ? { before: query.before } : {}),
101
+ limit: DEFAULT_LIMIT,
102
+ });
103
+ if (r.ok) {
104
+ setRows(r.data || []);
105
+ setNextCursor(r.nextCursor || null);
106
+ setError('');
107
+ } else {
108
+ setRows([]);
109
+ setNextCursor(null);
110
+ setError(r.error || 'Could not load the audit log.');
111
+ }
112
+ setLoading(false);
113
+ }, [audit, token, query]);
114
+
115
+ useEffect(() => {
116
+ load();
117
+ }, [load]);
118
+
119
+ // Any filter change starts a fresh cursor walk (reset inline, not in an effect).
120
+ const onTypeChange = useCallback((e) => {
121
+ setCursorStack([]);
122
+ setQuery((q) => ({ ...q, type: e.target.value, before: undefined }));
123
+ }, []);
124
+ const onActorChange = useCallback((e) => {
125
+ setCursorStack([]);
126
+ setQuery((q) => ({ ...q, actor: e.target.value, before: undefined }));
127
+ }, []);
128
+ const onTenantChange = useCallback((e) => {
129
+ setCursorStack([]);
130
+ setQuery((q) => ({ ...q, tenant: e.target.value, before: undefined }));
131
+ }, []);
132
+
133
+ const onNext = useCallback(() => {
134
+ if (!nextCursor) return;
135
+ setCursorStack((s) => [...s, query.before]);
136
+ setQuery((q) => ({ ...q, before: nextCursor }));
137
+ }, [nextCursor, query.before]);
138
+
139
+ const onPrev = useCallback(() => {
140
+ setCursorStack((s) => {
141
+ if (s.length === 0) return s;
142
+ const prevCursor = s[s.length - 1];
143
+ setQuery((q) => ({ ...q, before: prevCursor }));
144
+ return s.slice(0, -1);
145
+ });
146
+ }, []);
147
+
148
+ // Root tenant-column gating: prefer the profile flag once it exists; until then,
149
+ // fall back to detecting real multi-tenant results in the loaded page (see file header).
150
+ const showTenantColumn = useMemo(() => {
151
+ if (profile?.isRoot) return true;
152
+ const distinctTenants = new Set(rows.map((r) => r.tenantLabel).filter(Boolean));
153
+ return distinctTenants.size > 1;
154
+ }, [profile, rows]);
155
+
156
+ const columns = useMemo(() => {
157
+ const cols = [
158
+ {
159
+ key: 'occurredAt',
160
+ label: 'Time',
161
+ render: (row) => (
162
+ <span className="whitespace-nowrap text-sm text-foreground">
163
+ {formatDateTime(row.occurredAt)}
164
+ </span>
165
+ ),
166
+ },
167
+ {
168
+ key: 'actorLabel',
169
+ label: 'Actor',
170
+ render: (row) => <span className="text-sm text-foreground">{row.actorLabel || '—'}</span>,
171
+ },
172
+ {
173
+ key: 'action',
174
+ label: 'Action',
175
+ render: (row) => (
176
+ <Badge variant="info" className="font-normal">
177
+ {row.action || '(event)'}
178
+ </Badge>
179
+ ),
180
+ },
181
+ ];
182
+ if (showTenantColumn) {
183
+ cols.push({
184
+ key: 'tenantLabel',
185
+ label: 'Tenant',
186
+ render: (row) => <span className="text-sm text-foreground">{row.tenantLabel || '—'}</span>,
187
+ });
188
+ }
189
+ cols.push({
190
+ key: 'detail',
191
+ label: 'Detail',
192
+ render: (row) => (
193
+ <span className="text-xs text-muted-foreground">{formatDetail(row.detail)}</span>
194
+ ),
195
+ });
196
+ return cols;
197
+ }, [showTenantColumn]);
198
+
199
+ return (
200
+ <AppShell section="Identity" title="Audit">
201
+ <div className="p-4">
202
+ <PageHeader
203
+ section="Identity"
204
+ title="Audit"
205
+ icon={History}
206
+ actions={
207
+ <div className="flex flex-wrap items-center gap-2">
208
+ <Input
209
+ value={query.type}
210
+ onChange={onTypeChange}
211
+ placeholder="Filter by action…"
212
+ className="h-9 w-40"
213
+ aria-label="Filter by action"
214
+ />
215
+ <Input
216
+ value={query.actor}
217
+ onChange={onActorChange}
218
+ placeholder="Filter by actor…"
219
+ className="h-9 w-40"
220
+ aria-label="Filter by actor"
221
+ />
222
+ {showTenantColumn && (
223
+ <Input
224
+ value={query.tenant}
225
+ onChange={onTenantChange}
226
+ placeholder="Filter by tenant…"
227
+ className="h-9 w-40"
228
+ aria-label="Filter by tenant"
229
+ />
230
+ )}
231
+ </div>
232
+ }
233
+ />
234
+
235
+ <EntityList
236
+ columns={columns}
237
+ rows={rows}
238
+ loading={loading}
239
+ error={error}
240
+ emptyLabel="No audit events."
241
+ />
242
+
243
+ {!error && (rows.length > 0 || loading) && (
244
+ <div className="mt-3 flex items-center justify-end gap-2">
245
+ <Button
246
+ type="button"
247
+ variant="outline"
248
+ size="sm"
249
+ onClick={onPrev}
250
+ disabled={cursorStack.length === 0 || loading}
251
+ >
252
+ Prev
253
+ </Button>
254
+ <Button
255
+ type="button"
256
+ variant="outline"
257
+ size="sm"
258
+ onClick={onNext}
259
+ disabled={!nextCursor || loading}
260
+ >
261
+ Next
262
+ </Button>
263
+ </div>
264
+ )}
265
+ </div>
266
+ </AppShell>
267
+ );
268
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * CompaniesPage — the Companies management screen: a list of companies with routed-modal
3
+ * Add/Edit and a themed delete confirm. This is a LAYOUT route: it owns the list and
4
+ * renders `<Outlet context={{ onRefresh }} />`, so the Add/Edit child routes
5
+ * (/companies/add, /companies/edit/:uuid) render CompanyForm as an overlay on top of the
6
+ * still-mounted list and can trigger a re-fetch on success.
7
+ *
8
+ * Data loads on mount via the shared identity client service (mirrors TwoFactorPage's
9
+ * load-on-mount + refresh pattern). Companies management is root-only server-side;
10
+ * a non-root gets a 403 which we render as a graceful "need platform-admin access"
11
+ * message (the EntityList `error` state) rather than a broken screen.
12
+ *
13
+ * ⛔ No raw UUIDs: columns render `fields.name` / `fields.status`; the row uuid (`row.id`)
14
+ * is used only as a key and for routing to the edit page.
15
+ */
16
+ import { useCallback, useEffect, useState } from 'react';
17
+ import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
18
+ import { Building2 } from 'lucide-react';
19
+ import { toast } from 'sonner';
20
+ import { useAuth } from '@/auth/AuthContext';
21
+ import AppShell from '@/shell/AppShell';
22
+ import PageHeader from '@/components/PageHeader';
23
+ import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
24
+ import ConfirmDialog from '@/components/ConfirmDialog';
25
+ import { Badge } from '@/components/ui/badge';
26
+
27
+ /** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
28
+ function formatDate(v) {
29
+ if (!v) return '—';
30
+ try {
31
+ return new Date(v).toLocaleDateString(undefined, {
32
+ day: 'numeric',
33
+ month: 'short',
34
+ year: 'numeric',
35
+ });
36
+ } catch {
37
+ return '—';
38
+ }
39
+ }
40
+
41
+ // Sortable columns (server-side): Name, Status, Created. `sortKey` is the engine field KEY.
42
+ const COLUMNS = [
43
+ {
44
+ key: 'name',
45
+ label: 'Name',
46
+ sortable: true,
47
+ sortKey: 'name',
48
+ render: (row) => row.fields?.name || '(unnamed)',
49
+ },
50
+ {
51
+ key: 'status',
52
+ label: 'Status',
53
+ sortable: true,
54
+ sortKey: 'status',
55
+ render: (row) => {
56
+ const status = row.fields?.status || 'active';
57
+ return (
58
+ <Badge variant={status === 'active' ? 'success' : 'secondary'} className="capitalize">
59
+ {status}
60
+ </Badge>
61
+ );
62
+ },
63
+ },
64
+ {
65
+ key: 'createdAt',
66
+ label: 'Created',
67
+ sortable: true,
68
+ sortKey: 'createdAt',
69
+ render: (row) => formatDate(row.fields?.createdAt),
70
+ },
71
+ ];
72
+
73
+ const DEFAULT_LIMIT = 25;
74
+
75
+ /**
76
+ * Seed the initial DataTable query from the URL so a reload / shared link restores
77
+ * EXACTLY the view (page + limit + search + sortBy + sortOrder). Absent params fall
78
+ * back to the defaults; values are clamped/guarded so a hand-edited URL can't poison state.
79
+ */
80
+ function readQueryFromParams(sp) {
81
+ const page = Number.parseInt(sp.get('page') ?? '', 10);
82
+ const limit = Number.parseInt(sp.get('limit') ?? '', 10);
83
+ const sortOrder = (sp.get('sortOrder') || '').toLowerCase();
84
+ return {
85
+ page: Number.isFinite(page) && page >= 1 ? page : 1,
86
+ limit: Number.isFinite(limit) && limit >= 1 && limit <= 200 ? limit : DEFAULT_LIMIT,
87
+ search: sp.get('search') || '',
88
+ sortBy: sp.get('sortBy') || 'createdAt',
89
+ sortOrder: sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'desc',
90
+ };
91
+ }
92
+
93
+ export default function CompaniesPage() {
94
+ const { tokens, identity } = useAuth();
95
+ const token = tokens?.accessToken;
96
+ const navigate = useNavigate();
97
+ const [searchParams, setSearchParams] = useSearchParams();
98
+
99
+ const [rows, setRows] = useState([]);
100
+ const [total, setTotal] = useState(0);
101
+ const [loading, setLoading] = useState(true);
102
+ const [error, setError] = useState('');
103
+
104
+ // The DataTable query state — the PAGE owns it; EntityList is controlled by it.
105
+ // Seed ONCE from the URL (initializer runs only on mount) → single source of truth.
106
+ const [query, setQuery] = useState(() => readQueryFromParams(searchParams));
107
+
108
+ // Sync query → URL on every change (replace:true — no history spam). Only non-default
109
+ // page/limit/search are emitted to keep the URL clean; sortBy/sortOrder are always
110
+ // meaningful so they're always emitted. Reads only `query` — never the URL — so no loop.
111
+ useEffect(() => {
112
+ const p = new URLSearchParams();
113
+ if (query.page > 1) p.set('page', String(query.page));
114
+ if (query.limit !== DEFAULT_LIMIT) p.set('limit', String(query.limit));
115
+ if (query.search) p.set('search', query.search);
116
+ if (query.sortBy) p.set('sortBy', query.sortBy);
117
+ if (query.sortOrder) p.set('sortOrder', query.sortOrder);
118
+ setSearchParams(p, { replace: true });
119
+ }, [query, setSearchParams]);
120
+
121
+ // Delete confirm state (ConfirmDialog — the API fires ONLY from onConfirm).
122
+ const [deleteTarget, setDeleteTarget] = useState(null);
123
+ const [deleteBusy, setDeleteBusy] = useState(false);
124
+
125
+ const load = useCallback(async () => {
126
+ if (!token) return;
127
+ setLoading(true);
128
+ const r = await identity.listCompanies(token, query);
129
+ if (r.ok) {
130
+ setRows(r.rows || []);
131
+ setTotal(r.total || 0);
132
+ setError('');
133
+ } else {
134
+ setRows([]);
135
+ setTotal(0);
136
+ setError(r.error || 'Could not load companies.');
137
+ }
138
+ setLoading(false);
139
+ }, [identity, token, query]);
140
+
141
+ useEffect(() => {
142
+ load();
143
+ }, [load]);
144
+
145
+ // Query-change handlers: search/limit reset to page 1; sort toggles asc↔desc.
146
+ const onSearchChange = useCallback(
147
+ (search) => setQuery((q) => ({ ...q, search, page: 1 })),
148
+ [],
149
+ );
150
+ const onSortChange = useCallback(
151
+ (sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
152
+ [],
153
+ );
154
+ const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
155
+ const onLimitChange = useCallback(
156
+ (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
157
+ [],
158
+ );
159
+
160
+ const onConfirmDelete = async () => {
161
+ if (!deleteTarget) return;
162
+ setDeleteBusy(true);
163
+ const r = await identity.deleteCompany(token, deleteTarget.id);
164
+ setDeleteBusy(false);
165
+ if (!r.ok) {
166
+ toast.error(r.error || 'Could not delete the company.');
167
+ return;
168
+ }
169
+ setDeleteTarget(null);
170
+ toast.success('Company deleted');
171
+ await load();
172
+ };
173
+
174
+ return (
175
+ <AppShell section="Identity" title="Companies">
176
+ <div className="p-4">
177
+ <PageHeader
178
+ section="Identity"
179
+ title="Companies"
180
+ icon={Building2}
181
+ actions={
182
+ <EntityListControls
183
+ search={query.search}
184
+ onSearchChange={onSearchChange}
185
+ searchPlaceholder="Search companies…"
186
+ addLabel="Add company"
187
+ onAdd={() => navigate('/companies/add')}
188
+ />
189
+ }
190
+ />
191
+
192
+ <EntityList
193
+ columns={COLUMNS}
194
+ rows={rows}
195
+ search={query.search}
196
+ sortBy={query.sortBy}
197
+ sortOrder={query.sortOrder}
198
+ loading={loading}
199
+ error={error}
200
+ emptyLabel="No companies yet. Add one to get started."
201
+ onEdit={(row) => navigate(`/companies/edit/${row.id}`)}
202
+ onDelete={(row) => setDeleteTarget(row)}
203
+ onSortChange={onSortChange}
204
+ />
205
+
206
+ <EntityListPager
207
+ total={total}
208
+ page={query.page}
209
+ limit={query.limit}
210
+ loading={loading}
211
+ error={error}
212
+ onPageChange={onPageChange}
213
+ onLimitChange={onLimitChange}
214
+ />
215
+ </div>
216
+
217
+ {/* Add / Edit render here as a routed overlay; onRefresh re-fetches the list. */}
218
+ <Outlet context={{ onRefresh: load }} />
219
+
220
+ <ConfirmDialog
221
+ open={!!deleteTarget}
222
+ title={deleteTarget ? `Delete “${deleteTarget.fields?.name || 'this company'}”?` : 'Delete company?'}
223
+ message="This permanently removes the company and its 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,127 @@
1
+ /**
2
+ * CompanyForm — the Add/Edit company form, rendered as a routed-modal overlay by the
3
+ * /companies/add and /companies/edit/:uuid child routes (under CompaniesPage's layout).
4
+ * The SAME component serves add + edit: `useParams()` → a `:uuid` means edit (load the
5
+ * record first), else add. It closes via `navigate(-1)` (back-button semantics) and, on
6
+ * success, toasts + calls the layout's `onRefresh` (via `useOutletContext`) to re-fetch.
7
+ *
8
+ * Fields: Name (required) + Status (edit only — a new company defaults server-side).
9
+ * ⛔ The `:uuid` is a route param only — never rendered as visible text.
10
+ */
11
+ import { useEffect, useState } from 'react';
12
+ import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
13
+ import { toast } from 'sonner';
14
+ import { useAuth } from '@/auth/AuthContext';
15
+ import EntityFormModal from '@/components/EntityFormModal';
16
+ import { Input } from '@/components/ui/input';
17
+ import { Label } from '@/components/ui/label';
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from '@/components/ui/select';
25
+
26
+ export default function CompanyForm() {
27
+ const { tokens, identity } = useAuth();
28
+ const token = tokens?.accessToken;
29
+ const { uuid } = useParams();
30
+ const isEdit = Boolean(uuid);
31
+ const navigate = useNavigate();
32
+ const { onRefresh } = useOutletContext() ?? {};
33
+
34
+ const [name, setName] = useState('');
35
+ const [status, setStatus] = useState('active');
36
+ const [loading, setLoading] = useState(isEdit);
37
+ const [busy, setBusy] = useState(false);
38
+ const [error, setError] = useState('');
39
+
40
+ // On edit, load the current record so the form pre-fills with its display fields.
41
+ useEffect(() => {
42
+ if (!isEdit || !token) return undefined;
43
+ let active = true;
44
+ (async () => {
45
+ const r = await identity.getCompany(token, uuid);
46
+ if (!active) return;
47
+ if (r.ok) {
48
+ setName(r.record?.fields?.name || '');
49
+ setStatus(r.record?.fields?.status || 'active');
50
+ } else {
51
+ setError(r.error || 'Could not load the company.');
52
+ }
53
+ setLoading(false);
54
+ })();
55
+ return () => {
56
+ active = false;
57
+ };
58
+ }, [identity, token, uuid, isEdit]);
59
+
60
+ const close = () => navigate(-1);
61
+
62
+ const onSubmit = async () => {
63
+ const trimmed = name.trim();
64
+ if (!trimmed) {
65
+ setError('Enter a company name.');
66
+ return;
67
+ }
68
+ setError('');
69
+ setBusy(true);
70
+ const r = isEdit
71
+ ? await identity.updateCompany(token, uuid, { name: trimmed, status })
72
+ : await identity.createCompany(token, trimmed);
73
+ setBusy(false);
74
+ if (!r.ok) {
75
+ setError(r.error || 'Could not save the company.');
76
+ return;
77
+ }
78
+ toast.success(isEdit ? 'Company updated' : 'Company created');
79
+ onRefresh?.();
80
+ close();
81
+ };
82
+
83
+ return (
84
+ <EntityFormModal
85
+ title={isEdit ? 'Edit company' : 'Add company'}
86
+ description={isEdit ? undefined : 'Create a new tenant company.'}
87
+ open
88
+ onClose={close}
89
+ onSubmit={onSubmit}
90
+ submitLabel={isEdit ? 'Save changes' : 'Create company'}
91
+ busy={busy || loading}
92
+ error={error}
93
+ >
94
+ <div className="space-y-1.5">
95
+ <Label htmlFor="company-name" variant="field">
96
+ Name
97
+ </Label>
98
+ <Input
99
+ id="company-name"
100
+ autoFocus
101
+ maxLength={120}
102
+ placeholder="e.g. Acme Corp"
103
+ value={name}
104
+ disabled={loading}
105
+ onChange={(e) => setName(e.target.value)}
106
+ />
107
+ </div>
108
+
109
+ {isEdit && (
110
+ <div className="space-y-1.5">
111
+ <Label htmlFor="company-status" variant="field">
112
+ Status
113
+ </Label>
114
+ <Select value={status} disabled={loading} onValueChange={setStatus}>
115
+ <SelectTrigger id="company-status">
116
+ <SelectValue />
117
+ </SelectTrigger>
118
+ <SelectContent>
119
+ <SelectItem value="active">Active</SelectItem>
120
+ <SelectItem value="inactive">Inactive</SelectItem>
121
+ </SelectContent>
122
+ </Select>
123
+ </div>
124
+ )}
125
+ </EntityFormModal>
126
+ );
127
+ }