create-astram-app 0.5.0 → 0.7.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/README.md +256 -38
- package/dist/add-frontend.js.map +1 -1
- package/dist/index.js +14 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/template/README.md +51 -20
- package/template/frontend/README.md +21 -11
- package/template/frontend/index.html +16 -0
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +82 -20
- package/template/frontend/src/auth/AuthContext.jsx +141 -21
- package/template/frontend/src/auth/routeGate.js +31 -0
- package/template/frontend/src/auth/routeGate.test.mjs +59 -0
- package/template/frontend/src/auth/sessionFlow.js +27 -0
- package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
- package/template/frontend/src/components/EntityList.jsx +1 -7
- package/template/frontend/src/components/PageHeader.jsx +5 -4
- package/template/frontend/src/components/ShareDialog.jsx +3 -5
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
- package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
- package/template/frontend/src/components/ui/alert.tsx +17 -23
- package/template/frontend/src/components/ui/avatar.tsx +8 -20
- package/template/frontend/src/components/ui/badge.tsx +17 -26
- package/template/frontend/src/components/ui/button.tsx +22 -24
- package/template/frontend/src/components/ui/card.tsx +19 -36
- package/template/frontend/src/components/ui/checkbox.tsx +7 -10
- package/template/frontend/src/components/ui/date-field.tsx +26 -27
- package/template/frontend/src/components/ui/dialog.tsx +24 -40
- package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
- package/template/frontend/src/components/ui/input-otp.tsx +1 -4
- package/template/frontend/src/components/ui/label.tsx +10 -12
- package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
- package/template/frontend/src/components/ui/select.tsx +31 -43
- package/template/frontend/src/components/ui/separator.tsx +6 -6
- package/template/frontend/src/components/ui/sheet.tsx +29 -38
- package/template/frontend/src/components/ui/skeleton.tsx +3 -3
- package/template/frontend/src/components/ui/sonner.tsx +7 -7
- package/template/frontend/src/components/ui/table.tsx +56 -70
- package/template/frontend/src/components/ui/tooltip.tsx +7 -11
- package/template/frontend/src/config/branding.js +1 -4
- package/template/frontend/src/hooks/useMobile.tsx +4 -6
- package/template/frontend/src/index.css +5 -2
- package/template/frontend/src/pages/Dashboard.jsx +13 -7
- package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
- package/template/frontend/src/pages/Login.jsx +85 -70
- package/template/frontend/src/pages/ResetPassword.jsx +9 -2
- package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
- package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
- package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
- package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
- package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
- package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +77 -18
- package/template/frontend/src/pages/service-accounts/accessModel.js +22 -0
- package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +28 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
- package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
- package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
- package/template/frontend/src/platform/httpClient.js +18 -0
- package/template/frontend/src/platform/httpClient.test.mjs +60 -0
- package/template/frontend/src/platform/sessionStore.js +55 -13
- package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
- package/template/frontend/src/platform/shieldTransport.js +6 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
- package/template/frontend/src/shell/AppShell.jsx +10 -12
- package/template/frontend/src/shell/Sidebar.jsx +15 -3
- package/template/frontend/src/shell/nav.js +1 -3
- package/template/frontend/vite.config.js +19 -1
- package/template/server/_env.example +22 -1
- package/template/server/eslint.config.mjs +314 -0
- package/template/server/package.json +11 -11
- package/template/server/scripts/dev.mjs +17 -6
- package/template/server/scripts/sync-astram-guide.mjs +45 -19
- package/template/server/src/app.module.ts +16 -7
- package/template/server/src/health/demo.controller.ts +16 -12
- package/template/server/src/main.ts +23 -1
- 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
|
|
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={
|
|
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, {
|
|
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) => ({
|
|
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, {
|
|
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) => ({
|
|
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, {
|
|
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) => ({
|
|
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
|
|
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">
|
|
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)
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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">
|
|
219
|
+
<div className="mb-2 text-sm font-medium text-foreground">
|
|
220
|
+
Outgoing — grants you'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={
|
|
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)
|
|
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
|
-
{
|
|
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={
|
|
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={
|
|
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 {
|
|
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, {
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 === '
|
|
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
|
|
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
|
|
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">
|
|
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={
|
|
213
|
-
onRotateKey={(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
|
-
{/*
|
|
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={
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
[
|
|
124
|
-
|
|
125
|
-
|
|
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={
|
|
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
|