portal-mojo 0.2.1 → 0.2.3
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/docs/DetailView.md +19 -15
- package/docs/README.md +1 -0
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +20 -4
- 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 +13 -5
- 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/feedback.md +10 -0
- package/docs/forms.md +10 -0
- package/docs/popover.md +15 -0
- 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 +91 -55
- package/src/admin/credentials/models.ts +64 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +28 -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/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 +274 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +6 -0
- 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 +89 -5
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/modal.tsx +8 -4
|
@@ -1,15 +1,29 @@
|
|
|
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
|
+
fmt,
|
|
12
|
+
modal,
|
|
13
|
+
toast,
|
|
14
|
+
type Column,
|
|
15
|
+
type Field,
|
|
16
|
+
type FilterDef,
|
|
8
17
|
} from '../../ui';
|
|
9
18
|
import {
|
|
10
|
-
GLOBAL_CREDENTIAL_PERMS,
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
GLOBAL_CREDENTIAL_PERMS,
|
|
20
|
+
GROUP_CREDENTIAL_PERMS,
|
|
21
|
+
WebhookSubscriptionModel,
|
|
22
|
+
fetchWebhookSecret,
|
|
23
|
+
normalizeWebhookEvents,
|
|
24
|
+
type CredentialGroup,
|
|
25
|
+
type WebhookSecretInfo,
|
|
26
|
+
type WebhookSubscriptionRow,
|
|
13
27
|
} from './models';
|
|
14
28
|
import { showSecretDialog } from './secret-dialog';
|
|
15
29
|
|
|
@@ -123,7 +137,7 @@ const WEBHOOK_FIELDS: Field[] = [
|
|
|
123
137
|
|
|
124
138
|
function useWebhookActions(permission: PermSpec) {
|
|
125
139
|
const save = WebhookSubscriptionModel.useSave();
|
|
126
|
-
|
|
140
|
+
|
|
127
141
|
const { can } = useCan(permission);
|
|
128
142
|
|
|
129
143
|
const createSubscription = async (fixedGroup?: CredentialGroup) => {
|
|
@@ -209,19 +223,9 @@ function useWebhookActions(permission: PermSpec) {
|
|
|
209
223
|
}
|
|
210
224
|
};
|
|
211
225
|
|
|
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
226
|
|
|
224
|
-
|
|
227
|
+
|
|
228
|
+
return { canManage: can, createSubscription, editSubscription, toggleSubscription, pending: save.isPending };
|
|
225
229
|
}
|
|
226
230
|
|
|
227
231
|
function WebhookCard({ row, actions }: {
|
|
@@ -271,13 +275,7 @@ function WebhookCard({ row, actions }: {
|
|
|
271
275
|
}}
|
|
272
276
|
/>
|
|
273
277
|
</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
|
-
/>
|
|
278
|
+
|
|
281
279
|
</div>
|
|
282
280
|
)}
|
|
283
281
|
</div>
|
|
@@ -333,6 +331,8 @@ export function WebhookSubscriptionDetail({ id, onClose }: { id: number; onClose
|
|
|
333
331
|
title={row.url}
|
|
334
332
|
subtitle={`${groupLabel(row.group)} · webhook subscription`}
|
|
335
333
|
chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }]}
|
|
334
|
+
active={actions.canManage ? { value: row.is_active, disabled: actions.pending, onChange: next => void actions.toggleSubscription(row, next) } : undefined}
|
|
335
|
+
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
336
|
sections={[
|
|
337
337
|
{
|
|
338
338
|
key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
@@ -342,19 +342,7 @@ export function WebhookSubscriptionDetail({ id, onClose }: { id: number; onClose
|
|
|
342
342
|
<FlatRow label="URL"><code>{row.url}</code></FlatRow>
|
|
343
343
|
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
344
344
|
<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>
|
|
345
|
+
|
|
358
346
|
</>
|
|
359
347
|
),
|
|
360
348
|
},
|
|
@@ -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 = {
|
|
@@ -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>
|