create-astram-app 0.5.0 → 0.6.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 (82) hide show
  1. package/README.md +211 -38
  2. package/dist/add-frontend.js.map +1 -1
  3. package/dist/index.js +14 -9
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/template/README.md +51 -20
  7. package/template/frontend/README.md +21 -11
  8. package/template/frontend/index.html +16 -0
  9. package/template/frontend/package.json +1 -1
  10. package/template/frontend/src/App.jsx +82 -20
  11. package/template/frontend/src/auth/AuthContext.jsx +139 -21
  12. package/template/frontend/src/auth/routeGate.js +31 -0
  13. package/template/frontend/src/auth/routeGate.test.mjs +59 -0
  14. package/template/frontend/src/auth/sessionFlow.js +27 -0
  15. package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
  16. package/template/frontend/src/components/EntityList.jsx +1 -7
  17. package/template/frontend/src/components/PageHeader.jsx +5 -4
  18. package/template/frontend/src/components/ShareDialog.jsx +3 -5
  19. package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
  20. package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
  21. package/template/frontend/src/components/ui/alert.tsx +17 -23
  22. package/template/frontend/src/components/ui/avatar.tsx +8 -20
  23. package/template/frontend/src/components/ui/badge.tsx +17 -26
  24. package/template/frontend/src/components/ui/button.tsx +22 -24
  25. package/template/frontend/src/components/ui/card.tsx +19 -36
  26. package/template/frontend/src/components/ui/checkbox.tsx +7 -10
  27. package/template/frontend/src/components/ui/date-field.tsx +26 -27
  28. package/template/frontend/src/components/ui/dialog.tsx +24 -40
  29. package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
  30. package/template/frontend/src/components/ui/input-otp.tsx +1 -4
  31. package/template/frontend/src/components/ui/label.tsx +10 -12
  32. package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
  33. package/template/frontend/src/components/ui/select.tsx +31 -43
  34. package/template/frontend/src/components/ui/separator.tsx +6 -6
  35. package/template/frontend/src/components/ui/sheet.tsx +29 -38
  36. package/template/frontend/src/components/ui/skeleton.tsx +3 -3
  37. package/template/frontend/src/components/ui/sonner.tsx +7 -7
  38. package/template/frontend/src/components/ui/table.tsx +56 -70
  39. package/template/frontend/src/components/ui/tooltip.tsx +7 -11
  40. package/template/frontend/src/config/branding.js +1 -4
  41. package/template/frontend/src/hooks/useMobile.tsx +4 -6
  42. package/template/frontend/src/index.css +5 -2
  43. package/template/frontend/src/pages/Dashboard.jsx +13 -7
  44. package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
  45. package/template/frontend/src/pages/Login.jsx +85 -70
  46. package/template/frontend/src/pages/ResetPassword.jsx +9 -2
  47. package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
  48. package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
  49. package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
  50. package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
  51. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
  52. package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
  53. package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
  54. package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
  55. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
  56. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
  57. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
  58. package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
  59. package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +77 -18
  60. package/template/frontend/src/pages/service-accounts/accessModel.js +22 -0
  61. package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +28 -0
  62. package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
  63. package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
  64. package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
  65. package/template/frontend/src/platform/httpClient.js +5 -0
  66. package/template/frontend/src/platform/sessionStore.js +55 -13
  67. package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
  68. package/template/frontend/src/platform/shieldTransport.js +6 -0
  69. package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
  70. package/template/frontend/src/shell/AppShell.jsx +10 -12
  71. package/template/frontend/src/shell/Sidebar.jsx +15 -3
  72. package/template/frontend/src/shell/nav.js +1 -3
  73. package/template/frontend/vite.config.js +19 -1
  74. package/template/server/_env.example +22 -1
  75. package/template/server/eslint.config.mjs +194 -0
  76. package/template/server/package.json +11 -11
  77. package/template/server/scripts/dev.mjs +17 -6
  78. package/template/server/scripts/sync-astram-guide.mjs +45 -19
  79. package/template/server/src/app.module.ts +11 -5
  80. package/template/server/src/health/demo.controller.ts +16 -12
  81. package/template/server/src/main.ts +23 -1
  82. package/template/server/eslint.config.js +0 -95
