portal-mojo 0.2.2 → 0.2.4
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 +3 -0
- package/docs/DetailView.md +19 -15
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +2 -1
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +24 -6
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +70 -1
- package/docs/forms.md +51 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +18 -3
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +67 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +11 -5
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +90 -6
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/detail/MetadataSection.tsx +46 -10
- package/src/ui/form-autosave.ts +134 -23
- package/src/ui/guardrail.tsx +125 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +8 -4
- package/src/ui/toast.tsx +48 -5
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useState } from 'react';
|
|
3
3
|
import { useCan, type PermSpec } from '../../client/runtime';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Badge,
|
|
6
|
+
DetailView,
|
|
7
|
+
Eyebrow,
|
|
8
|
+
FlatRow,
|
|
9
|
+
ModelTable,
|
|
10
|
+
SchemaForm,
|
|
11
|
+
confirmGuardrail,
|
|
12
|
+
fmt,
|
|
13
|
+
modal,
|
|
14
|
+
toast,
|
|
15
|
+
type Column,
|
|
16
|
+
type Field,
|
|
17
|
+
type FilterDef,
|
|
8
18
|
} from '../../ui';
|
|
9
19
|
import {
|
|
10
|
-
GLOBAL_CREDENTIAL_PERMS,
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
GLOBAL_CREDENTIAL_PERMS,
|
|
21
|
+
GROUP_CREDENTIAL_PERMS,
|
|
22
|
+
WebhookSubscriptionModel,
|
|
23
|
+
fetchWebhookSecret,
|
|
24
|
+
normalizeWebhookEvents,
|
|
25
|
+
type CredentialGroup,
|
|
26
|
+
type WebhookSecretInfo,
|
|
27
|
+
type WebhookSubscriptionRow,
|
|
13
28
|
} from './models';
|
|
14
29
|
import { showSecretDialog } from './secret-dialog';
|
|
15
30
|
|
|
@@ -121,9 +136,26 @@ const WEBHOOK_FIELDS: Field[] = [
|
|
|
121
136
|
{ name: 'is_active', type: 'switch', label: 'Active', help: 'Inactive subscriptions are skipped.' },
|
|
122
137
|
];
|
|
123
138
|
|
|
139
|
+
/** The stop in front of `is_active: false` — copy states what the backend does. */
|
|
140
|
+
function confirmDisableSubscription(row: WebhookSubscriptionRow): Promise<boolean> {
|
|
141
|
+
let host = row.url;
|
|
142
|
+
try { host = new URL(row.url).host || row.url; } catch { /* keep the raw url */ }
|
|
143
|
+
const events = row.events.length ? row.events.join(', ') : 'all events';
|
|
144
|
+
return confirmGuardrail({
|
|
145
|
+
title: `Disable webhook to ${host}?`,
|
|
146
|
+
effect: <>Deliveries of <b>{events}</b> to <b>{row.url}</b> stop the moment this saves.</>,
|
|
147
|
+
why: [
|
|
148
|
+
<>Deliveries stop for these events and nothing re-registers them — the subscription stays off until someone switches it back on here.</>,
|
|
149
|
+
<>Consumers see <b>silence, not an error</b>: the receiver gets no failed delivery and no notice, so anything downstream that depends on these events degrades quietly.</>,
|
|
150
|
+
],
|
|
151
|
+
undo: 'Re-enable it here to resume deliveries for new events.',
|
|
152
|
+
confirmText: 'Disable webhook',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
124
156
|
function useWebhookActions(permission: PermSpec) {
|
|
125
157
|
const save = WebhookSubscriptionModel.useSave();
|
|
126
|
-
|
|
158
|
+
|
|
127
159
|
const { can } = useCan(permission);
|
|
128
160
|
|
|
129
161
|
const createSubscription = async (fixedGroup?: CredentialGroup) => {
|
|
@@ -192,6 +224,7 @@ function useWebhookActions(permission: PermSpec) {
|
|
|
192
224
|
if (events.join('\u0000') !== row.events.join('\u0000')) changes.events = events;
|
|
193
225
|
if (isActive !== row.is_active) changes.is_active = isActive;
|
|
194
226
|
if (!Object.keys(changes).length) return;
|
|
227
|
+
if (changes.is_active === false && !(await confirmDisableSubscription(row))) return;
|
|
195
228
|
try {
|
|
196
229
|
await save.mutateAsync({ id: row.id, changes });
|
|
197
230
|
toast.success('Webhook subscription updated');
|
|
@@ -202,6 +235,8 @@ function useWebhookActions(permission: PermSpec) {
|
|
|
202
235
|
|
|
203
236
|
const toggleSubscription = async (row: WebhookSubscriptionRow, next: boolean) => {
|
|
204
237
|
if (!can) return;
|
|
238
|
+
// Only the OFF direction is guarded — re-enabling restores deliveries.
|
|
239
|
+
if (!next && !(await confirmDisableSubscription(row))) return;
|
|
205
240
|
try {
|
|
206
241
|
await save.mutateAsync({ id: row.id, changes: { is_active: next } });
|
|
207
242
|
} catch (error) {
|
|
@@ -209,19 +244,9 @@ function useWebhookActions(permission: PermSpec) {
|
|
|
209
244
|
}
|
|
210
245
|
};
|
|
211
246
|
|
|
212
|
-
const deleteSubscription = async (row: WebhookSubscriptionRow) => {
|
|
213
|
-
if (!can) return false;
|
|
214
|
-
try {
|
|
215
|
-
await destroy.mutateAsync({ id: row.id });
|
|
216
|
-
toast.success('Webhook subscription deleted');
|
|
217
|
-
return true;
|
|
218
|
-
} catch (error) {
|
|
219
|
-
toast.error(error instanceof Error ? error.message : 'Failed to delete subscription');
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
247
|
|
|
224
|
-
|
|
248
|
+
|
|
249
|
+
return { canManage: can, createSubscription, editSubscription, toggleSubscription, pending: save.isPending };
|
|
225
250
|
}
|
|
226
251
|
|
|
227
252
|
function WebhookCard({ row, actions }: {
|
|
@@ -271,13 +296,7 @@ function WebhookCard({ row, actions }: {
|
|
|
271
296
|
}}
|
|
272
297
|
/>
|
|
273
298
|
</label>
|
|
274
|
-
|
|
275
|
-
className="btn-compact"
|
|
276
|
-
label={<i className="bi bi-trash" aria-label="Delete this subscription" />}
|
|
277
|
-
armedLabel="Click again — deliveries stop"
|
|
278
|
-
title="Delete this subscription"
|
|
279
|
-
onConfirm={async () => { await actions.deleteSubscription(row); }}
|
|
280
|
-
/>
|
|
299
|
+
|
|
281
300
|
</div>
|
|
282
301
|
)}
|
|
283
302
|
</div>
|
|
@@ -333,6 +352,8 @@ export function WebhookSubscriptionDetail({ id, onClose }: { id: number; onClose
|
|
|
333
352
|
title={row.url}
|
|
334
353
|
subtitle={`${groupLabel(row.group)} · webhook subscription`}
|
|
335
354
|
chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }]}
|
|
355
|
+
active={actions.canManage ? { value: row.is_active, disabled: actions.pending, onChange: next => void actions.toggleSubscription(row, next) } : undefined}
|
|
356
|
+
contextMenu={actions.canManage ? [{ label: 'Edit subscription', onSelect: () => void actions.editSubscription(row) }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: actions.pending, onSelect: () => void actions.toggleSubscription(row, !row.is_active) }] : []}
|
|
336
357
|
sections={[
|
|
337
358
|
{
|
|
338
359
|
key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
@@ -342,19 +363,7 @@ export function WebhookSubscriptionDetail({ id, onClose }: { id: number; onClose
|
|
|
342
363
|
<FlatRow label="URL"><code>{row.url}</code></FlatRow>
|
|
343
364
|
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
344
365
|
<FlatRow label="Modified">{fmt.datetime(row.modified)}</FlatRow>
|
|
345
|
-
|
|
346
|
-
<button className="btn btn-compact" onClick={() => void actions.editSubscription(row)}>
|
|
347
|
-
<i className="bi bi-pencil" /> Edit URL, events, and status
|
|
348
|
-
</button>
|
|
349
|
-
<ArmedButton
|
|
350
|
-
className="btn-compact"
|
|
351
|
-
label="Delete"
|
|
352
|
-
armedLabel="Click again — delete now"
|
|
353
|
-
onConfirm={async () => {
|
|
354
|
-
if (await actions.deleteSubscription(row)) onClose();
|
|
355
|
-
}}
|
|
356
|
-
/>
|
|
357
|
-
</div>
|
|
366
|
+
|
|
358
367
|
</>
|
|
359
368
|
),
|
|
360
369
|
},
|
|
@@ -84,7 +84,7 @@ function LoginOverview() {
|
|
|
84
84
|
|
|
85
85
|
export function AdminDashboardPage() {
|
|
86
86
|
return <div className="admin-dashboard">
|
|
87
|
-
<header className="admin-dashboard-header"><
|
|
87
|
+
<header className="admin-dashboard-header"><h1>Dashboard</h1><p className="dim">Authoritative operational signals for this django-mojo deployment.</p></header>
|
|
88
88
|
<MetricOverview />
|
|
89
89
|
<AttentionOverview />
|
|
90
90
|
<LoginOverview />
|
|
@@ -49,9 +49,21 @@ export function CertificateLifecyclePoller({ domain, intervalMs = CERTIFICATE_PO
|
|
|
49
49
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
50
50
|
const [version, setVersion] = useState(0);
|
|
51
51
|
|
|
52
|
-
useEffect(() =>
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let mounted = true;
|
|
54
|
+
let queued = false;
|
|
55
|
+
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
|
56
|
+
if (event.query.queryKey[0] !== CertificateModel.endpoint || queued) return;
|
|
57
|
+
// Observer registration can notify while a detail is rendering.
|
|
58
|
+
// Coalesce cache notifications after that render, including StrictMode replay.
|
|
59
|
+
queued = true;
|
|
60
|
+
queueMicrotask(() => {
|
|
61
|
+
queued = false;
|
|
62
|
+
if (mounted) setVersion((value) => value + 1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
return () => { mounted = false; unsubscribe(); };
|
|
66
|
+
}, [queryClient]);
|
|
55
67
|
|
|
56
68
|
useEffect(() => {
|
|
57
69
|
const rowsById = new Map<number, CertificateRow>();
|
|
@@ -1,18 +1,34 @@
|
|
|
1
|
-
import { useRef, useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
3
|
import { useCan } from '../../client/runtime';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Badge,
|
|
6
|
+
CollectionSelect,
|
|
7
|
+
DetailView,
|
|
8
|
+
FlatRow,
|
|
9
|
+
ModelTable,
|
|
10
|
+
fmt,
|
|
11
|
+
modal,
|
|
12
|
+
toast,
|
|
13
|
+
type Column,
|
|
14
|
+
type FilterDef,
|
|
15
|
+
type Tone,
|
|
7
16
|
} from '../../ui';
|
|
8
17
|
import {
|
|
9
|
-
DNS_GROUP_CHOICE_ENDPOINT,
|
|
10
|
-
|
|
18
|
+
DNS_GROUP_CHOICE_ENDPOINT,
|
|
19
|
+
invalidateDnsCredentials,
|
|
20
|
+
linkDnsCredential,
|
|
21
|
+
rotateDnsCredential,
|
|
22
|
+
useDnsCapabilities,
|
|
11
23
|
} from './api';
|
|
12
24
|
import { linkableProviders, providerLabel } from './data';
|
|
13
25
|
import {
|
|
14
|
-
DNS_MANAGE_PERMISSIONS,
|
|
15
|
-
|
|
26
|
+
DNS_MANAGE_PERMISSIONS,
|
|
27
|
+
DNS_VIEW_PERMISSIONS,
|
|
28
|
+
DnsCredentialModel,
|
|
29
|
+
type DnsCapabilities,
|
|
30
|
+
type DnsCredentialRow,
|
|
31
|
+
type DnsGroupChoice,
|
|
16
32
|
} from './models';
|
|
17
33
|
|
|
18
34
|
function groupId(group: DnsCredentialRow['group']): number | null {
|
|
@@ -154,7 +170,7 @@ function CredentialDetail({ id, caps, close }: { id: number; caps: DnsCapabiliti
|
|
|
154
170
|
const { data: row, isPending, error } = DnsCredentialModel.useOne(id);
|
|
155
171
|
const { can: canManage } = useCan(DNS_MANAGE_PERMISSIONS);
|
|
156
172
|
const save = DnsCredentialModel.useSave();
|
|
157
|
-
|
|
173
|
+
|
|
158
174
|
if (isPending) return <div className="modal-pad dim">Loading credential…</div>;
|
|
159
175
|
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'Credential not found'}</div>;
|
|
160
176
|
|
|
@@ -167,26 +183,13 @@ function CredentialDetail({ id, caps, close }: { id: number; caps: DnsCapabiliti
|
|
|
167
183
|
}
|
|
168
184
|
};
|
|
169
185
|
|
|
170
|
-
const remove = async () => {
|
|
171
|
-
try {
|
|
172
|
-
await destroy.mutateAsync({ id: row.id });
|
|
173
|
-
toast.success('Credential deleted');
|
|
174
|
-
close();
|
|
175
|
-
} catch (reason) {
|
|
176
|
-
toast.error(reason instanceof Error ? reason.message : 'Credential could not be deleted');
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
186
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge>
|
|
187
|
-
<Badge tone="warning">{providerLabel(row.provider)}</Badge>
|
|
188
|
-
</div>
|
|
189
|
-
<FlatRow label="Group">{groupLabel(row.group)}</FlatRow>
|
|
187
|
+
|
|
188
|
+
return <DetailView title={row.name || `Credential #${row.id}`} subtitle={providerLabel(row.provider)} icon="bi-key" onClose={close}
|
|
189
|
+
chips={[{ text: row.verified ? 'Verified' : 'Unverified', tone: verificationTone(row) }, { text: row.is_active ? 'Active' : 'Retired', tone: row.is_active ? 'success' : 'muted' }]}
|
|
190
|
+
active={canManage ? { value: row.is_active, disabled: save.isPending, onChange: () => void toggle() } : undefined}
|
|
191
|
+
contextMenu={canManage ? [{ label: 'Rotate credentials…', onSelect: () => void showCredentialEditor(caps, row) }, { label: row.is_active ? 'Retire' : 'Reactivate', disabled: save.isPending, onSelect: () => void toggle() }] : []}
|
|
192
|
+
sections={[{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"> <FlatRow label="Group">{groupLabel(row.group)}</FlatRow>
|
|
190
193
|
<FlatRow label="API key"><code>{row.api_key_masked || '—'}</code></FlatRow>
|
|
191
194
|
<FlatRow label="API secret"><code>{row.api_secret_masked || '—'}</code></FlatRow>
|
|
192
195
|
<FlatRow label="Domains at verification">{row.domain_count}</FlatRow>
|
|
@@ -196,26 +199,7 @@ function CredentialDetail({ id, caps, close }: { id: number; caps: DnsCapabiliti
|
|
|
196
199
|
<p className="dim">
|
|
197
200
|
The linked-domain count is a health signal only. Provider account domains are never enumerated here.
|
|
198
201
|
</p>
|
|
199
|
-
|
|
200
|
-
<div className="modal-actions">
|
|
201
|
-
<button type="button" className="btn" onClick={() => void showCredentialEditor(caps, row)}>
|
|
202
|
-
<i className="bi bi-arrow-repeat" /> Rotate
|
|
203
|
-
</button>
|
|
204
|
-
<button type="button" className="btn" disabled={save.isPending} onClick={() => void toggle()}>
|
|
205
|
-
<i className={`bi ${row.is_active ? 'bi-pause-circle' : 'bi-play-circle'}`} /> {row.is_active ? 'Retire' : 'Activate'}
|
|
206
|
-
</button>
|
|
207
|
-
<ArmedButton
|
|
208
|
-
label="Delete"
|
|
209
|
-
armedLabel="Click again — linked domains lose provider-backed DNS"
|
|
210
|
-
disabled={destroy.isPending}
|
|
211
|
-
onConfirm={remove}
|
|
212
|
-
/>
|
|
213
|
-
<button type="button" className="btn" onClick={close}>Close</button>
|
|
214
|
-
</div>
|
|
215
|
-
)}
|
|
216
|
-
{!canManage && <div className="modal-actions"><button type="button" className="btn" onClick={close}>Close</button></div>}
|
|
217
|
-
</div>
|
|
218
|
-
);
|
|
202
|
+
</div> }]} />;
|
|
219
203
|
}
|
|
220
204
|
|
|
221
205
|
const COLUMNS: Column<DnsCredentialRow>[] = [
|
|
@@ -248,9 +232,9 @@ function CredentialsTable({ caps }: { caps: DnsCapabilities }) {
|
|
|
248
232
|
];
|
|
249
233
|
const openDetail = (row: DnsCredentialRow) => {
|
|
250
234
|
void DnsCredentialModel.fetchOne(queryClient, row.id).catch(() => undefined);
|
|
251
|
-
void modal.
|
|
235
|
+
void modal.detail((done) => (
|
|
252
236
|
<CredentialDetail id={row.id} caps={caps} close={() => done(null)} />
|
|
253
|
-
)
|
|
237
|
+
));
|
|
254
238
|
};
|
|
255
239
|
const canLink = canManage && linkableProviders(caps).length > 0;
|
|
256
240
|
return (
|
package/src/admin/dns/setup.ts
CHANGED
|
@@ -10,7 +10,7 @@ registerDnsAdminIntegration({
|
|
|
10
10
|
return resolveDnsDomainByName(normalizedName);
|
|
11
11
|
},
|
|
12
12
|
recordsHref: (domainId) => `dns/records?domain=${encodeURIComponent(domainId)}`,
|
|
13
|
-
...(usingMockTransport() ? {
|
|
13
|
+
...(import.meta.env.VITE_MOJO_PACKAGED_ADMIN !== '1' && usingMockTransport() ? {
|
|
14
14
|
applyManagedDnsRecords: async (domainId: number, records: readonly ManagedDnsRecordInput[]) => {
|
|
15
15
|
const { applyMockManagedDnsRecords } = await import('../../client/mock');
|
|
16
16
|
applyMockManagedDnsRecords(domainId, records);
|
|
@@ -9,7 +9,7 @@ import { SECURITY_VIEW_PERMS } from '../security-permissions';
|
|
|
9
9
|
|
|
10
10
|
export const ADMIN_DASHBOARD_PERMISSIONS = [...new Set([...DASHBOARD_METRIC_PERMISSIONS, ...SECURITY_VIEW_PERMS, ...JOBS_VIEW_PERMS, ...EMAIL_ADMIN_PERMISSIONS, ...LOGIN_SUMMARY_PERMS])];
|
|
11
11
|
export const DASHBOARD_ADMIN_SECTION: AdminSection = {
|
|
12
|
-
id: 'dashboard', basePath: '', title: '
|
|
12
|
+
id: 'dashboard', basePath: '', title: 'Dashboard', icon: 'bi-grid-1x2', navigationGroup: null, permissions: ADMIN_DASHBOARD_PERMISSIONS,
|
|
13
13
|
routes: [{ path: '', label: 'Dashboard', loadComponent: () => import('../dashboard/AdminDashboardPage').then(({ AdminDashboardPage }) => ({ default: AdminDashboardPage })), permissions: ADMIN_DASHBOARD_PERMISSIONS, fallbackToFirstVisible: true }],
|
|
14
14
|
};
|
|
15
15
|
export const MONITORING_ADMIN_SECTION: AdminSection = {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { useSyncExternalStore } from 'react';
|
|
1
|
+
import { useSyncExternalStore, type ReactNode } from 'react';
|
|
2
2
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
3
|
import { useCan, type PermSpec } from '../../../client/runtime';
|
|
4
4
|
import {
|
|
5
5
|
Badge, DetailView, Eyebrow, FlatRow, FormView, JsonBlock, MetricCard,
|
|
6
|
-
ModelTable, fmt, formModal, toast, type Column, type DetailMenuEntry,
|
|
6
|
+
ModelTable, confirmGuardrail, fmt, formModal, toast, type Column, type DetailMenuEntry,
|
|
7
7
|
} from '../../../ui';
|
|
8
8
|
import { LOGS_ADMIN_PERMISSIONS, LogModel, type LogRow } from '../../monitoring';
|
|
9
9
|
import {
|
|
@@ -51,6 +51,43 @@ function AuditTable({ memberId }: { memberId: number }) {
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The two grants that let a member reach past its own row. A switch flip
|
|
56
|
+
* autosaves with no save button, so the stop lives in FormView's
|
|
57
|
+
* `beforeSave`: only these keys, only the ON direction — everything else
|
|
58
|
+
* stays one click. (`admin` is not an editable member grant here —
|
|
59
|
+
* `isEditableMemberPermission` excludes it — so it cannot be flipped from
|
|
60
|
+
* this form and needs no guard.)
|
|
61
|
+
*/
|
|
62
|
+
const ESCALATING_GRANTS: Record<string, { label: string; why: ReactNode }> = {
|
|
63
|
+
manage_group: {
|
|
64
|
+
label: 'Manage Group',
|
|
65
|
+
why: <><code>manage_group</code> unlocks the group record itself — its settings and policy metadata, its API keys, webhook subscriptions and integrations — every write the group's credential and identity panels gate on.</>,
|
|
66
|
+
},
|
|
67
|
+
manage_members: {
|
|
68
|
+
label: 'Manage Members',
|
|
69
|
+
why: <><code>manage_members</code> lets the holder change any member's grants, including its own, and admit or remove members — a grant that can reproduce itself.</>,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** beforeSave gate: stops only when an escalating grant flips ON. */
|
|
74
|
+
function confirmGrantEscalation(member: MemberRow, changes: Record<string, unknown>): Promise<boolean> | true {
|
|
75
|
+
const perms = changes.permissions;
|
|
76
|
+
if (!perms || typeof perms !== 'object') return true;
|
|
77
|
+
const granting = Object.keys(ESCALATING_GRANTS).filter((key) => (perms as Record<string, unknown>)[key] === true);
|
|
78
|
+
if (granting.length === 0) return true;
|
|
79
|
+
const who = member.user?.display_name || member.user?.email || member.user?.username || `User #${member.user?.id ?? member.id}`;
|
|
80
|
+
const group = member.group?.name || 'this group';
|
|
81
|
+
const labels = granting.map((key) => ESCALATING_GRANTS[key]!.label).join(' + ');
|
|
82
|
+
return confirmGuardrail({
|
|
83
|
+
title: `Grant ${labels} to ${who}?`,
|
|
84
|
+
effect: <><b>{who}</b> holds <b>{granting.join(' and ')}</b> in <b>{group}</b> the moment this saves — there is no save button and no second step.</>,
|
|
85
|
+
why: granting.map((key) => ESCALATING_GRANTS[key]!.why),
|
|
86
|
+
undo: 'Switch it off here to revoke. Anything done while the grant was held stays done.',
|
|
87
|
+
confirmText: `Grant ${labels}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
function GrantSummary({ member }: { member: MemberRow }) {
|
|
55
92
|
const raw = rawMemberGrants(member.permissions);
|
|
56
93
|
const effective = effectiveMemberGrants(member.permissions);
|
|
@@ -201,7 +238,15 @@ export function MemberDetail({
|
|
|
201
238
|
<Eyebrow>Per-group authorization</Eyebrow>
|
|
202
239
|
<p className="dim">Role labels are not authorization. Protected changes may be rejected by <code>MEMBER_PERMS_PROTECTION</code>; failed autosaves revert and show the server error.</p>
|
|
203
240
|
{canSave
|
|
204
|
-
?
|
|
241
|
+
? (
|
|
242
|
+
<FormView
|
|
243
|
+
model={MemberModel}
|
|
244
|
+
row={member}
|
|
245
|
+
fields={memberPermissionFields()}
|
|
246
|
+
beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
|
|
247
|
+
onSaved={() => { void MemberModel.invalidate(qc); }}
|
|
248
|
+
/>
|
|
249
|
+
)
|
|
205
250
|
: <GrantSummary member={member} />}
|
|
206
251
|
{deploymentOnly.length > 0 && (
|
|
207
252
|
<p className="dim">Deployment-specific stored grants without registered editors remain read-only: <code>{deploymentOnly.join(', ')}</code>.</p>
|
|
@@ -23,34 +23,49 @@
|
|
|
23
23
|
import { useSyncExternalStore } from 'react';
|
|
24
24
|
import { useCan, useMe } from '../../../client/runtime';
|
|
25
25
|
import {
|
|
26
|
-
DetailView,
|
|
27
|
-
|
|
26
|
+
DetailView,
|
|
27
|
+
fmt,
|
|
28
|
+
getFormTabs,
|
|
29
|
+
modal,
|
|
30
|
+
subscribeFormTabs,
|
|
31
|
+
type Chip,
|
|
32
|
+
type DetailMenuEntry,
|
|
28
33
|
} from '../../../ui';
|
|
34
|
+
import { MEMBER_READ_PERMISSIONS } from '../members';
|
|
29
35
|
import {
|
|
30
|
-
USER_DEVICE_PERMISSIONS,
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
USER_DEVICE_PERMISSIONS,
|
|
37
|
+
USER_EVENT_PERMISSIONS,
|
|
38
|
+
USER_LOGIN_PERMISSIONS,
|
|
39
|
+
USER_LOG_PERMISSIONS,
|
|
40
|
+
USER_MANAGE_PERMISSIONS,
|
|
41
|
+
USER_PUSH_DEVICE_PERMISSIONS,
|
|
42
|
+
UserModel,
|
|
43
|
+
type UserRow,
|
|
33
44
|
} from './models';
|
|
34
|
-
import { MEMBER_READ_PERMISSIONS } from '../members';
|
|
35
45
|
import { useUserAdminActions } from './sections/actions';
|
|
36
|
-
import { useSharedUserQueries } from './sections/queries';
|
|
37
|
-
import {
|
|
38
|
-
accountType, inactivityWarning, isAnonymized, isOnline, statusBadge, useAdminCaller,
|
|
39
|
-
} from './sections/shared';
|
|
40
|
-
import { USER_APP_PERMS_TABSET } from './sections/permission-catalog';
|
|
41
|
-
import { OverviewSection } from './sections/OverviewSection';
|
|
42
|
-
import { ProfileSection } from './sections/ProfileSection';
|
|
43
|
-
import { PersonalSection } from './sections/PersonalSection';
|
|
44
|
-
import { SecuritySection } from './sections/SecuritySection';
|
|
45
|
-
import { OAuthSection } from './sections/OAuthSection';
|
|
46
|
-
import { GroupsSection } from './sections/GroupsSection';
|
|
47
|
-
import { AppPermsSection, SysPermsSection } from './sections/PermissionsSection';
|
|
48
46
|
import { ApiKeysSection } from './sections/ApiKeysSection';
|
|
47
|
+
import { AuditSection } from './sections/AuditSection';
|
|
49
48
|
import { DevicesSection } from './sections/DevicesSection';
|
|
49
|
+
import { GroupsSection } from './sections/GroupsSection';
|
|
50
50
|
import { LoginsSection } from './sections/LoginsSection';
|
|
51
|
-
import { AuditSection } from './sections/AuditSection';
|
|
52
|
-
import { NotificationsSection } from './sections/NotificationsSection';
|
|
53
51
|
import { UserMetadataSection } from './sections/MetadataPanel';
|
|
52
|
+
import { NotificationsSection } from './sections/NotificationsSection';
|
|
53
|
+
import { OAuthSection } from './sections/OAuthSection';
|
|
54
|
+
import { OverviewSection } from './sections/OverviewSection';
|
|
55
|
+
import { USER_APP_PERMS_TABSET } from './sections/permission-catalog';
|
|
56
|
+
import { AppPermsSection, SysPermsSection } from './sections/PermissionsSection';
|
|
57
|
+
import { PersonalSection } from './sections/PersonalSection';
|
|
58
|
+
import { ProfileSection } from './sections/ProfileSection';
|
|
59
|
+
import { useSharedUserQueries } from './sections/queries';
|
|
60
|
+
import { SecuritySection } from './sections/SecuritySection';
|
|
61
|
+
import {
|
|
62
|
+
accountType,
|
|
63
|
+
inactivityWarning,
|
|
64
|
+
isAnonymized,
|
|
65
|
+
isOnline,
|
|
66
|
+
statusBadge,
|
|
67
|
+
useAdminCaller,
|
|
68
|
+
} from './sections/shared';
|
|
54
69
|
|
|
55
70
|
export interface UserDetailProps {
|
|
56
71
|
id: number;
|
|
@@ -144,6 +159,7 @@ function UserDetailLoaded({ user, isAdmin, hasAppPerms, onClose, onOpenGroup }:
|
|
|
144
159
|
// ── Kebab (UserView contextItems at full scope) ───────────────────
|
|
145
160
|
const ADMIN = USER_MANAGE_PERMISSIONS;
|
|
146
161
|
const MENU: DetailMenuEntry<UserRow>[] = [
|
|
162
|
+
{ label: user.is_active ? 'Deactivate' : 'Reactivate', icon: 'bi-power', permissions: ADMIN, when: u => Boolean(u && !isAnonymized(u)), onSelect: () => void (user.is_active ? actions.disableUser() : actions.reactivateUser()) },
|
|
147
163
|
{ label: 'Edit user', icon: 'bi-pencil', permissions: ADMIN, onSelect: () => void actions.editUser() },
|
|
148
164
|
{
|
|
149
165
|
label: 'Manage avatar…', icon: 'bi-image', permissions: ADMIN,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useCan, useMe } from '../../../../client/runtime';
|
|
1
3
|
// OAuth section — AdminConnectedSection port (read in full 2026-08-05):
|
|
2
4
|
// the user's linked OAuth providers with admin unlink. The same list powers
|
|
3
5
|
// the Profile card's "manage linked accounts" modal (source Phase 3 shared
|
|
@@ -6,23 +8,28 @@
|
|
|
6
8
|
// Wire: GET /api/account/oauth_connection?user=<id> → rows
|
|
7
9
|
// {id, provider, email, is_active, created} (default graph, measured in
|
|
8
10
|
// django-mojo oauth.py); DELETE /api/account/oauth_connection/<id> unlinks.
|
|
9
|
-
import {
|
|
10
|
-
import { OAuthConnectionModel, type UserRow } from '../models';
|
|
11
|
+
import { Eyebrow, fmt, modal, toast } from '../../../../ui';
|
|
12
|
+
import { OAuthConnectionModel, USER_MANAGE_PERMISSIONS, type UserRow } from '../models';
|
|
11
13
|
import { providerIcon } from './shared';
|
|
12
14
|
|
|
13
15
|
export function OAuthConnectionList({ userId, canManage = true }: { userId: number; canManage?: boolean }) {
|
|
14
16
|
const { data, isPending } = OAuthConnectionModel.useList({ user: userId, size: 25, sort: '-created' });
|
|
15
17
|
const del = OAuthConnectionModel.useDelete();
|
|
18
|
+
const admin = useCan(USER_MANAGE_PERMISSIONS).can;
|
|
19
|
+
const me = useMe().data;
|
|
20
|
+
const allowed = (me?.id === userId || admin) && canManage;
|
|
21
|
+
const permission = useRef(allowed); permission.current = allowed;
|
|
22
|
+
useEffect(() => { permission.current = allowed; return () => { permission.current = false; }; }, [allowed]);
|
|
16
23
|
const rows = data?.rows ?? [];
|
|
17
24
|
|
|
18
25
|
const unlink = async (id: number, provider: string) => {
|
|
19
26
|
const ok = await modal.confirm({
|
|
20
27
|
title: 'Unlink account',
|
|
21
|
-
message: <>Unlink <b>{provider}</b>
|
|
28
|
+
message: <>Unlink <b>{provider}</b> from user #{userId}? This removes the association only; reconnect through provider authorization. The provider account is retained.</>,
|
|
22
29
|
confirmText: 'Unlink',
|
|
23
30
|
danger: true,
|
|
24
31
|
});
|
|
25
|
-
if (!ok) return;
|
|
32
|
+
if (!ok || !permission.current) return;
|
|
26
33
|
try {
|
|
27
34
|
await del.mutateAsync({ id });
|
|
28
35
|
toast.success(`${provider} account unlinked`);
|
|
@@ -51,8 +58,8 @@ export function OAuthConnectionList({ userId, canManage = true }: { userId: numb
|
|
|
51
58
|
{c.email ?? <span className="dim-italic">no email</span>} · Connected {fmt.relative(c.created)}
|
|
52
59
|
</div>
|
|
53
60
|
</div>
|
|
54
|
-
{
|
|
55
|
-
<button className="btn btn-compact us-danger" onClick={() => void unlink(c.id, c.provider)}>
|
|
61
|
+
{allowed && <div className="us-row-actions">
|
|
62
|
+
<button className="btn btn-compact us-danger" disabled={del.isPending} onClick={() => void unlink(c.id, c.provider)}>
|
|
56
63
|
<i className="bi bi-x-lg" /> Unlink
|
|
57
64
|
</button>
|
|
58
65
|
</div>}
|
|
@@ -9,16 +9,22 @@
|
|
|
9
9
|
// surfaces via the error toast like any failure. auth/email/verify/send is
|
|
10
10
|
// the public, admin-targetable verification sender (NOT the JWT-scoped
|
|
11
11
|
// auth/verify/email/send).
|
|
12
|
-
import { useEffect, useState } from 'react';
|
|
13
12
|
import { useQueryClient } from '@tanstack/react-query';
|
|
14
|
-
import {
|
|
13
|
+
import { useEffect, useRef, useState } from 'react';
|
|
14
|
+
import { mojoCall, useCan, useMe, withFreshAuth, type Field } from '../../../../client/runtime';
|
|
15
15
|
import {
|
|
16
|
-
Badge,
|
|
16
|
+
Badge,
|
|
17
|
+
ImageField,
|
|
18
|
+
PasswordStrengthMeter,
|
|
19
|
+
fmt,
|
|
20
|
+
formModal,
|
|
21
|
+
modal,
|
|
22
|
+
toast,
|
|
17
23
|
type FileFieldOwnerResult,
|
|
18
24
|
} from '../../../../ui';
|
|
19
|
-
import { PasskeyModel, UserModel, type UserRow } from '../models';
|
|
20
|
-
import { openGroupDetail, useAdminCaller } from './shared';
|
|
25
|
+
import { PasskeyModel, USER_MANAGE_PERMISSIONS, UserModel, type UserRow } from '../models';
|
|
21
26
|
import { OAuthConnectionList } from './OAuthSection';
|
|
27
|
+
import { openGroupDetail, useAdminCaller } from './shared';
|
|
22
28
|
|
|
23
29
|
/** One-field prompt modal (the source's Modal.prompt pencils). */
|
|
24
30
|
async function promptField(title: string, field: Field, initial: string): Promise<string | null> {
|
|
@@ -663,6 +669,10 @@ function AvatarModal({ user, onClose, onPendingChange }: { user: UserRow; onClos
|
|
|
663
669
|
* friendly_name and is_enabled; delete is real (CAN_DELETE).
|
|
664
670
|
*/
|
|
665
671
|
function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => void }) {
|
|
672
|
+
const admin = useCan(USER_MANAGE_PERMISSIONS).can;
|
|
673
|
+
const allowed = useMe().data?.id === userId || admin;
|
|
674
|
+
const permission = useRef(allowed); permission.current = allowed;
|
|
675
|
+
useEffect(() => { permission.current = allowed; return () => { permission.current = false; }; }, [allowed]);
|
|
666
676
|
const { data, isPending } = PasskeyModel.useList({ user: userId, size: 25, sort: '-created' });
|
|
667
677
|
const save = PasskeyModel.useSave();
|
|
668
678
|
const del = PasskeyModel.useDelete();
|
|
@@ -679,7 +689,7 @@ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => voi
|
|
|
679
689
|
initial: { friendly_name: current ?? '' },
|
|
680
690
|
});
|
|
681
691
|
const name = data?.friendly_name;
|
|
682
|
-
if (typeof name !== 'string' || !name.trim()) return;
|
|
692
|
+
if (!permission.current || typeof name !== 'string' || !name.trim()) return;
|
|
683
693
|
try {
|
|
684
694
|
await save.mutateAsync({ id, changes: { friendly_name: name.trim() } });
|
|
685
695
|
toast.success('Passkey updated');
|
|
@@ -689,6 +699,7 @@ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => voi
|
|
|
689
699
|
};
|
|
690
700
|
|
|
691
701
|
const toggleEnabled = async (id: number, next: boolean) => {
|
|
702
|
+
if (!permission.current) return;
|
|
692
703
|
try {
|
|
693
704
|
await save.mutateAsync({ id, changes: { is_enabled: next } });
|
|
694
705
|
toast.success(next ? 'Passkey enabled' : 'Passkey disabled');
|
|
@@ -700,11 +711,11 @@ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => voi
|
|
|
700
711
|
const remove = async (id: number) => {
|
|
701
712
|
const ok = await modal.confirm({
|
|
702
713
|
title: 'Delete passkey',
|
|
703
|
-
message: '
|
|
714
|
+
message: `Remove ${rows.find(row => row.id === id)?.friendly_name || 'Unnamed passkey'} for ${rows.find(row => row.id === id)?.rp_id || 'unknown relying party'}, user #${userId}? Reenrollment is required to restore it. The physical device is not erased.`,
|
|
704
715
|
confirmText: 'Delete',
|
|
705
716
|
danger: true,
|
|
706
717
|
});
|
|
707
|
-
if (!ok) return;
|
|
718
|
+
if (!ok || !permission.current) return;
|
|
708
719
|
try {
|
|
709
720
|
await del.mutateAsync({ id });
|
|
710
721
|
toast.success('Passkey deleted');
|
|
@@ -735,7 +746,7 @@ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => voi
|
|
|
735
746
|
Created {fmt.date(p.created)} · Last used {fmt.relative(p.last_used, 'never')} · {p.sign_count} uses
|
|
736
747
|
</div>
|
|
737
748
|
</div>
|
|
738
|
-
<div className="us-row-actions">
|
|
749
|
+
<div className="us-row-actions" inert={!allowed || save.isPending || del.isPending}>
|
|
739
750
|
<button className="btn-icon btn-icon-sm" title="Rename" aria-label={`Rename ${p.friendly_name ?? 'passkey'}`} onClick={() => void rename(p.id, p.friendly_name)}>
|
|
740
751
|
<i className="bi bi-pencil" />
|
|
741
752
|
</button>
|