portal-mojo 0.2.2 → 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 +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 +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 +47 -43
- 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,18 +1,40 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
2
|
+
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
|
|
4
3
|
import type { RecordFeedItem } from '../../client/record-feed';
|
|
4
|
+
import { createTicketNoteAdapter } from '../../client/record-feed';
|
|
5
|
+
import { mojoSave, useCan } from '../../client/runtime';
|
|
5
6
|
import {
|
|
6
|
-
Badge,
|
|
7
|
-
|
|
7
|
+
Badge,
|
|
8
|
+
CollectionSelect,
|
|
9
|
+
DetailView,
|
|
10
|
+
MarkdownView,
|
|
11
|
+
ModelTable,
|
|
12
|
+
RecordFeed,
|
|
13
|
+
SchemaForm,
|
|
14
|
+
fmt,
|
|
15
|
+
modal,
|
|
16
|
+
toast,
|
|
17
|
+
type Column,
|
|
18
|
+
type Field,
|
|
19
|
+
type FilterDef,
|
|
20
|
+
type FormData,
|
|
21
|
+
type Tone,
|
|
8
22
|
} from '../../ui';
|
|
9
23
|
import { AssistantContextLauncher } from '../assistant/launchers';
|
|
10
|
-
import { createTicketNoteAdapter } from '../../client/record-feed';
|
|
11
24
|
import {
|
|
12
|
-
MaestroItemLinkModel,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
MaestroItemLinkModel,
|
|
26
|
+
TICKET_MANAGE_PERMS,
|
|
27
|
+
TICKET_USER_LOOKUP_PERMS,
|
|
28
|
+
TicketModel,
|
|
29
|
+
buildTicketActionResponseBody,
|
|
30
|
+
invalidateTicketDependents,
|
|
31
|
+
isTicketActionDisabled,
|
|
32
|
+
isTicketTerminal,
|
|
33
|
+
knownOptionsWithCurrent,
|
|
34
|
+
relationId,
|
|
35
|
+
relationLabel,
|
|
36
|
+
type TicketNoteAction,
|
|
37
|
+
type TicketRow,
|
|
16
38
|
} from './models';
|
|
17
39
|
|
|
18
40
|
export const TICKET_STATUSES = [
|
|
@@ -407,6 +429,12 @@ function TicketDetail({ ticketId, close }: { ticketId: number; close(): void })
|
|
|
407
429
|
]}
|
|
408
430
|
onClose={close}
|
|
409
431
|
menuContext={ticket}
|
|
432
|
+
contextMenu={canManage ? [
|
|
433
|
+
{ label: 'Edit ticket', disabled: busy, onSelect: () => void openTicketEditor(ticket) },
|
|
434
|
+
{ label: llmEnabled ? 'Disable LLM' : 'Enable LLM', disabled: busy, onSelect: () => void runLlm() },
|
|
435
|
+
{ label: 'Close ticket', disabled: busy || isTicketTerminal(ticket.status), onSelect: () => void patch({ status: 'closed' }, 'Ticket closed') },
|
|
436
|
+
{ label: 'Push to Maestro', disabled: busy || Boolean(link), onSelect: () => void push() },
|
|
437
|
+
] : []}
|
|
410
438
|
sections={[
|
|
411
439
|
{
|
|
412
440
|
key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => (
|
|
@@ -439,9 +467,9 @@ function TicketDetail({ ticketId, close }: { ticketId: number; close(): void })
|
|
|
439
467
|
),
|
|
440
468
|
},
|
|
441
469
|
...(canManage ? [{
|
|
442
|
-
key: '
|
|
470
|
+
key: 'properties', label: 'Properties', icon: 'bi-sliders', render: () => (
|
|
443
471
|
<section className="ticket-detail-controls" aria-label="Ticket controls">
|
|
444
|
-
<h4>
|
|
472
|
+
<h4>Ticket properties</h4>
|
|
445
473
|
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
446
474
|
<div className="ticket-control-grid">
|
|
447
475
|
<label className="field"><span className="field-label">Status</span>
|
|
@@ -481,12 +509,7 @@ function TicketDetail({ ticketId, close }: { ticketId: number; close(): void })
|
|
|
481
509
|
if (descriptionDraft !== (ticket.description ?? '')) void patch({ description: descriptionDraft }, 'Description updated');
|
|
482
510
|
}} />
|
|
483
511
|
</label>
|
|
484
|
-
|
|
485
|
-
<button type="button" className="btn" disabled={busy} onClick={() => void runLlm()}>{llmEnabled ? 'Disable LLM' : 'Enable LLM'}</button>
|
|
486
|
-
<button type="button" className="btn" disabled={busy || isTicketTerminal(ticket.status)} onClick={() => void patch({ status: 'closed' }, 'Ticket closed')}>Close ticket</button>
|
|
487
|
-
<button type="button" className="btn" disabled={busy || Boolean(link)} onClick={() => void push()}>{pushToMaestro.isPending ? 'Queueing…' : 'Push to Maestro'}</button>
|
|
488
|
-
<button type="button" className="btn" disabled={busy} onClick={() => void openTicketEditor(ticket)}>Edit all fields</button>
|
|
489
|
-
</div>
|
|
512
|
+
|
|
490
513
|
</section>
|
|
491
514
|
),
|
|
492
515
|
}] : []),
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useCan } from '../../client/runtime';
|
|
4
|
+
import { DetailView, FlatRow, fmt, toast } from '../../ui';
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
SHORTLINK_MANAGE_PERMISSIONS,
|
|
7
|
+
ShortlinkModel,
|
|
8
|
+
ShortlinkReconciliationError,
|
|
9
|
+
functionalShortlinkUrl,
|
|
10
|
+
refreshShortlinks,
|
|
11
|
+
setShortlinkActive,
|
|
12
|
+
useTrackedCounts,
|
|
7
13
|
} from './models';
|
|
8
14
|
|
|
9
15
|
export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
10
16
|
const queryClient = useQueryClient();
|
|
17
|
+
const canManage = useCan(SHORTLINK_MANAGE_PERMISSIONS).can;
|
|
18
|
+
const [pending, setPending] = useState(false);
|
|
11
19
|
const query = ShortlinkModel.useOne(id);
|
|
12
20
|
const [retryBlocked, setRetryBlocked] = useState(false);
|
|
13
21
|
const counts = useTrackedCounts(query.data ?? null);
|
|
@@ -15,13 +23,14 @@ export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => vo
|
|
|
15
23
|
const row = query.data;
|
|
16
24
|
const capability = functionalShortlinkUrl(row.code);
|
|
17
25
|
const act = async (action: () => Promise<unknown>, success: string): Promise<boolean> => {
|
|
18
|
-
if (retryBlocked) return false;
|
|
26
|
+
if (!canManage || pending || retryBlocked) return false;
|
|
27
|
+
setPending(true);
|
|
19
28
|
try { await action(); toast.success(success); await query.refetch(); return true; }
|
|
20
29
|
catch (error) {
|
|
21
30
|
if (error instanceof ShortlinkReconciliationError) setRetryBlocked(true);
|
|
22
31
|
toast.error(error instanceof Error ? error.message : 'Shortlink operation failed');
|
|
23
32
|
return false;
|
|
24
|
-
}
|
|
33
|
+
} finally { setPending(false); }
|
|
25
34
|
};
|
|
26
35
|
const refresh = async () => {
|
|
27
36
|
try { await refreshShortlinks(queryClient); await query.refetch(); setRetryBlocked(false); toast.success('Authoritative shortlink state refreshed'); }
|
|
@@ -29,6 +38,8 @@ export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => vo
|
|
|
29
38
|
};
|
|
30
39
|
return <DetailView title={row.code} subtitle={row.source || 'No source label'} icon="bi-link-45deg"
|
|
31
40
|
chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }]}
|
|
41
|
+
active={canManage ? { value: row.is_active, disabled: pending || retryBlocked, onChange: next => void act(() => setShortlinkActive(queryClient, row, next), next ? 'Shortlink reactivated' : 'Shortlink deactivated') } : undefined}
|
|
42
|
+
contextMenu={canManage ? [{ label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: pending || retryBlocked, onSelect: () => void act(() => setShortlinkActive(queryClient, row, !row.is_active), 'Shortlink updated') }] : []}
|
|
32
43
|
onClose={onClose} sections={[
|
|
33
44
|
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section">
|
|
34
45
|
{retryBlocked && <div className="form-alert">The last change may have persisted, but reconciliation failed. Refresh successfully before retrying.</div>}
|
|
@@ -45,11 +56,6 @@ export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => vo
|
|
|
45
56
|
<h3>Tracked records</h3><p className="dim">Human and bot values count retained tracking records. Total hits may also include requests made while tracking was off.</p>
|
|
46
57
|
{counts.isLoading ? <p>Loading counts…</p> : counts.isError ? <div className="form-alert">Tracked counts unavailable.</div> : <div className="shortlink-counts"><div><b>{counts.data?.human ?? 0}</b><span>Human records</span></div><div><b>{counts.data?.bot ?? 0}</b><span>Bot records</span></div><div><b>{counts.data?.remainder ?? row.hit_count}</b><span>Hit-count remainder</span></div></div>}
|
|
47
58
|
</div> },
|
|
48
|
-
|
|
49
|
-
<h3>{row.is_active ? 'Deactivate link' : 'Reactivate link'}</h3><p>Changes use a single non-retrying request followed by an authoritative refresh.</p>
|
|
50
|
-
<button className="btn" disabled={retryBlocked} onClick={() => void act(() => setShortlinkActive(queryClient, row, !row.is_active), row.is_active ? 'Shortlink deactivated' : 'Shortlink reactivated')}>{row.is_active ? 'Deactivate' : 'Reactivate'}</button>
|
|
51
|
-
<h3>Delete link</h3><p>Deletion is permanent. Click history is removed by the backend cascade.</p>
|
|
52
|
-
<ArmedButton label="Delete shortlink" armedLabel={`Click again to delete ${row.code}`} disabled={retryBlocked} onConfirm={async () => { if (await act(() => deleteShortlink(queryClient, row), 'Shortlink deleted')) onClose(); }} />
|
|
53
|
-
</div> },
|
|
59
|
+
|
|
54
60
|
]} />;
|
|
55
61
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useCan } from '../../client/runtime';
|
|
3
4
|
import { ArmedButton, DetailView, FlatRow, SchemaForm, fmt, modal, toast } from '../../ui';
|
|
4
5
|
import {
|
|
5
|
-
BUCKET_MANAGE_PERMS,
|
|
6
|
-
|
|
6
|
+
BUCKET_MANAGE_PERMS,
|
|
7
|
+
emptyBucket,
|
|
8
|
+
setBucketPublic,
|
|
9
|
+
type BucketEmptyResult,
|
|
10
|
+
type BucketMutationOutcome,
|
|
11
|
+
type S3BucketRow,
|
|
12
|
+
type S3FailureEvidence,
|
|
7
13
|
} from './models';
|
|
8
14
|
|
|
9
15
|
function message(error: unknown): string { return error instanceof Error ? error.message : 'Storage operation failed'; }
|
|
@@ -29,6 +35,9 @@ function Outcome({ value }: { value: BucketMutationOutcome<unknown> | null }) {
|
|
|
29
35
|
|
|
30
36
|
export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose: () => void }) {
|
|
31
37
|
const queryClient = useQueryClient();
|
|
38
|
+
const canManage = useCan(BUCKET_MANAGE_PERMS).can;
|
|
39
|
+
const permission = useRef(canManage); permission.current = canManage;
|
|
40
|
+
useEffect(() => { permission.current = canManage; return () => { permission.current = false; }; }, [canManage]);
|
|
32
41
|
const [outcome, setOutcome] = useState<BucketMutationOutcome<unknown> | null>(null);
|
|
33
42
|
const [emptyResult, setEmptyResult] = useState<BucketEmptyResult | null>(null);
|
|
34
43
|
|
|
@@ -40,13 +49,15 @@ export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose
|
|
|
40
49
|
: <>This applies the all-private Public Access Block posture to <code>{bucket.name}</code>. The operation is externally observable and not atomic.</>,
|
|
41
50
|
confirmText: isPublic ? 'Make public' : 'Make private', danger: isPublic,
|
|
42
51
|
});
|
|
43
|
-
if (!confirmed) return;
|
|
52
|
+
if (!confirmed || !permission.current) return;
|
|
44
53
|
const next = await setBucketPublic(queryClient, bucket.name, isPublic);
|
|
45
54
|
setOutcome(next);
|
|
46
55
|
if (next.data) toast.success(`${bucket.name} verified ${next.data.is_public ? 'public' : 'private'}`);
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
const confirmEmpty = async () => {
|
|
59
|
+
if (!permission.current) return;
|
|
60
|
+
let pending = false;
|
|
50
61
|
await modal.open((close) => (
|
|
51
62
|
<div className="modal-pad">
|
|
52
63
|
<h2 className="modal-title">Empty {bucket.name}</h2>
|
|
@@ -56,9 +67,13 @@ export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose
|
|
|
56
67
|
submitText="Empty bucket"
|
|
57
68
|
onCancel={() => close(null)}
|
|
58
69
|
onSubmit={async (data) => {
|
|
70
|
+
if (!permission.current) throw new Error('Permission to empty this bucket was revoked.');
|
|
59
71
|
const exact = String(data.confirm_name ?? '');
|
|
60
72
|
if (exact !== bucket.name) throw new Error('Bucket name does not match exactly. No request was sent.');
|
|
61
|
-
|
|
73
|
+
pending = true;
|
|
74
|
+
let next;
|
|
75
|
+
try { next = await emptyBucket(queryClient, bucket.name, exact); }
|
|
76
|
+
finally { pending = false; }
|
|
62
77
|
setOutcome(next);
|
|
63
78
|
if (next.error) throw next.error;
|
|
64
79
|
if (next.data) { setEmptyResult(next.data); toast.success(`${bucket.name} empty acknowledged`); }
|
|
@@ -66,12 +81,16 @@ export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose
|
|
|
66
81
|
}}
|
|
67
82
|
/>
|
|
68
83
|
</div>
|
|
69
|
-
), { size: 'sm' });
|
|
84
|
+
), { size: 'sm', canDismiss: () => !pending });
|
|
70
85
|
};
|
|
71
86
|
|
|
72
|
-
return <DetailView title={bucket.name} subtitle="Account-level S3 bucket" icon="bi-bucket" chips={[{ text: 'Global', tone: 'info' }]}
|
|
87
|
+
return <DetailView title={bucket.name} subtitle="Account-level S3 bucket" icon="bi-bucket" chips={[{ text: 'Global', tone: 'info' }]} contextMenu={canManage ? [
|
|
88
|
+
{ label: 'Make public…', danger: true, onSelect: () => void access(true) },
|
|
89
|
+
{ label: 'Make private…', onSelect: () => void access(false) },
|
|
90
|
+
{ label: 'Empty bucket…', danger: true, onSelect: () => void modal.open(close => <div className="modal-pad"><h2 className="modal-title">Empty {bucket.name}</h2><p className="modal-message">Permanently removes objects, versions, delete markers and multipart uploads. Stop writers first. The bucket is retained.</p><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Cancel</button><ArmedButton className="btn-danger" label="Empty bucket" armedLabel="Continue to exact-name confirmation" onConfirm={async () => { await confirmEmpty(); close(null); }} /></div></div>, { size: 'sm' }) },
|
|
91
|
+
] : []} sections={[
|
|
73
92
|
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><FlatRow label="Name"><code>{bucket.name}</code></FlatRow><FlatRow label="Created">{fmt.datetime(bucket.created)}</FlatRow><FlatRow label="Inventory">Complete account inventory row</FlatRow><Outcome value={outcome} />{emptyResult && <div className="storage-counts"><b>Last acknowledged empty counts</b><span>Objects {emptyResult.deleted_objects}</span><span>Versions {emptyResult.deleted_versions}</span><span>Delete markers {emptyResult.deleted_markers}</span><span>Multipart uploads {emptyResult.aborted_uploads}</span></div>}</div> },
|
|
74
|
-
{ key: 'access', label: 'Access', icon: 'bi-shield-lock', permissions: BUCKET_MANAGE_PERMS, render: () => <div className="detail-section"><h3>Verified access posture</h3><p className="dim">Every attempt refreshes inventory in a finally path. A rejection can still represent partial external change.</p
|
|
75
|
-
|
|
93
|
+
{ key: 'access', label: 'Access', icon: 'bi-shield-lock', permissions: BUCKET_MANAGE_PERMS, render: () => <div className="detail-section"><h3>Verified access posture</h3><p className="dim">Every attempt refreshes inventory in a finally path. A rejection can still represent partial external change.</p></div> },
|
|
94
|
+
|
|
76
95
|
]} initialSection="overview" onClose={onClose} />;
|
|
77
96
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from 'react';
|
|
3
3
|
import { useCan } from '../../client/runtime';
|
|
4
|
+
import { Badge, DetailView, FlatRow, fmt, modal, toast } from '../../ui';
|
|
4
5
|
import { FileView } from './FileView';
|
|
6
|
+
import { FileManagerModel, FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, USER_DIRECTORY_PERMS, runFileManagerAction, saveFileManagerAtomic, storageRelationId } from './models';
|
|
5
7
|
import { openFileManagerEditor } from './storage-dialogs';
|
|
6
|
-
import { FileManagerModel, FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, USER_DIRECTORY_PERMS, storageRelationId, runFileManagerAction, saveFileManagerAtomic } from './models';
|
|
7
8
|
|
|
8
9
|
function NestedFiles({ managerId }: { managerId: number }) {
|
|
9
10
|
const query = FileModel.useList({ file_manager: managerId, start: 0, size: 10, sort: '-created' });
|
|
@@ -15,6 +16,7 @@ function NestedFiles({ managerId }: { managerId: number }) {
|
|
|
15
16
|
|
|
16
17
|
export function FileManagerDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
17
18
|
const queryClient = useQueryClient();
|
|
19
|
+
const [pending, setPending] = useState(false);
|
|
18
20
|
const query = FileManagerModel.useOne(id);
|
|
19
21
|
const managerPermission = useCan(STORAGE_MANAGE_PERMS);
|
|
20
22
|
const groupDirectoryPermission = useCan(GROUP_DIRECTORY_PERMS);
|
|
@@ -28,9 +30,25 @@ export function FileManagerDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
28
30
|
const action = async (name: 'test_connection' | 'check_cors' | 'fix_cors') => { try { const result = await runFileManagerAction(id, name); toast.success(name === 'test_connection' ? 'Connection succeeded' : `${name.replace('_', ' ')} completed`); await modal.open((close) => <div className="modal-pad"><h2 className="modal-title">{name.replaceAll('_', ' ')}</h2><pre className="storage-result-json">{JSON.stringify(result, null, 2)}</pre><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>); } catch (error) { toast.error(error instanceof Error ? error.message : `${name} failed`); } };
|
|
29
31
|
const clone = async () => { const ok = await modal.confirm({ title: 'Clone backend?', message: <>Create a private clone of <b>{row.name}</b> under the same explicit owner scope?</>, confirmText: 'Clone' }); if (!ok) return; try { await runFileManagerAction(id, 'clone'); await FileManagerModel.invalidate(queryClient); toast.success(`${row.name} cloned`); } catch (error) { toast.error(error instanceof Error ? error.message : 'Clone failed'); } };
|
|
30
32
|
const toggle = async () => { const ok = await modal.confirm({ title: row.is_public ? 'Make backend private?' : 'Make backend public?', message: 'This changes prefix policy on the storage provider. Continue?', confirmText: row.is_public ? 'Make private' : 'Make public', danger: !row.is_public }); if (!ok) return; try { await saveFileManagerAtomic({ queryClient, id, changes: { is_public: !row.is_public } }); await query.refetch(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Access change failed'); } };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
const setActive = async (next: boolean) => {
|
|
34
|
+
if (!canManage || pending) return; setPending(true);
|
|
35
|
+
try { await saveFileManagerAtomic({ queryClient, id, changes: { is_active: next } }); await query.refetch(); }
|
|
36
|
+
catch (error) { toast.error(error instanceof Error ? error.message : 'State change failed'); }
|
|
37
|
+
finally { setPending(false); }
|
|
38
|
+
};
|
|
39
|
+
return <DetailView active={canManage ? { value: row.is_active, disabled: pending, onChange: next => void setActive(next) } : undefined}
|
|
40
|
+
contextMenu={canManage ? [
|
|
41
|
+
{ label: 'Edit backend', onSelect: () => void edit('general') },
|
|
42
|
+
{ label: 'Credentials…', onSelect: () => void edit('credentials') },
|
|
43
|
+
...(canChangeOwner ? [{ label: 'Owner scope…', onSelect: () => void edit('owner') }] : []),
|
|
44
|
+
{ label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: pending, onSelect: () => void setActive(!row.is_active) },
|
|
45
|
+
{ label: row.is_public ? 'Make private…' : 'Make public…', onSelect: () => void toggle() },
|
|
46
|
+
{ label: 'Test connection', onSelect: () => void action('test_connection') },
|
|
47
|
+
...(row.backend_type === 's3' ? [{ label: 'Check CORS', onSelect: () => void action('check_cors') }, { label: 'Fix CORS', onSelect: () => void action('fix_cors') }] : []),
|
|
48
|
+
{ label: 'Clone…', onSelect: () => void clone() },
|
|
49
|
+
] : []} title={row.name} subtitle={`${row.backend_type} · ${row.backend_url}`} icon="bi-hdd-stack" chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }, { text: row.is_public ? 'Public' : 'Private', tone: row.is_public ? 'warning' : 'success' }]} sections={[
|
|
50
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><FlatRow label="Backend"><Badge tone="info">{row.backend_type}</Badge></FlatRow><FlatRow label="Use">{row.use || '—'}</FlatRow><FlatRow label="URL"><code>{row.backend_url}</code></FlatRow><FlatRow label="Owner">{storageRelationId(row.group) != null ? `Group #${storageRelationId(row.group)}` : storageRelationId(row.user) != null ? `User #${storageRelationId(row.user)}` : 'System'}</FlatRow><FlatRow label="Default">{row.is_default ? 'Yes' : 'No'}</FlatRow><FlatRow label="Region">{row.aws_region || '—'}</FlatRow><FlatRow label="Access key">{row.aws_key_masked || 'Not configured'}</FlatRow><FlatRow label="Secret key">{row.aws_secret_masked || 'Not configured'}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> },
|
|
33
51
|
{ key: 'files', label: 'Files', icon: 'bi-files', render: () => <div className="detail-section"><h3>Recent files</h3><p className="dim">Newest 10 for this backend. This bounded child inventory does not own URL table state.</p><NestedFiles managerId={id} /></div> },
|
|
34
|
-
|
|
52
|
+
|
|
35
53
|
]} initialSection="overview" onClose={onClose} />;
|
|
36
54
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
3
|
import { useCan } from '../../client/runtime';
|
|
4
|
+
import { Badge, DataView, DetailView, FlatRow, SchemaForm, fmt, modal, toast } from '../../ui';
|
|
5
5
|
import { FilePreview, filePreviewCategory } from './FilePreview';
|
|
6
|
-
import {
|
|
7
|
-
import { FileModel,
|
|
6
|
+
import { normalizeRenditionRoles, normalizeRenditions, pollRenditionConvergence, renditionTargetSignature, type RenditionPollStop } from './file-renditions';
|
|
7
|
+
import { FileModel, STORAGE_MANAGE_PERMS, ShortLinkShareModel, createFileShare, isSafeCapabilityUrl, openCapabilityUrl, saveFileAndReconcileGroup, setFileShareActive, type FileRenditionRow } from './models';
|
|
8
8
|
|
|
9
9
|
function ShareResult({ url, close }: { url: string; close: () => void }) {
|
|
10
10
|
return <div className="modal-pad"><h2 className="modal-title">Share link created</h2><p className="modal-message">This newly minted capability is shown once from component-local state.</p><code className="storage-capability">{url}</code><div className="modal-actions"><button className="btn" onClick={() => void navigator.clipboard.writeText(url)}>Copy</button><button className="btn btn-primary" onClick={close}>Done</button></div></div>;
|
|
@@ -19,8 +19,12 @@ export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
19
19
|
const queryClient = useQueryClient();
|
|
20
20
|
const query = FileModel.useOne(id);
|
|
21
21
|
const canManage = useCan(STORAGE_MANAGE_PERMS).can;
|
|
22
|
+
const [pendingShares, setPendingShares] = useState<Set<number>>(() => new Set());
|
|
23
|
+
const shareLocks = useRef(new Set<number>());
|
|
24
|
+
const permission = useRef(canManage); permission.current = canManage;
|
|
25
|
+
useEffect(() => { permission.current = canManage; return () => { permission.current = false; }; }, [canManage]);
|
|
22
26
|
const regenerate = FileModel.useAction('regenerate_renditions');
|
|
23
|
-
|
|
27
|
+
|
|
24
28
|
const shares = ShortLinkShareModel.useList({ file: id, start: 0, size: 25 }, { enabled: canManage });
|
|
25
29
|
const alive = useRef(true);
|
|
26
30
|
const initialPollStarted = useRef(false);
|
|
@@ -65,12 +69,12 @@ export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
65
69
|
const updatePublic = async () => {
|
|
66
70
|
const next = !file.is_public;
|
|
67
71
|
const ok = await modal.confirm({ title: next ? 'Make file public?' : 'Make file private?', message: <>Change access for <b>{file.filename}</b>?</>, confirmText: next ? 'Make public' : 'Make private', danger: next });
|
|
68
|
-
if (!ok) return;
|
|
72
|
+
if (!ok || !permission.current) return;
|
|
69
73
|
try { await saveFileAndReconcileGroup(queryClient, id, { is_public: next }); await query.refetch(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Access change failed'); }
|
|
70
74
|
};
|
|
71
75
|
const createShare = async () => {
|
|
72
76
|
const values = await modal.open<Record<string, unknown>>((close) => <div className="modal-pad"><h2 className="modal-title">Create share link</h2><SchemaForm fields={[{ name: 'expire_days', type: 'text', label: 'Expire after days', help: '0 means no expiry.' }, { name: 'track_clicks', type: 'switch', label: 'Track clicks' }, { name: 'note', type: 'textarea', label: 'Audit note' }]} initial={{ expire_days: '30', track_clicks: false, note: '' }} submitText="Create share" onCancel={() => close(null as never)} onSubmit={(data) => close(data as unknown as Record<string, unknown>)} /></div>);
|
|
73
|
-
if (!values) return;
|
|
77
|
+
if (!values || !permission.current) return;
|
|
74
78
|
try {
|
|
75
79
|
const result = await createFileShare(id, { expire_days: Number(values.expire_days ?? 30), track_clicks: Boolean(values.track_clicks), note: String(values.note ?? '') });
|
|
76
80
|
await modal.open((close) => <ShareResult url={result.url} close={() => close(null)} />, { size: 'sm' });
|
|
@@ -84,7 +88,7 @@ export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
84
88
|
const normalized = normalizeRenditionRoles(roles);
|
|
85
89
|
try {
|
|
86
90
|
const ok = await modal.confirm({ title: normalized.length ? `Regenerate ${normalized.join(', ')}?` : 'Regenerate all renditions?', message: 'The renderer is asynchronous. This viewer polls finite observable state for up to 12 attempts.', confirmText: 'Queue regeneration' });
|
|
87
|
-
if (!ok) return;
|
|
91
|
+
if (!ok || !permission.current) return;
|
|
88
92
|
const currentRoles = normalizeRenditions(file.renditions).map((row) => row.role);
|
|
89
93
|
const targetRoles = normalized.length > 0 ? normalized : currentRoles.length > 0 ? currentRoles : ['thumbnail', 'preview'];
|
|
90
94
|
const before = renditionTargetSignature(file, targetRoles);
|
|
@@ -93,17 +97,25 @@ export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
93
97
|
} catch (error) { toast.error(error instanceof Error ? error.message : 'Could not queue renditions'); }
|
|
94
98
|
finally { regenerationBusyRef.current = false; setRegenerationBusy(false); }
|
|
95
99
|
};
|
|
96
|
-
const deleteOne = async () => {
|
|
97
|
-
await modal.open((close) => <div className="modal-pad"><h2 className="modal-title">Delete {file.filename}</h2><p className="modal-message">Enter the exact filename. This deletes the record, original backend object, and renditions.</p><SchemaForm fields={[{ name: 'filename', type: 'text', label: 'Exact filename', required: true }]} submitText="Delete file" onCancel={() => close(null)} onSubmit={async (data) => { if (String(data.filename ?? '') !== file.filename) throw new Error('Filename does not match. No request was sent.'); await remove.mutateAsync({ id }); close(true); onClose(); }} /></div>, { size: 'sm' });
|
|
98
|
-
};
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
|
|
102
|
+
const toggleShare = async (shareId: number, next: boolean) => {
|
|
103
|
+
if (!permission.current || shareLocks.current.has(shareId)) return;
|
|
104
|
+
shareLocks.current.add(shareId); setPendingShares(new Set(shareLocks.current));
|
|
105
|
+
try { await setFileShareActive(shareId, next); }
|
|
106
|
+
catch (error) { toast.error(error instanceof Error ? error.message : 'Share state change failed'); }
|
|
107
|
+
finally { await shares.refetch(); shareLocks.current.delete(shareId); setPendingShares(new Set(shareLocks.current)); }
|
|
108
|
+
};
|
|
109
|
+
return <DetailView contextMenu={[
|
|
110
|
+
...(isSafeCapabilityUrl(file.url) ? [{ label: 'Open file', onSelect: () => { openCapabilityUrl(file.url!); } }, { label: 'Download file', onSelect: () => { openCapabilityUrl(file.url!, true); } }, { label: 'Copy URL', onSelect: () => void navigator.clipboard.writeText(file.url!).catch(() => toast.error('Copy failed')) }] : []),
|
|
111
|
+
...(canManage ? [{ label: file.is_public ? 'Make private…' : 'Make public…', onSelect: () => void updatePublic() }, { label: 'Create share…', onSelect: () => void createShare() }, { label: 'Regenerate all renditions…', disabled: regenerationBusy || pollState === 'polling', onSelect: () => void runRegenerate() }] : []),
|
|
112
|
+
]} title={file.filename} subtitle={`${file.content_type} · ${file.file_size == null ? 'unknown size' : fmt.filesize(file.file_size)}`} icon="bi-file-earmark" chips={[{ text: file.upload_status, tone: file.upload_status === 'completed' ? 'success' : file.upload_status === 'failed' ? 'danger' : 'warning' }, { text: file.is_public ? 'Public' : 'Private', tone: file.is_public ? 'warning' : 'muted' }]} sections={[
|
|
113
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section">{!isSafeCapabilityUrl(file.url) && <p className="storage-unsafe">The backend URL is absent or unsafe; Open, Download, and Copy are refused.</p>}<FlatRow label="Filename">{file.filename}</FlatRow><FlatRow label="Category">{file.category || 'Unknown'}</FlatRow><FlatRow label="Upload status"><Badge tone={file.upload_status === 'completed' ? 'success' : 'warning'}>{file.upload_status}</Badge></FlatRow><FlatRow label="Public">{file.is_public ? 'Yes' : 'No'}</FlatRow><FlatRow label="Active">{file.is_active ? 'Yes' : 'No'}</FlatRow></div> },
|
|
102
114
|
{ key: 'preview', label: 'Preview', icon: 'bi-eye', render: () => <div className="detail-section"><FilePreview file={file} /></div> },
|
|
103
115
|
{ key: 'details', label: 'Details', icon: 'bi-card-list', render: () => <div className="detail-section"><FlatRow label="ID">{file.id}</FlatRow><FlatRow label="Content type">{file.content_type}</FlatRow><FlatRow label="Size">{file.file_size == null ? 'Unknown' : fmt.filesize(file.file_size)}</FlatRow><FlatRow label="Group">{typeof file.group === 'object' && file.group ? file.group.name || `#${file.group.id}` : file.group == null ? 'System' : `#${file.group}`}</FlatRow><FlatRow label="Backend">{typeof file.file_manager === 'object' && file.file_manager ? file.file_manager.name || `#${file.file_manager.id}` : `#${file.file_manager ?? '—'}`}</FlatRow><FlatRow label="Created">{fmt.datetime(file.created)}</FlatRow><FlatRow label="Modified">{fmt.datetime(file.modified)}</FlatRow></div> },
|
|
104
|
-
{ key: 'renditions', label: 'Renditions', icon: 'bi-images', render: () => { const pollingDisabled = regenerationBusy || pollState === 'polling'; return <div className="detail-section"><div className="storage-section-head"><div><h3>Renditions</h3><p className="dim">Derived from the role-keyed File graph; no duplicate rendition store.</p></div
|
|
105
|
-
{ key: 'shares', label: 'Shares', icon: 'bi-share', render: () => <div className="detail-section"><div className="storage-section-head"><div><h3>Visible shares</h3><p className="dim">Owner-visible rows only unless the caller separately holds global shortlink authority.</p></div>{canManage && <button className="btn btn-primary" onClick={() => void createShare()}>Create share…</button>}</div>{!canManage ? <p className="dim">Share controls are unavailable for this viewer.</p> : shares.isError ? <div className="form-alert">Visible shares unavailable. File inspection remains available.</div> : (shares.data?.rows ?? []).length === 0 ? <p className="dim">No visible shares.</p> : <div className="storage-shares">{shares.data!.rows.map((share) => <div key={share.id}><span><code>{share.code}</code><small>{share.is_active ? 'Active' : 'Inactive'} · {share.expires_at ? fmt.datetime(share.expires_at) : 'No expiry'} · {share.hit_count} hits · {share.track_clicks ? 'tracking' : 'not tracking'}{share.note ? ` · ${share.note}` : ''}</small></span><div className="storage-action-row"><button className="btn btn-compact" onClick={
|
|
116
|
+
{ key: 'renditions', label: 'Renditions', icon: 'bi-images', render: () => { const pollingDisabled = regenerationBusy || pollState === 'polling'; return <div className="detail-section"><div className="storage-section-head"><div><h3>Renditions</h3><p className="dim">Derived from the role-keyed File graph; no duplicate rendition store.</p></div></div><RenditionRows rows={renditionRows} canManage={canManage} pollingDisabled={pollingDisabled} onRegenerate={(roles) => void runRegenerate(roles)} />{pollState === 'polling' && <p className="dim"><i className="bi bi-arrow-repeat spin" /> Waiting for observable rendition change…</p>}{pollState === 'timeout' && <div className="form-alert">No convergence after 12 attempts. The job may still be running. <button className="btn btn-compact" onClick={() => void query.refetch()}>Refresh now</button></div>}{pollState && ['failed', 'expired'].includes(pollState) && <div className="form-alert">Polling stopped because upload state is {pollState}.</div>}</div>; } },
|
|
117
|
+
{ key: 'shares', label: 'Shares', icon: 'bi-share', render: () => <div className="detail-section"><div className="storage-section-head"><div><h3>Visible shares</h3><p className="dim">Owner-visible rows only unless the caller separately holds global shortlink authority.</p></div>{canManage && <button className="btn btn-primary" onClick={() => void createShare()}>Create share…</button>}</div>{!canManage ? <p className="dim">Share controls are unavailable for this viewer.</p> : shares.isError ? <div className="form-alert">Visible shares unavailable. File inspection remains available.</div> : (shares.data?.rows ?? []).length === 0 ? <p className="dim">No visible shares.</p> : <div className="storage-shares">{shares.data!.rows.map((share) => <div key={share.id}><span><code>{share.code}</code><small>{share.is_active ? 'Active' : 'Inactive'} · {share.expires_at ? fmt.datetime(share.expires_at) : 'No expiry'} · {share.hit_count} hits · {share.track_clicks ? 'tracking' : 'not tracking'}{share.note ? ` · ${share.note}` : ''}</small></span><div className="storage-action-row"><button className="btn btn-compact" disabled={pendingShares.has(share.id)} onClick={() => void toggleShare(share.id, !share.is_active)}>{share.is_active ? 'Deactivate' : 'Activate'}</button></div></div>)}</div>}</div> },
|
|
106
118
|
{ key: 'metadata', label: 'Metadata', icon: 'bi-braces', render: () => <div className="detail-section"><DataView data={file.metadata ?? {}} columns={2} /></div> },
|
|
107
|
-
|
|
119
|
+
|
|
108
120
|
]} initialSection="overview" onClose={onClose} />;
|
|
109
121
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
2
|
+
import { useState } from 'react';
|
|
4
3
|
import { useCan } from '../../client/runtime';
|
|
5
|
-
import {
|
|
4
|
+
import { Badge, CollectionSelect, ModelTable, fmt, modal, type BatchAction, type Column, type FilterDef } from '../../ui';
|
|
6
5
|
import { FileUploadSurface } from './FileUploadSurface';
|
|
6
|
+
import { FileView } from './FileView';
|
|
7
7
|
import { FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, exportFiles, openCapabilityUrl, saveFileAndReconcileGroup, type FileRow, type RelationRow } from './models';
|
|
8
8
|
|
|
9
9
|
const COLUMNS: Column<FileRow>[] = [
|
|
@@ -36,14 +36,13 @@ export function FilesPage() {
|
|
|
36
36
|
const queryClient = useQueryClient();
|
|
37
37
|
const canManage = useCan(STORAGE_MANAGE_PERMS).can;
|
|
38
38
|
const canChooseGroup = useCan(GROUP_DIRECTORY_PERMS).can;
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
const saveAccess = (row: FileRow, value: boolean) => saveFileAndReconcileGroup(queryClient, row.id, { is_public: value });
|
|
41
41
|
const batches: BatchAction<FileRow>[] = [
|
|
42
42
|
{ key: 'public', label: 'Make public', eligible: (row) => !row.is_public, confirm: 'Make the selected files public?', run: (row) => saveAccess(row, true) },
|
|
43
43
|
{ key: 'private', label: 'Make private', eligible: (row) => row.is_public, run: (row) => saveAccess(row, false) },
|
|
44
44
|
...(canChooseGroup ? [{ key: 'move', label: 'Move to group', confirm: false as const, prepare: (rows: FileRow[]) => modal.open<number>((close) => <MoveGroupDialog rows={rows} close={(value) => close(value as number)} />), run: (row: FileRow, prepared: unknown) => saveFileAndReconcileGroup(queryClient, row.id, { group: Number(prepared) }, Number(prepared)) }] : []),
|
|
45
45
|
{ key: 'download', label: 'Download selected', confirm: false, run: async (row) => { if (!openCapabilityUrl(row.url ?? '', true)) throw new Error(`${row.filename} has no safe download URL`); } },
|
|
46
|
-
{ key: 'delete', label: 'Delete', danger: true, confirm: 'Delete the selected files and their backend objects? This cannot be undone.', run: (row) => remove.mutateAsync({ id: row.id }) },
|
|
47
46
|
];
|
|
48
47
|
const table = (openUpload?: () => void) => <ModelTable model={FileModel} eyebrow="Infrastructure · Storage" title="Files" columns={COLUMNS} filters={FILTERS} presets={[{ key: 'all', label: 'All', params: {} }, { key: 'ready', label: 'Ready', params: { upload_status: 'completed' } }, { key: 'failed', label: 'Failed', params: { upload_status: 'failed' } }, { key: 'public', label: 'Public', params: { is_public: 'true' } }]} defaultSort="-created" searchable searchPlaceholder="Search filename or content type" selectable={canManage} batchActions={canManage ? batches : []} columnChooser persistState persistKey="admin:storage:files" exportFormats={['csv', 'json']} exporter={exportFiles} onAdd={openUpload} addLabel="Add File" onRowClick={(row) => showFileView(row.id)} />;
|
|
49
48
|
// Do not mount picker/drop/manager queries for view-only operators.
|
package/src/client/auth.ts
CHANGED
|
@@ -306,6 +306,12 @@ function adoptGrant(data: TokenGrant, remember = true): AuthUser {
|
|
|
306
306
|
|
|
307
307
|
/** Password login. `username` may be a username, email, or (if enabled) phone. */
|
|
308
308
|
export async function login(username: string, password: string, opts: LoginOptions = {}): Promise<LoginResult> {
|
|
309
|
+
// A malformed/expired session must not prevent the user from replacing it.
|
|
310
|
+
// The transport gate deliberately blocks authenticated requests when both
|
|
311
|
+
// tokens are unusable, but /api/login is itself the recovery path. Clear
|
|
312
|
+
// only that dead session; a valid session (including fresh-auth re-login)
|
|
313
|
+
// remains intact until the new grant is adopted.
|
|
314
|
+
if (checkTokenStatus().action === 'logout') clearTokens({ silent: true });
|
|
309
315
|
const body = await mojoCall('/api/login', { method: 'POST', body: { username, password, ...opts.extra } });
|
|
310
316
|
const data = body.data as TokenGrant;
|
|
311
317
|
if (data.mfa_required) {
|
|
@@ -599,6 +605,9 @@ export function exchangeAuthCode(code: string): Promise<AuthUser | null> {
|
|
|
599
605
|
|
|
600
606
|
async function doExchange(code: string): Promise<AuthUser | null> {
|
|
601
607
|
try {
|
|
608
|
+
// Hosted sign-in is also a recovery path: stale credentials must not
|
|
609
|
+
// block exchanging the fresh one-time login code.
|
|
610
|
+
if (checkTokenStatus().action === 'logout') clearTokens({ silent: true });
|
|
602
611
|
const body = await mojoCall('/api/auth/exchange', { method: 'POST', body: { code } });
|
|
603
612
|
return adoptGrant(body.data as TokenGrant);
|
|
604
613
|
} catch (error) {
|
|
@@ -721,7 +730,7 @@ export function initAuth(): void {
|
|
|
721
730
|
// Recursion guard: never gate the refresh call itself.
|
|
722
731
|
if (path === REFRESH_PATH) return;
|
|
723
732
|
// No stored token → pre-login / public flow, pass through.
|
|
724
|
-
if (!getAccessToken()) return;
|
|
733
|
+
if (!getAccessToken() && !getRefreshToken()) return;
|
|
725
734
|
await ensureValidToken();
|
|
726
735
|
// NOTE: web-mojo had to re-stamp Authorization here because its
|
|
727
736
|
// Rest snapshotted headers before the gate ran. Our transport
|
package/src/client/client.ts
CHANGED
|
@@ -23,20 +23,25 @@ import type { MojoList, Params } from './types';
|
|
|
23
23
|
export { MojoError, AuthRequiredError } from './errors';
|
|
24
24
|
|
|
25
25
|
const API_BASE: string = import.meta.env.VITE_MOJO_API ?? '';
|
|
26
|
+
/** Reserved for the controlled Django artifact producer; ordinary dev stays mock-backed. */
|
|
27
|
+
export function isPackagedAdmin(): boolean {
|
|
28
|
+
return import.meta.env.VITE_MOJO_PACKAGED_ADMIN === '1';
|
|
29
|
+
}
|
|
26
30
|
let mockModule: Promise<typeof import('./mock')> | null = null;
|
|
27
31
|
|
|
28
32
|
function loadMockModule(): Promise<typeof import('./mock')> {
|
|
33
|
+
if (import.meta.env.VITE_MOJO_PACKAGED_ADMIN === '1') throw new Error('Mock transport is unavailable in packaged Admin');
|
|
29
34
|
return mockModule ??= import('./mock');
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/** True when the in-memory mock transport is active (VITE_MOJO_API unset). */
|
|
33
38
|
export function usingMockTransport(): boolean {
|
|
34
|
-
return !API_BASE;
|
|
39
|
+
return !isPackagedAdmin() && !API_BASE;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/** The configured django-mojo origin ('' under the mock transport). */
|
|
38
43
|
export function apiOrigin(): string {
|
|
39
|
-
return API_BASE;
|
|
44
|
+
return isPackagedAdmin() ? window.location.origin : API_BASE;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
/**
|
|
@@ -81,6 +86,8 @@ export interface FetchOpts {
|
|
|
81
86
|
body?: Record<string, unknown>;
|
|
82
87
|
/** Cancels the selected transport request. */
|
|
83
88
|
signal?: AbortSignal;
|
|
89
|
+
/** Optional final validity check after auth refresh and immediately before sending. */
|
|
90
|
+
beforeSend?: () => void;
|
|
84
91
|
/** Explicit opt-out from a registered endpoint scope (scoped.ts) — for
|
|
85
92
|
* genuinely global calls inside a scoped family. Greppable, never silent. */
|
|
86
93
|
unscoped?: boolean;
|
|
@@ -117,6 +124,7 @@ async function transport(path: string, opts: FetchOpts): Promise<Envelope> {
|
|
|
117
124
|
// Authorization value is picked up below by construction (web-mojo had to
|
|
118
125
|
// re-stamp a snapshotted header here).
|
|
119
126
|
if (authHooks) await authHooks.preRequest(path);
|
|
127
|
+
opts.beforeSend?.();
|
|
120
128
|
|
|
121
129
|
const headers: Record<string, string> = {
|
|
122
130
|
'Content-Type': 'application/json',
|
|
@@ -125,11 +133,11 @@ async function transport(path: string, opts: FetchOpts): Promise<Envelope> {
|
|
|
125
133
|
const bearer = authHooks?.authHeader();
|
|
126
134
|
if (bearer) headers['Authorization'] = bearer;
|
|
127
135
|
|
|
128
|
-
if (
|
|
136
|
+
if (usingMockTransport()) {
|
|
129
137
|
const { mockFetch } = await loadMockModule();
|
|
130
138
|
return (await mockFetch(path, { ...opts, headers })) as Envelope;
|
|
131
139
|
}
|
|
132
|
-
const res = await fetch(`${
|
|
140
|
+
const res = await fetch(`${apiOrigin()}${path}${buildQuery(opts.params ?? {})}`, {
|
|
133
141
|
method: opts.method ?? 'GET',
|
|
134
142
|
headers,
|
|
135
143
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
@@ -265,7 +273,7 @@ export async function mojoDownload(endpoint: string, params: Params, format: 'cs
|
|
|
265
273
|
const bearer = authHooks?.authHeader();
|
|
266
274
|
if (bearer) headers['Authorization'] = bearer;
|
|
267
275
|
|
|
268
|
-
if (
|
|
276
|
+
if (usingMockTransport()) {
|
|
269
277
|
const { mockFetch } = await loadMockModule();
|
|
270
278
|
const body = (await mockFetch(endpoint, { params: withFormat, headers })) as Envelope;
|
|
271
279
|
if (body.status === false) {
|
|
@@ -276,7 +284,7 @@ export async function mojoDownload(endpoint: string, params: Params, format: 'cs
|
|
|
276
284
|
downloadBlob(new Blob([file.content], { type: file.mime }), file.filename);
|
|
277
285
|
return;
|
|
278
286
|
}
|
|
279
|
-
const res = await fetch(`${
|
|
287
|
+
const res = await fetch(`${apiOrigin()}${endpoint}${buildQuery(withFormat)}`, { headers });
|
|
280
288
|
if (!res.ok) throw new MojoError(`Export failed (HTTP ${res.status})`, res.status);
|
|
281
289
|
downloadBlob(await res.blob(), filename);
|
|
282
290
|
}
|