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.
Files changed (70) hide show
  1. package/docs/DetailView.md +19 -15
  2. package/docs/README.md +1 -0
  3. package/docs/admin-assistant.md +3 -1
  4. package/docs/admin-credentials.md +7 -0
  5. package/docs/admin-dashboard.md +1 -1
  6. package/docs/admin-dns.md +8 -2
  7. package/docs/admin-incidents.md +26 -3
  8. package/docs/admin-jobs.md +7 -4
  9. package/docs/admin-messaging.md +6 -5
  10. package/docs/admin-modals.md +122 -0
  11. package/docs/admin-network-security.md +4 -4
  12. package/docs/admin-phonehub.md +13 -5
  13. package/docs/admin-push.md +7 -0
  14. package/docs/admin-rules.md +8 -0
  15. package/docs/admin-shortlinks.md +2 -1
  16. package/docs/admin-storage.md +2 -1
  17. package/docs/auth.md +28 -0
  18. package/docs/client.md +12 -0
  19. package/docs/feedback.md +10 -0
  20. package/docs/forms.md +10 -0
  21. package/docs/popover.md +15 -0
  22. package/package.json +1 -1
  23. package/src/admin/assistant/api.ts +1 -1
  24. package/src/admin/assistant/pages.tsx +71 -17
  25. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  26. package/src/admin/core/index.ts +7 -2
  27. package/src/admin/credentials/group-api-keys.tsx +47 -43
  28. package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
  29. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  30. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  31. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  32. package/src/admin/dns/setup.ts +1 -1
  33. package/src/admin/domains/observability.ts +1 -1
  34. package/src/admin/identity/users/UserDetail.tsx +36 -20
  35. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  36. package/src/admin/identity/users/sections/actions.tsx +20 -9
  37. package/src/admin/incidents/EventDetail.tsx +147 -5
  38. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  39. package/src/admin/incidents/models.ts +1 -1
  40. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  41. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  42. package/src/admin/jobs/index.ts +1 -1
  43. package/src/admin/jobs/purge-preview.ts +25 -0
  44. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  45. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  46. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  47. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  48. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  49. package/src/admin/messaging/push/PushPage.tsx +20 -8
  50. package/src/admin/monitoring/LogInspector.tsx +3 -19
  51. package/src/admin/network/IPSetDetail.tsx +32 -63
  52. package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
  53. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  54. package/src/admin/phonehub/api.ts +6 -0
  55. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  56. package/src/admin/rules/condition-removal.ts +7 -0
  57. package/src/admin/security/geoip/models.ts +5 -0
  58. package/src/admin/security/tickets.tsx +40 -17
  59. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  60. package/src/admin/storage/BucketDetail.tsx +28 -9
  61. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  62. package/src/admin/storage/FileView.tsx +28 -16
  63. package/src/admin/storage/FilesPage.tsx +4 -5
  64. package/src/client/auth.ts +10 -1
  65. package/src/client/client.ts +14 -6
  66. package/src/client/mock.ts +89 -5
  67. package/src/client/upload.ts +1 -0
  68. package/src/ui/DetailView.tsx +22 -5
  69. package/src/ui/Popover.tsx +13 -12
  70. 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 { mojoSave, useCan } from '../../client/runtime';
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, CollectionSelect, DetailView, MarkdownView, ModelTable, RecordFeed, SchemaForm, fmt, modal, toast,
7
- type Column, type Field, type FilterDef, type FormData, type Tone,
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, TICKET_MANAGE_PERMS, TICKET_USER_LOOKUP_PERMS,
13
- TicketModel, buildTicketActionResponseBody, invalidateTicketDependents,
14
- isTicketActionDisabled, isTicketTerminal, knownOptionsWithCurrent, relationId, relationLabel,
15
- type TicketNoteAction, type TicketRow,
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: 'manage', label: 'Manage', icon: 'bi-sliders', render: () => (
470
+ key: 'properties', label: 'Properties', icon: 'bi-sliders', render: () => (
443
471
  <section className="ticket-detail-controls" aria-label="Ticket controls">
444
- <h4>Manage ticket</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
- <div className="ticket-control-actions">
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 { ArmedButton, DetailView, FlatRow, fmt, toast } from '../../ui';
2
+ import { useState } from 'react';
3
+ import { useCan } from '../../client/runtime';
4
+ import { DetailView, FlatRow, fmt, toast } from '../../ui';
4
5
  import {
5
- ShortlinkModel, ShortlinkReconciliationError, deleteShortlink, functionalShortlinkUrl,
6
- refreshShortlinks, setShortlinkActive, useTrackedCounts,
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
- { key: 'controls', label: 'Controls', icon: 'bi-sliders', render: () => <div className="detail-section">
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, emptyBucket, setBucketPublic,
6
- type BucketEmptyResult, type BucketMutationOutcome, type S3BucketRow, type S3FailureEvidence,
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
- const next = await emptyBucket(queryClient, bucket.name, exact);
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' }]} sections={[
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><div className="storage-action-row"><button className="btn btn-danger" onClick={() => void access(true)}>Make public…</button><button className="btn" onClick={() => void access(false)}>Make private…</button></div></div> },
75
- { key: 'danger', label: 'Danger zone', icon: 'bi-exclamation-octagon', permissions: BUCKET_MANAGE_PERMS, render: () => <div className="detail-section storage-danger"><h3>Empty bucket</h3><p>Irreversible and non-atomic against active writers. Bucket deletion is unavailable.</p><ArmedButton className="btn-danger" icon="bi-trash3" label="Empty bucket" armedLabel="Click again — remove all objects, versions, markers, and uploads" onConfirm={confirmEmpty} /></div> },
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 { Badge, DetailView, FlatRow, fmt, modal, toast } from '../../ui';
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
- return <DetailView 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={[
32
- { key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><div className="storage-action-row">{canManage && <><button className="btn" onClick={() => void edit('general')}>Edit</button><button className="btn" onClick={() => void edit('credentials')}>Credentials</button>{canChangeOwner && <button className="btn" onClick={() => void edit('owner')}>Owner scope</button>}<button className="btn" onClick={() => void toggle()}>{row.is_public ? 'Make private…' : 'Make public…'}</button></>}</div><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
+ 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
- { key: 'operations', label: 'Operations', icon: 'bi-tools', permissions: STORAGE_MANAGE_PERMS, render: () => <div className="detail-section"><h3>Provider operations</h3><div className="storage-action-row"><button className="btn" onClick={() => void action('test_connection')}>Test connection</button>{row.backend_type === 's3' && <><button className="btn" onClick={() => void action('check_cors')}>Check CORS</button><button className="btn" onClick={() => void action('fix_cors')}>Fix CORS</button></>}<button className="btn" onClick={() => void clone()}>Clone…</button></div><p className="dim">Backend deletion and public-access audit shortcuts are deliberately unavailable.</p></div> },
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 { ArmedButton, Badge, DataView, DetailView, FlatRow, SchemaForm, fmt, modal, toast } from '../../ui';
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 { normalizeRenditions, normalizeRenditionRoles, pollRenditionConvergence, renditionTargetSignature, type RenditionPollStop } from './file-renditions';
7
- import { FileModel, ShortLinkShareModel, STORAGE_MANAGE_PERMS, createFileShare, deleteFileShare, isSafeCapabilityUrl, openCapabilityUrl, setFileShareActive, saveFileAndReconcileGroup, type FileRenditionRow } from './models';
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
- const remove = FileModel.useDelete();
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
- return <DetailView 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={[
101
- { key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><div className="storage-action-row">{isSafeCapabilityUrl(file.url) && <><button className="btn" onClick={() => openCapabilityUrl(file.url!)}><i className="bi bi-box-arrow-up-right" /> Open</button><button className="btn" onClick={() => openCapabilityUrl(file.url!, true)}><i className="bi bi-download" /> Download</button><button className="btn" onClick={() => void navigator.clipboard.writeText(file.url!)}><i className="bi bi-copy" /> Copy URL</button></>}{canManage && <button className="btn" onClick={() => void updatePublic()}>{file.is_public ? 'Make private…' : 'Make public…'}</button>}</div>{!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> },
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>{canManage && <button className="btn" disabled={pollingDisabled} onClick={() => void runRegenerate()}>Regenerate all…</button>}</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>; } },
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={async () => { await setFileShareActive(share.id, !share.is_active); await shares.refetch(); }}>{share.is_active ? 'Deactivate' : 'Activate'}</button><button className="btn btn-compact" onClick={async () => { if (!await modal.confirm({ title: `Delete ${share.code}?`, message: 'This permanently deletes the share and its click history.', confirmText: 'Delete', danger: true })) return; await deleteFileShare(share.id); await shares.refetch(); }}>Delete</button></div></div>)}</div>}</div> },
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
- ...(canManage ? [{ key: 'danger', label: 'Danger zone', icon: 'bi-exclamation-octagon', permissions: STORAGE_MANAGE_PERMS, render: () => <div className="detail-section storage-danger"><h3>Delete file</h3><p>This removes the backend object and derived renditions. It cannot be undone.</p><ArmedButton className="btn-danger" icon="bi-trash" label="Delete file" armedLabel="Click again — open exact-filename confirmation" onConfirm={deleteOne} /></div> }] : []),
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 { Badge, CollectionSelect, ModelTable, fmt, modal, type BatchAction, type Column, type FilterDef } from '../../ui';
2
+ import { useState } from 'react';
4
3
  import { useCan } from '../../client/runtime';
5
- import { FileView } from './FileView';
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
- const remove = FileModel.useDelete();
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.
@@ -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
@@ -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 (!API_BASE) {
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(`${API_BASE}${path}${buildQuery(opts.params ?? {})}`, {
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 (!API_BASE) {
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(`${API_BASE}${endpoint}${buildQuery(withFormat)}`, { headers });
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
  }