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.
Files changed (82) hide show
  1. package/README.md +3 -0
  2. package/docs/DetailView.md +19 -15
  3. package/docs/ModelTable.md +26 -0
  4. package/docs/README.md +2 -1
  5. package/docs/admin-assistant.md +3 -1
  6. package/docs/admin-credentials.md +7 -0
  7. package/docs/admin-dashboard.md +1 -1
  8. package/docs/admin-dns.md +8 -2
  9. package/docs/admin-incidents.md +26 -3
  10. package/docs/admin-jobs.md +7 -4
  11. package/docs/admin-messaging.md +6 -5
  12. package/docs/admin-modals.md +122 -0
  13. package/docs/admin-network-security.md +4 -4
  14. package/docs/admin-phonehub.md +24 -6
  15. package/docs/admin-push.md +7 -0
  16. package/docs/admin-rules.md +8 -0
  17. package/docs/admin-shortlinks.md +2 -1
  18. package/docs/admin-storage.md +2 -1
  19. package/docs/auth.md +28 -0
  20. package/docs/client.md +12 -0
  21. package/docs/detail-primitives.md +12 -1
  22. package/docs/feedback.md +70 -1
  23. package/docs/forms.md +51 -0
  24. package/docs/loading.md +3 -2
  25. package/docs/popover.md +18 -3
  26. package/package.json +1 -1
  27. package/src/admin/assistant/api.ts +1 -1
  28. package/src/admin/assistant/pages.tsx +71 -17
  29. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  30. package/src/admin/core/index.ts +7 -2
  31. package/src/admin/credentials/group-api-keys.tsx +67 -43
  32. package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
  33. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  34. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  35. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  36. package/src/admin/dns/setup.ts +1 -1
  37. package/src/admin/domains/observability.ts +1 -1
  38. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  39. package/src/admin/identity/users/UserDetail.tsx +36 -20
  40. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  41. package/src/admin/identity/users/sections/actions.tsx +20 -9
  42. package/src/admin/incidents/EventDetail.tsx +147 -5
  43. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  44. package/src/admin/incidents/models.ts +1 -1
  45. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  46. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  47. package/src/admin/jobs/index.ts +1 -1
  48. package/src/admin/jobs/purge-preview.ts +25 -0
  49. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  50. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  51. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  52. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  53. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  54. package/src/admin/messaging/push/PushPage.tsx +20 -8
  55. package/src/admin/monitoring/LogInspector.tsx +3 -19
  56. package/src/admin/network/IPSetDetail.tsx +32 -63
  57. package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
  58. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  59. package/src/admin/phonehub/api.ts +11 -5
  60. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  61. package/src/admin/rules/condition-removal.ts +7 -0
  62. package/src/admin/security/geoip/models.ts +5 -0
  63. package/src/admin/security/tickets.tsx +40 -17
  64. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  65. package/src/admin/storage/BucketDetail.tsx +28 -9
  66. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  67. package/src/admin/storage/FileView.tsx +28 -16
  68. package/src/admin/storage/FilesPage.tsx +4 -5
  69. package/src/client/auth.ts +10 -1
  70. package/src/client/client.ts +14 -6
  71. package/src/client/mock.ts +90 -6
  72. package/src/client/upload.ts +1 -0
  73. package/src/ui/DetailView.tsx +22 -5
  74. package/src/ui/FormView.tsx +24 -1
  75. package/src/ui/ModelTable.tsx +12 -4
  76. package/src/ui/Popover.tsx +13 -12
  77. package/src/ui/detail/MetadataSection.tsx +46 -10
  78. package/src/ui/form-autosave.ts +134 -23
  79. package/src/ui/guardrail.tsx +125 -0
  80. package/src/ui/index.ts +1 -0
  81. package/src/ui/modal.tsx +8 -4
  82. 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
- ArmedButton, Badge, DetailView, Eyebrow, FlatRow, ModelTable,
6
- SchemaForm, fmt, modal, toast,
7
- type Column, type Field, type FilterDef,
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, GROUP_CREDENTIAL_PERMS,
11
- WebhookSubscriptionModel, fetchWebhookSecret, normalizeWebhookEvents,
12
- type CredentialGroup, type WebhookSecretInfo, type WebhookSubscriptionRow,
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
- const destroy = WebhookSubscriptionModel.useDelete();
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
- return { canManage: can, createSubscription, editSubscription, toggleSubscription, deleteSubscription };
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
- <ArmedButton
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
- <div className="ga-toolbar" style={{ marginTop: 16 }}>
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"><div className="eyebrow">Overview</div><h1>Admin dashboard</h1><p className="dim">Authoritative operational signals for this django-mojo deployment.</p></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(() => queryClient.getQueryCache().subscribe((event) => {
53
- if (event.query.queryKey[0] === CertificateModel.endpoint) setVersion((value) => value + 1);
54
- }), [queryClient]);
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
- ArmedButton, Badge, CollectionSelect, FlatRow, ModelTable, fmt, modal, toast,
6
- type Column, type FilterDef, type Tone,
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, invalidateDnsCredentials, linkDnsCredential,
10
- rotateDnsCredential, useDnsCapabilities,
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, DNS_VIEW_PERMISSIONS, DnsCredentialModel,
15
- type DnsCapabilities, type DnsCredentialRow, type DnsGroupChoice,
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
- const destroy = DnsCredentialModel.useDelete();
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
- return (
181
- <div className="modal-pad">
182
- <div className="eyebrow">Provider credential</div>
183
- <h2 className="modal-title">{row.name || `Credential #${row.id}`}</h2>
184
- <div className="chip-row" style={{ marginBottom: 14 }}>
185
- <Badge tone={verificationTone(row)}>{row.verified ? 'Verified' : 'Unverified'}</Badge>
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
- {canManage && (
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.open((done) => (
235
+ void modal.detail((done) => (
252
236
  <CredentialDetail id={row.id} caps={caps} close={() => done(null)} />
253
- ), { size: 'md' });
237
+ ));
254
238
  };
255
239
  const canLink = canManage && linkableProviders(caps).length > 0;
256
240
  return (
@@ -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: 'Overview', icon: 'bi-grid-1x2', navigationGroup: 'overview', permissions: ADMIN_DASHBOARD_PERMISSIONS,
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
- ? <FormView model={MemberModel} row={member} fields={memberPermissionFields()} onSaved={() => { void MemberModel.invalidate(qc); }} />
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, getFormTabs, subscribeFormTabs, fmt, modal,
27
- type Chip, type DetailMenuEntry,
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, USER_EVENT_PERMISSIONS, USER_LOGIN_PERMISSIONS,
31
- USER_LOG_PERMISSIONS, USER_MANAGE_PERMISSIONS, USER_PUSH_DEVICE_PERMISSIONS,
32
- UserModel, type UserRow,
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 { modal, toast, fmt, Eyebrow } from '../../../../ui';
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> for this user?</>,
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
- {canManage && <div className="us-row-actions">
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 { mojoCall, withFreshAuth, type Field } from '../../../../client/runtime';
13
+ import { useEffect, useRef, useState } from 'react';
14
+ import { mojoCall, useCan, useMe, withFreshAuth, type Field } from '../../../../client/runtime';
15
15
  import {
16
- Badge, ImageField, PasswordStrengthMeter, fmt, formModal, modal, toast,
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: 'Delete this passkey?',
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>