@@ -237,20 +237,15 @@ function DeviceRow({ device, onRename, onRemove, disabled, reenrollMode = false
237
237
  <p className="truncate font-medium text-foreground">{device.name}</p>
238
238
  )}
239
239
  <p className="mt-0.5 text-xs text-muted-foreground">
240
- Added {formatAdded(device.createdAtMs)} · Last used {formatLastUsed(device.lastUsedAtMs)}
240
+ Added {formatAdded(device.createdAtMs)} · Last used{' '}
241
+ {formatLastUsed(device.lastUsedAtMs)}
241
242
  </p>
242
243
  </div>
243
244
  </div>
244
245
 
245
246
  {!editing && !reenrollMode && (
246
247
  <div className="flex items-center gap-1 self-end sm:self-auto">
247
- <Button
248
- type="button"
249
- variant="ghost"
250
- size="sm"
251
- onClick={startEdit}
252
- disabled={disabled}
253
- >
248
+ <Button type="button" variant="ghost" size="sm" onClick={startEdit} disabled={disabled}>
254
249
  <Pencil className="size-4" />
255
250
  Rename
256
251
  </Button>
@@ -143,19 +143,13 @@ export default function CompaniesPage() {
143
143
  }, [load]);
144
144
 
145
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
- );
146
+ const onSearchChange = useCallback((search) => setQuery((q) => ({ ...q, search, page: 1 })), []);
150
147
  const onSortChange = useCallback(
151
148
  (sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
152
149
  [],
153
150
  );
154
151
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
155
- const onLimitChange = useCallback(
156
- (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
157
- [],
158
- );
152
+ const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
159
153
 
160
154
  const onConfirmDelete = async () => {
161
155
  if (!deleteTarget) return;
@@ -219,7 +213,11 @@ export default function CompaniesPage() {
219
213
 
220
214
  <ConfirmDialog
221
215
  open={!!deleteTarget}
222
- title={deleteTarget ? `Delete “${deleteTarget.fields?.name || 'this company'}”?` : 'Delete company?'}
216
+ title={
217
+ deleteTarget
218
+ ? `Delete “${deleteTarget.fields?.name || 'this company'}”?`
219
+ : 'Delete company?'
220
+ }
223
221
  message="This permanently removes the company and its memberships. This cannot be undone."
224
222
  confirmLabel="Delete"
225
223
  destructive
@@ -133,10 +133,19 @@ export default function GrantForm() {
133
133
 
134
134
  const fetchTenants = useCallback(
135
135
  async ({ search: term, page, limit }) => {
136
- const r = await identity.listCompanies(token, { page, limit, search: term, sortBy: 'name', sortOrder: 'asc' });
136
+ const r = await identity.listCompanies(token, {
137
+ page,
138
+ limit,
139
+ search: term,
140
+ sortBy: 'name',
141
+ sortOrder: 'asc',
142
+ });
137
143
  if (!r.ok) return { options: [], total: 0, page };
138
144
  return {
139
- options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
145
+ options: (r.rows || []).map((row) => ({
146
+ value: row.id,
147
+ label: row.fields?.name || '(unnamed)',
148
+ })),
140
149
  total: r.total ?? 0,
141
150
  page: r.page ?? page,
142
151
  };
@@ -147,18 +156,36 @@ export default function GrantForm() {
147
156
  const fetchGrantees = useCallback(
148
157
  async ({ search: term, page, limit }) => {
149
158
  if (granteeKind === 'role') {
150
- const r = await identity.listRoles(token, { page, limit, search: term, sortBy: 'name', sortOrder: 'asc' });
159
+ const r = await identity.listRoles(token, {
160
+ page,
161
+ limit,
162
+ search: term,
163
+ sortBy: 'name',
164
+ sortOrder: 'asc',
165
+ });
151
166
  if (!r.ok) return { options: [], total: 0, page };
152
167
  return {
153
- options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
168
+ options: (r.rows || []).map((row) => ({
169
+ value: row.id,
170
+ label: row.fields?.name || '(unnamed)',
171
+ })),
154
172
  total: r.total ?? 0,
155
173
  page: r.page ?? page,
156
174
  };
157
175
  }
158
- const r = await identity.listUsers(token, { page, limit, search: term, sortBy: 'username', sortOrder: 'asc' });
176
+ const r = await identity.listUsers(token, {
177
+ page,
178
+ limit,
179
+ search: term,
180
+ sortBy: 'username',
181
+ sortOrder: 'asc',
182
+ });
159
183
  if (!r.ok) return { options: [], total: 0, page };
160
184
  return {
161
- options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.username || '(unnamed)' })),
185
+ options: (r.rows || []).map((row) => ({
186
+ value: row.id,
187
+ label: row.fields?.username || '(unnamed)',
188
+ })),
162
189
  total: r.total ?? 0,
163
190
  page: r.page ?? page,
164
191
  };
@@ -280,7 +307,11 @@ export default function GrantForm() {
280
307
  <Label htmlFor="grant-type" variant="field">
281
308
  Object type
282
309
  </Label>
283
- <Select value={typeKey} onValueChange={onTypeChange} disabled={typesLoading || types.length === 0}>
310
+ <Select
311
+ value={typeKey}
312
+ onValueChange={onTypeChange}
313
+ disabled={typesLoading || types.length === 0}
314
+ >
284
315
  <SelectTrigger id="grant-type">
285
316
  <SelectValue placeholder={typesLoading ? 'Loading…' : 'Select an object type'} />
286
317
  </SelectTrigger>
@@ -293,7 +324,9 @@ export default function GrantForm() {
293
324
  </SelectContent>
294
325
  </Select>
295
326
  {!typesLoading && types.length === 0 && (
296
- <p className="text-xs text-muted-foreground">No object types are available to offer yet.</p>
327
+ <p className="text-xs text-muted-foreground">
328
+ No object types are available to offer yet.
329
+ </p>
297
330
  )}
298
331
  </div>
299
332
 
@@ -40,7 +40,8 @@ function statusVariant(status) {
40
40
  }
41
41
 
42
42
  function ActionChips({ actions }) {
43
- if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
43
+ if (!actions || actions.length === 0)
44
+ return <span className="text-xs text-muted-foreground">—</span>;
44
45
  return (
45
46
  <div className="flex flex-wrap gap-1">
46
47
  {actions.map((a) => (
@@ -127,7 +128,11 @@ export default function GrantsPage() {
127
128
  ),
128
129
  },
129
130
  { key: 'typeLabel', label: 'Object type' },
130
- { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
131
+ {
132
+ key: 'actions',
133
+ label: 'Actions',
134
+ render: (row) => <ActionChips actions={row.allowedActions} />,
135
+ },
131
136
  {
132
137
  key: 'status',
133
138
  label: 'Status',
@@ -176,7 +181,11 @@ export default function GrantsPage() {
176
181
  ),
177
182
  },
178
183
  { key: 'typeLabel', label: 'Object type' },
179
- { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
184
+ {
185
+ key: 'actions',
186
+ label: 'Actions',
187
+ render: (row) => <ActionChips actions={row.allowedActions} />,
188
+ },
180
189
  { key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
181
190
  { key: 'offeredByName', label: 'Offered by' },
182
191
  ];
@@ -190,16 +199,26 @@ export default function GrantsPage() {
190
199
  icon={Handshake}
191
200
  actions={
192
201
  <>
193
- <Button type="button" variant="outline" size="sm" onClick={() => navigate('/cross-tenant/inbox')}>
202
+ <Button
203
+ type="button"
204
+ variant="outline"
205
+ size="sm"
206
+ onClick={() => navigate('/cross-tenant/inbox')}
207
+ >
194
208
  <Inbox className="size-4" />
195
209
  Inbox
196
210
  </Button>
197
- <EntityListControls addLabel="Offer grant" onAdd={() => navigate('/cross-tenant/offer')} />
211
+ <EntityListControls
212
+ addLabel="Offer grant"
213
+ onAdd={() => navigate('/cross-tenant/offer')}
214
+ />
198
215
  </>
199
216
  }
200
217
  />
201
218
 
202
- <div className="mb-2 text-sm font-medium text-foreground">Outgoing — grants you&apos;ve offered</div>
219
+ <div className="mb-2 text-sm font-medium text-foreground">
220
+ Outgoing — grants you&apos;ve offered
221
+ </div>
203
222
  <EntityList
204
223
  columns={outgoingColumns}
205
224
  rows={rows}
@@ -224,7 +243,11 @@ export default function GrantsPage() {
224
243
 
225
244
  <ConfirmDialog
226
245
  open={!!revokeTarget}
227
- title={revokeTarget ? `Revoke the grant to “${revokeTarget.granteeTenantName}”?` : 'Revoke grant?'}
246
+ title={
247
+ revokeTarget
248
+ ? `Revoke the grant to “${revokeTarget.granteeTenantName}”?`
249
+ : 'Revoke grant?'
250
+ }
228
251
  message="This immediately revokes the grant. The grantee tenant will lose access. This cannot be undone."
229
252
  confirmLabel="Revoke"
230
253
  destructive
@@ -33,7 +33,8 @@ function formatDate(ms) {
33
33
  }
34
34
 
35
35
  function ActionChips({ actions }) {
36
- if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
36
+ if (!actions || actions.length === 0)
37
+ return <span className="text-xs text-muted-foreground">—</span>;
37
38
  return (
38
39
  <div className="flex flex-wrap gap-1">
39
40
  {actions.map((a) => (
@@ -119,7 +120,11 @@ export default function InboxPage() {
119
120
  render: (row) => <span className="font-medium text-foreground">{row.owningTenantName}</span>,
120
121
  },
121
122
  { key: 'typeLabel', label: 'Object type' },
122
- { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
123
+ {
124
+ key: 'actions',
125
+ label: 'Actions',
126
+ render: (row) => <ActionChips actions={row.allowedActions} />,
127
+ },
123
128
  { key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
124
129
  { key: 'offeredByName', label: 'Offered by' },
125
130
  {
@@ -166,7 +171,11 @@ export default function InboxPage() {
166
171
 
167
172
  <ConfirmDialog
168
173
  open={!!declineTarget}
169
- title={declineTarget ? `Decline the offer from “${declineTarget.owningTenantName}”?` : 'Decline this offer?'}
174
+ title={
175
+ declineTarget
176
+ ? `Decline the offer from “${declineTarget.owningTenantName}”?`
177
+ : 'Decline this offer?'
178
+ }
170
179
  message="This revokes the offer. The offering tenant would need to offer again for you to reconsider. This cannot be undone."
171
180
  confirmLabel="Decline"
172
181
  destructive
@@ -119,19 +119,13 @@ export default function RolesPage() {
119
119
  load();
120
120
  }, [load]);
121
121
 
122
- const onSearchChange = useCallback(
123
- (search) => setQuery((q) => ({ ...q, search, page: 1 })),
124
- [],
125
- );
122
+ const onSearchChange = useCallback((search) => setQuery((q) => ({ ...q, search, page: 1 })), []);
126
123
  const onSortChange = useCallback(
127
124
  (sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
128
125
  [],
129
126
  );
130
127
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
131
- const onLimitChange = useCallback(
132
- (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
133
- [],
134
- );
128
+ const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
135
129
 
136
130
  const onConfirmDelete = async () => {
137
131
  if (!deleteTarget) return;
@@ -194,7 +188,9 @@ export default function RolesPage() {
194
188
 
195
189
  <ConfirmDialog
196
190
  open={!!deleteTarget}
197
- title={deleteTarget ? `Delete “${deleteTarget.fields?.name || 'this role'}”?` : 'Delete role?'}
191
+ title={
192
+ deleteTarget ? `Delete “${deleteTarget.fields?.name || 'this role'}”?` : 'Delete role?'
193
+ }
198
194
  message="This permanently removes the role. Users currently assigned it will lose the grant. This cannot be undone."
199
195
  confirmLabel="Delete"
200
196
  destructive
@@ -21,15 +21,32 @@ import PageHeader from '@/components/PageHeader';
21
21
  import EntityList, { EntityListControls } from '@/components/EntityList';
22
22
  import ConfirmDialog from '@/components/ConfirmDialog';
23
23
  import { Badge } from '@/components/ui/badge';
24
+ import { Label } from '@/components/ui/label';
25
+ import {
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ } from '@/components/ui/select';
24
32
  import ServiceAccountActionsMenu from './ServiceAccountActionsMenu';
25
33
  import ServiceAccountAccessDialog from './ServiceAccountAccessDialog';
26
34
  import KeyRevealDialog from './KeyRevealDialog';
27
- import { summarizeNames } from './accessModel';
35
+ import {
36
+ summarizeNames,
37
+ KEY_EXPIRY_CHOICES,
38
+ DEFAULT_KEY_EXPIRY,
39
+ keyExpiryRequest,
40
+ } from './accessModel';
28
41
 
29
42
  function formatDate(ms) {
30
43
  if (!ms) return '—';
31
44
  try {
32
- return new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
45
+ return new Date(ms).toLocaleDateString(undefined, {
46
+ day: 'numeric',
47
+ month: 'short',
48
+ year: 'numeric',
49
+ });
33
50
  } catch {
34
51
  return '—';
35
52
  }
@@ -49,8 +66,9 @@ export default function ServiceAccountsPage() {
49
66
 
50
67
  // One-time key reveal + destructive-confirm state.
51
68
  const [issuedKey, setIssuedKey] = useState(null);
52
- const [pending, setPending] = useState(null); // { kind: 'rotate'|'revoke'|'toggle', account }
69
+ const [pending, setPending] = useState(null); // { kind: 'issue'|'rotate'|'revoke'|'toggle', account }
53
70
  const [pendingBusy, setPendingBusy] = useState(false);
71
+ const [expiryChoice, setExpiryChoice] = useState(DEFAULT_KEY_EXPIRY);
54
72
 
55
73
  const load = useCallback(async () => {
56
74
  if (!token) return;
@@ -89,13 +107,10 @@ export default function ServiceAccountsPage() {
89
107
  await load();
90
108
  };
91
109
 
92
- const onIssueKey = async (account) => {
93
- const r = await serviceAccounts.issueKey(token, account.id);
94
- if (!r.ok) {
95
- toast.error(r.error || 'Could not issue a key.');
96
- return;
97
- }
98
- setIssuedKey(r.key); // reveal ONCE
110
+ // Issue + rotate both ask when the key expires (the server requires a choice; ADR-0033 §4).
111
+ const openPending = (kind, account) => {
112
+ setExpiryChoice(DEFAULT_KEY_EXPIRY);
113
+ setPending({ kind, account });
99
114
  };
100
115
 
101
116
  const confirmPending = async () => {
@@ -103,15 +118,25 @@ export default function ServiceAccountsPage() {
103
118
  setPendingBusy(true);
104
119
  const { kind, account } = pending;
105
120
  let r;
106
- if (kind === 'rotate') r = await serviceAccounts.rotateKey(token, account.id);
121
+ if (kind === 'issue')
122
+ r = await serviceAccounts.issueKey(token, account.id, keyExpiryRequest(expiryChoice));
123
+ else if (kind === 'rotate')
124
+ r = await serviceAccounts.rotateKey(token, account.id, keyExpiryRequest(expiryChoice));
107
125
  else if (kind === 'revoke') r = await serviceAccounts.revokeKeys(token, account.id);
108
- else r = await serviceAccounts.update(token, account.id, { status: account.status === 'disabled' ? 'active' : 'disabled' });
126
+ else
127
+ r = await serviceAccounts.update(token, account.id, {
128
+ status: account.status === 'disabled' ? 'active' : 'disabled',
129
+ });
109
130
  setPendingBusy(false);
110
131
  setPending(null);
111
132
  if (!r.ok) {
112
133
  toast.error(r.error || 'The action could not be completed.');
113
134
  return;
114
135
  }
136
+ if (kind === 'issue') {
137
+ setIssuedKey(r.key); // reveal ONCE
138
+ return;
139
+ }
115
140
  if (kind === 'rotate') {
116
141
  setIssuedKey(r.key); // reveal the NEW key ONCE
117
142
  toast.success('Key rotated — the previous key no longer works.');
@@ -145,7 +170,11 @@ export default function ServiceAccountsPage() {
145
170
  const { shown, more } = summarizeNames(row.fields.roleLabels);
146
171
  if (shown.length === 0) {
147
172
  return (
148
- <Badge variant="outline" className="text-muted-foreground" title="Denied everything until it has a role">
173
+ <Badge
174
+ variant="outline"
175
+ className="text-muted-foreground"
176
+ title="Denied everything until it has a role"
177
+ >
149
178
  No roles
150
179
  </Badge>
151
180
  );
@@ -186,7 +215,9 @@ export default function ServiceAccountsPage() {
186
215
  label: 'Status',
187
216
  render: (row) =>
188
217
  row.fields.status === 'disabled' ? (
189
- <Badge variant="outline" className="text-muted-foreground">Disabled</Badge>
218
+ <Badge variant="outline" className="text-muted-foreground">
219
+ Disabled
220
+ </Badge>
190
221
  ) : (
191
222
  <Badge>Active</Badge>
192
223
  ),
@@ -209,8 +240,8 @@ export default function ServiceAccountsPage() {
209
240
  <ServiceAccountActionsMenu
210
241
  account={row.account}
211
242
  onEditAccess={(a) => setAccessDialog({ account: a })}
212
- onIssueKey={onIssueKey}
213
- onRotateKey={(a) => setPending({ kind: 'rotate', account: a })}
243
+ onIssueKey={(a) => openPending('issue', a)}
244
+ onRotateKey={(a) => openPending('rotate', a)}
214
245
  onToggleStatus={(a) => setPending({ kind: 'toggle', account: a })}
215
246
  onRevokeKeys={(a) => setPending({ kind: 'revoke', account: a })}
216
247
  />
@@ -222,6 +253,14 @@ export default function ServiceAccountsPage() {
222
253
  const confirmCopy = (() => {
223
254
  if (!pending) return { title: '', message: '', confirmLabel: 'Confirm', destructive: false };
224
255
  const name = pending.account.label;
256
+ if (pending.kind === 'issue') {
257
+ return {
258
+ title: `Issue a key for “${name}”?`,
259
+ message: 'The new key is shown once. Choose when it stops working.',
260
+ confirmLabel: 'Issue key',
261
+ destructive: false,
262
+ };
263
+ }
225
264
  if (pending.kind === 'rotate') {
226
265
  return {
227
266
  title: `Rotate the key for “${name}”?`,
@@ -293,7 +332,7 @@ export default function ServiceAccountsPage() {
293
332
  {/* One-time key reveal (issue + rotate) */}
294
333
  <KeyRevealDialog issued={issuedKey} onClose={() => setIssuedKey(null)} />
295
334
 
296
- {/* Destructive confirms: rotate / revoke / disable */}
335
+ {/* Confirms: issue / rotate (with the key's expiry) / revoke / disable */}
297
336
  <ConfirmDialog
298
337
  open={!!pending}
299
338
  title={confirmCopy.title}
@@ -303,7 +342,27 @@ export default function ServiceAccountsPage() {
303
342
  busy={pendingBusy}
304
343
  onConfirm={confirmPending}
305
344
  onCancel={() => setPending(null)}
306
- />
345
+ >
346
+ {(pending?.kind === 'issue' || pending?.kind === 'rotate') && (
347
+ <div className="space-y-1.5">
348
+ <Label htmlFor="key-expiry" variant="field">
349
+ Key expires
350
+ </Label>
351
+ <Select value={expiryChoice} onValueChange={setExpiryChoice} disabled={pendingBusy}>
352
+ <SelectTrigger id="key-expiry" className="w-full">
353
+ <SelectValue />
354
+ </SelectTrigger>
355
+ <SelectContent>
356
+ {KEY_EXPIRY_CHOICES.map((c) => (
357
+ <SelectItem key={c.value} value={c.value}>
358
+ {c.label}
359
+ </SelectItem>
360
+ ))}
361
+ </SelectContent>
362
+ </Select>
363
+ </div>
364
+ )}
365
+ </ConfirmDialog>
307
366
  </AppShell>
308
367
  );
309
368
  }
@@ -134,3 +134,25 @@ export function summarizeNames(names, max = 3) {
134
134
  const list = names || [];
135
135
  return { shown: list.slice(0, max), more: Math.max(0, list.length - max) };
136
136
  }
137
+
138
+ const DAY_MS = 24 * 60 * 60 * 1000;
139
+
140
+ /**
141
+ * Key expiry choices for issue/rotate. The server REQUIRES a choice (ADR-0033 §4): a future
142
+ * `expiresAtMs`, or an explicit `neverExpires: true` — a non-expiring key is never the default.
143
+ */
144
+ export const KEY_EXPIRY_CHOICES = [
145
+ { value: '30', label: '30 days' },
146
+ { value: '90', label: '90 days' },
147
+ { value: '365', label: '1 year' },
148
+ { value: 'never', label: 'Never expires (not recommended)' },
149
+ ];
150
+ export const DEFAULT_KEY_EXPIRY = '90';
151
+
152
+ /** The issue/rotate request body for a choice; an unknown choice uses the default. */
153
+ export function keyExpiryRequest(choice, nowMs = Date.now()) {
154
+ if (choice === 'never') return { neverExpires: true };
155
+ const known = KEY_EXPIRY_CHOICES.some((c) => c.value === choice && c.value !== 'never');
156
+ const days = Number(known ? choice : DEFAULT_KEY_EXPIRY);
157
+ return { expiresAtMs: nowMs + days * DAY_MS };
158
+ }
@@ -17,6 +17,9 @@ import {
17
17
  fieldOfServerError,
18
18
  summarizeNames,
19
19
  SCOPE_REQUIRED,
20
+ KEY_EXPIRY_CHOICES,
21
+ DEFAULT_KEY_EXPIRY,
22
+ keyExpiryRequest,
20
23
  } from './accessModel.js';
21
24
 
22
25
  const ASSIGNABLE = {
@@ -159,3 +162,28 @@ describe('misc', () => {
159
162
  assert.deepEqual(summarizeNames(undefined), { shown: [], more: 0 });
160
163
  });
161
164
  });
165
+
166
+ describe('key expiry (the server requires an expiry OR an explicit neverExpires)', () => {
167
+ const NOW = Date.UTC(2026, 0, 1);
168
+ const DAY = 24 * 60 * 60 * 1000;
169
+
170
+ test('every choice maps to exactly one of expiresAtMs (future) / neverExpires', () => {
171
+ for (const c of KEY_EXPIRY_CHOICES) {
172
+ const body = keyExpiryRequest(c.value, NOW);
173
+ const keys = Object.keys(body);
174
+ assert.equal(keys.length, 1, c.value);
175
+ if ('expiresAtMs' in body) assert.ok(body.expiresAtMs > NOW, c.value);
176
+ else assert.deepEqual(body, { neverExpires: true });
177
+ }
178
+ });
179
+
180
+ test('day choices land N days ahead; the default is a finite expiry', () => {
181
+ assert.deepEqual(keyExpiryRequest('30', NOW), { expiresAtMs: NOW + 30 * DAY });
182
+ assert.deepEqual(keyExpiryRequest('never', NOW), { neverExpires: true });
183
+ assert.ok('expiresAtMs' in keyExpiryRequest(DEFAULT_KEY_EXPIRY, NOW));
184
+ });
185
+
186
+ test('an unknown choice falls back to the default (never silently non-expiring)', () => {
187
+ assert.deepEqual(keyExpiryRequest('bogus', NOW), keyExpiryRequest(DEFAULT_KEY_EXPIRY, NOW));
188
+ });
189
+ });
@@ -185,7 +185,9 @@ export default function MembershipCard({
185
185
  size="sm"
186
186
  onClick={onToggleActive}
187
187
  disabled={isActive || activating || saving}
188
- title={isActive ? 'This is the active login company' : 'Make this the active login company'}
188
+ title={
189
+ isActive ? 'This is the active login company' : 'Make this the active login company'
190
+ }
189
191
  >
190
192
  {activating ? (
191
193
  <Loader2 className="size-4 animate-spin" />
@@ -115,20 +115,14 @@ export default function PostingRow({
115
115
 
116
116
  // Department / Branch: small, per-company catalogs (passed in). The SAME control in
117
117
  // client-filter mode — `fetchPage` returns the whole list once; the panel filters locally.
118
- const fetchDepartments = useCallback(
119
- async () => {
120
- const options = (departments || []).map((d) => ({ value: d.uuid, label: d.name }));
121
- return { options, total: options.length, page: 1 };
122
- },
123
- [departments],
124
- );
125
- const fetchBranches = useCallback(
126
- async () => {
127
- const options = (branches || []).map((b) => ({ value: b.uuid, label: b.name }));
128
- return { options, total: options.length, page: 1 };
129
- },
130
- [branches],
131
- );
118
+ const fetchDepartments = useCallback(async () => {
119
+ const options = (departments || []).map((d) => ({ value: d.uuid, label: d.name }));
120
+ return { options, total: options.length, page: 1 };
121
+ }, [departments]);
122
+ const fetchBranches = useCallback(async () => {
123
+ const options = (branches || []).map((b) => ({ value: b.uuid, label: b.name }));
124
+ return { options, total: options.length, page: 1 };
125
+ }, [branches]);
132
126
 
133
127
  // Shared field controls — reused by both the desktop row and the mobile card.
134
128
  const roleSelect = (
@@ -144,19 +144,13 @@ export default function UsersPage() {
144
144
  load();
145
145
  }, [load]);
146
146
 
147
- const onSearchChange = useCallback(
148
- (search) => setQuery((q) => ({ ...q, search, page: 1 })),
149
- [],
150
- );
147
+ const onSearchChange = useCallback((search) => setQuery((q) => ({ ...q, search, page: 1 })), []);
151
148
  const onSortChange = useCallback(
152
149
  (sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
153
150
  [],
154
151
  );
155
152
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
156
- const onLimitChange = useCallback(
157
- (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
158
- [],
159
- );
153
+ const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
160
154
 
161
155
  const onConfirmDelete = async () => {
162
156
  if (!deleteTarget) return;
@@ -219,7 +213,11 @@ export default function UsersPage() {
219
213
 
220
214
  <ConfirmDialog
221
215
  open={!!deleteTarget}
222
- title={deleteTarget ? `Delete “${deleteTarget.fields?.username || 'this user'}”?` : 'Delete user?'}
216
+ title={
217
+ deleteTarget
218
+ ? `Delete “${deleteTarget.fields?.username || 'this user'}”?`
219
+ : 'Delete user?'
220
+ }
223
221
  message="This permanently removes the user and all their company memberships. This cannot be undone."
224
222
  confirmLabel="Delete"
225
223
  destructive
@@ -3,6 +3,11 @@
3
3
  * contract. Uses the browser `fetch`, same-origin `/api` (proxied by Vite to the
4
4
  * server's /auth/* routes). A mobile app provides its OWN adapter (RN fetch + its
5
5
  * base URL) implementing the same shape; the shared logic is identical for both.
6
+ *
7
+ * This is the RAW transport: it does not refresh tokens. AuthContext wraps it with
8
+ * @astram/frontend-core's `createRefreshingHttp` (single-flight refresh on a 401, one retry,
9
+ * cross-tab lock) and hands only the wrapped client to the services and the shield
10
+ * transport. Use the raw client directly only for the refresh call itself.
6
11
  */
7
12
 
8
13
  const BASE = '/api';