portal-mojo 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +3 -0
  2. package/docs/DetailView.md +19 -15
  3. package/docs/ModelTable.md +26 -0
  4. package/docs/README.md +2 -1
  5. package/docs/admin-assistant.md +3 -1
  6. package/docs/admin-credentials.md +7 -0
  7. package/docs/admin-dashboard.md +1 -1
  8. package/docs/admin-dns.md +8 -2
  9. package/docs/admin-incidents.md +26 -3
  10. package/docs/admin-jobs.md +7 -4
  11. package/docs/admin-messaging.md +6 -5
  12. package/docs/admin-modals.md +122 -0
  13. package/docs/admin-network-security.md +4 -4
  14. package/docs/admin-phonehub.md +24 -6
  15. package/docs/admin-push.md +7 -0
  16. package/docs/admin-rules.md +8 -0
  17. package/docs/admin-shortlinks.md +2 -1
  18. package/docs/admin-storage.md +2 -1
  19. package/docs/auth.md +28 -0
  20. package/docs/client.md +12 -0
  21. package/docs/detail-primitives.md +12 -1
  22. package/docs/feedback.md +70 -1
  23. package/docs/forms.md +51 -0
  24. package/docs/loading.md +3 -2
  25. package/docs/popover.md +18 -3
  26. package/package.json +1 -1
  27. package/src/admin/assistant/api.ts +1 -1
  28. package/src/admin/assistant/pages.tsx +71 -17
  29. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  30. package/src/admin/core/index.ts +7 -2
  31. package/src/admin/credentials/group-api-keys.tsx +67 -43
  32. package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
  33. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  34. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  35. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  36. package/src/admin/dns/setup.ts +1 -1
  37. package/src/admin/domains/observability.ts +1 -1
  38. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  39. package/src/admin/identity/users/UserDetail.tsx +36 -20
  40. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  41. package/src/admin/identity/users/sections/actions.tsx +20 -9
  42. package/src/admin/incidents/EventDetail.tsx +147 -5
  43. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  44. package/src/admin/incidents/models.ts +1 -1
  45. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  46. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  47. package/src/admin/jobs/index.ts +1 -1
  48. package/src/admin/jobs/purge-preview.ts +25 -0
  49. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  50. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  51. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  52. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  53. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  54. package/src/admin/messaging/push/PushPage.tsx +20 -8
  55. package/src/admin/monitoring/LogInspector.tsx +3 -19
  56. package/src/admin/network/IPSetDetail.tsx +32 -63
  57. package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
  58. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  59. package/src/admin/phonehub/api.ts +11 -5
  60. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  61. package/src/admin/rules/condition-removal.ts +7 -0
  62. package/src/admin/security/geoip/models.ts +5 -0
  63. package/src/admin/security/tickets.tsx +40 -17
  64. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  65. package/src/admin/storage/BucketDetail.tsx +28 -9
  66. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  67. package/src/admin/storage/FileView.tsx +28 -16
  68. package/src/admin/storage/FilesPage.tsx +4 -5
  69. package/src/client/auth.ts +10 -1
  70. package/src/client/client.ts +14 -6
  71. package/src/client/mock.ts +90 -6
  72. package/src/client/upload.ts +1 -0
  73. package/src/ui/DetailView.tsx +22 -5
  74. package/src/ui/FormView.tsx +24 -1
  75. package/src/ui/ModelTable.tsx +12 -4
  76. package/src/ui/Popover.tsx +13 -12
  77. package/src/ui/detail/MetadataSection.tsx +46 -10
  78. package/src/ui/form-autosave.ts +134 -23
  79. package/src/ui/guardrail.tsx +125 -0
  80. package/src/ui/index.ts +1 -0
  81. package/src/ui/modal.tsx +8 -4
  82. package/src/ui/toast.tsx +48 -5
@@ -3,10 +3,15 @@ import { mojoCall, mojoList } from '../../client/runtime';
3
3
  import { PhoneConfigModel, sanitizePhoneConfigRow, sanitizePhoneNumberRow, type PhoneConfigRow, type PhoneNumberRow, type PhoneRelation } from './models';
4
4
 
5
5
  export const PHONE_GROUP_CHOICE_LIMIT=100;
6
+ export const DEFAULT_MOJO_REMOTE_URL='https://api.mojoverify.com';
6
7
  export const PHONE_SECRET_FIELDS=['twilio_account_sid','twilio_auth_token','aws_access_key_id','aws_secret_access_key','mojo_api_key'] as const;
7
8
  export type PhoneSecretField=(typeof PHONE_SECRET_FIELDS)[number];
8
9
  export type SecretEdit={mode:'untouched'}|{mode:'replace';value:string}|{mode:'clear';confirmed:true};
9
10
 
11
+ export function resolveMojoRemoteUrl(value:unknown):string{
12
+ return typeof value==='string'&&value.trim()?value.trim():DEFAULT_MOJO_REMOTE_URL;
13
+ }
14
+
10
15
  export async function normalizePhoneNumber(phoneNumber:string,countryCode?:string):Promise<string>{
11
16
  const body=await mojoCall('/api/phonehub/number/normalize',{method:'POST',body:{phone_number:phoneNumber,...(countryCode?{country_code:countryCode}:{})}});
12
17
  const value=(body.data as {phone_number?:unknown}|undefined)?.phone_number;
@@ -25,6 +30,7 @@ export async function lookupPhoneNumber(phoneNumber:string,previous?:PhoneNumber
25
30
  }
26
31
  export function buildPhoneConfigPayload(scalars:Record<string,unknown>,credentials:Partial<Record<PhoneSecretField,SecretEdit>>):Record<string,unknown>{
27
32
  const body={...scalars};
33
+ if(body.provider==='mojo')body.mojo_remote_url=resolveMojoRemoteUrl(body.mojo_remote_url);
28
34
  for(const field of PHONE_SECRET_FIELDS){const edit=credentials[field];if(!edit||edit.mode==='untouched')continue;if(edit.mode==='clear')body[field]=null;else{const value=edit.value.trim();if(!value)throw new Error(`${field} replacements cannot be empty.`);body[field]=value;}}
29
35
  return body;
30
36
  }
@@ -36,13 +42,13 @@ export async function savePhoneConfigImperative(qc:QueryClient,id:number|null,sc
36
42
  const row=sanitizePhoneConfigRow(response.data as PhoneConfigRow);
37
43
  qc.setQueryData(PhoneConfigModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PhoneConfigModel.keys.root});return row;
38
44
  }
39
- // Deliberately NOT client/action-result's `mojoAction`: `status` here is the
40
- // connection test's VERDICT (a result datum), not an action refusal — a failed
41
- // test must resolve so the panel can render it.
42
- export async function testPhoneConfigImperative(id:number):Promise<{status:boolean;message:string}>{
45
+ // PhoneConfig returns `success` as its provider verdict; envelope `status`
46
+ // only says the REST action ran. A failed test resolves for inline display.
47
+ export async function testPhoneConfigImperative(id:number):Promise<{status:boolean;message:string;testMode:boolean;errorCode:string|null}>{
43
48
  const response=await mojoCall(`${PhoneConfigModel.endpoint}/${id}`,{method:'POST',body:{test_connection:1}});
44
49
  const data=(response.data??response) as Record<string,unknown>;
45
- return {status:data.status!==false,message:typeof data.message==='string'?data.message:'Connection test completed.'};
50
+ const status=data.success===true;
51
+ return {status,testMode:data.test_mode===true,errorCode:typeof data.error==='string'?data.error:null,message:typeof data.message==='string'?data.message:status?'Connection verified.':'The server did not confirm the provider connection.'};
46
52
  }
47
53
  export async function fetchPhoneGroupChoices():Promise<PhoneRelation[]>{
48
54
  const result=await mojoList<Record<string,unknown>>('/api/group',{graph:'basic',sort:'name',start:0,size:PHONE_GROUP_CHOICE_LIMIT});
@@ -1,14 +1,17 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, useEffect, useRef } from 'react';
2
2
  import { useCan } from '../../client/runtime';
3
3
  import { Badge, DetailView, FlatRow, JsonBlock, ModelTable, fmt, modal, toast, type BatchAction, type Column } from '../../ui';
4
4
  import { HandlerChainBuilder } from './HandlerChainBuilder';
5
- import { parseHandlerChain, runtimeEffectiveHandlerChain, validateHandlerChain } from './handler-dsl';
5
+ import { conditionRemovalEffect } from './condition-removal';
6
6
  import { confirmCatchAllEnable, openHandlerChainEditor, openRuleEditor, openRuleSetEditor } from './editors';
7
+ import { parseHandlerChain, runtimeEffectiveHandlerChain, validateHandlerChain } from './handler-dsl';
7
8
  import { BUNDLE_BY_OPTIONS, RULESET_MANAGE_PERMS, RuleModel, RuleSetModel, type RuleRow } from './models';
8
9
 
9
10
  export function RuleSetDetail({ id, onClose }: { id: number; onClose: () => void }) {
10
- const { can: canManage } = useCan(RULESET_MANAGE_PERMS); const query = RuleSetModel.useOne(Number.isFinite(id) ? id : null);
11
- const rules = RuleModel.useList({ parent: id, size: 250, sort: 'index' }); const save = RuleSetModel.useSave(); const destroy = RuleSetModel.useDelete(); const destroyRule = RuleModel.useDelete();
11
+ const { can: canManage } = useCan(RULESET_MANAGE_PERMS);
12
+ const permission = useRef(canManage); permission.current = canManage;
13
+ useEffect(() => { permission.current = canManage; return () => { permission.current = false; }; }, [canManage]); const query = RuleSetModel.useOne(Number.isFinite(id) ? id : null);
14
+ const rules = RuleModel.useList({ parent: id, size: 250, sort: 'index' }); const save = RuleSetModel.useSave(); const destroyRule = RuleModel.useDelete();
12
15
  const duplicateIndexes = useMemo(() => { const counts = new Map<number, number>(); for (const row of rules.data?.rows ?? []) counts.set(row.index, (counts.get(row.index) ?? 0) + 1); return [...counts].filter(([, count]) => count > 1).map(([index]) => index); }, [rules.data]);
13
16
  if (!Number.isFinite(id)) return <div className="modal-pad text-bad">Invalid rule-set id. <button type="button" className="btn" onClick={onClose}>Close</button></div>;
14
17
  if (query.isPending || !query.data) return <div className="modal-pad dim">Loading rule set… <button type="button" className="btn" onClick={onClose}>Close</button></div>;
@@ -19,12 +22,33 @@ export function RuleSetDetail({ id, onClose }: { id: number; onClose: () => void
19
22
  { key: 'comparator', label: 'Comparator', render: (rule) => <code>{rule.comparator}</code> }, { key: 'value', label: 'Value', render: (rule) => <code>{rule.value_type}:{rule.value}</code> },
20
23
  { key: 'is_required', label: 'Required', render: (rule) => rule.is_required ? 'Yes' : 'No' },
21
24
  ];
22
- const ruleActions: BatchAction<RuleRow>[] = canManage ? [{ key: 'delete', label: 'Delete conditions', icon: 'bi-trash', danger: true, confirm: 'Delete the selected conditions? This cannot be undone.', run: (rule) => destroyRule.mutateAsync({ id: rule.id }) }] : [];
25
+ const ruleActions: BatchAction<RuleRow>[] = canManage ? [{
26
+ key: 'delete', label: 'Remove conditions', icon: 'bi-trash', danger: true, confirm: false,
27
+ prepare: async selected => {
28
+ try {
29
+ if (!permission.current) return null;
30
+ const [parent, children] = await Promise.all([query.refetch(), rules.refetch()]);
31
+ if (!parent.data || !children.data || parent.error || children.error) throw new Error('Authoritative rule state unavailable. No conditions removed.');
32
+ const current = selected.map(item => children.data!.rows.find(child => child.id === item.id));
33
+ if (current.some(item => !item)) throw new Error('Selected conditions changed; refresh and select again.');
34
+ const effect = conditionRemovalEffect(parent.data, children.data.count, selected.length);
35
+ const confirmed = await modal.confirm({ title: `Remove ${selected.length} conditions?`, danger: true, confirmText: 'Remove conditions', message: <div><p>Rule set: {parent.data.name} (#{id}) · {parent.data.match_by === 0 ? 'ALL' : 'ANY'}</p><ul>{current.map(item => <li key={item!.id}>{item!.name} (#{item!.id})</li>)}</ul><p>{effect}</p><p>These conditions cannot be restored by this action.</p></div> });
36
+ return confirmed && permission.current ? selected.map(item => item.id) : null;
37
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Condition state unavailable'); return null; }
38
+ },
39
+ runBatch: async selected => {
40
+ if (!permission.current) throw new Error('Permission to remove conditions was revoked.');
41
+ const results = await Promise.allSettled(selected.map(rule => destroyRule.mutateAsync({ id: rule.id })));
42
+ await Promise.all([query.refetch(), rules.refetch()]);
43
+ const failed = results.filter(result => result.status === 'rejected');
44
+ if (failed.length) throw new Error(`${results.length - failed.length} removed; ${failed.length} refused. Authoritative parent and conditions refreshed. ${String((failed[0] as PromiseRejectedResult).reason)}`);
45
+ },
46
+ }] : [];
23
47
  const setActive = async (next: boolean) => { if (next && !(await confirmCatchAllEnable(row, rules.data?.count ?? 0))) return; try { await save.mutateAsync({ id, changes: { is_active: next } }); toast.success(next ? 'Rule set enabled' : 'Rule set disabled'); } catch (error) { toast.error(error instanceof Error ? error.message : 'State change failed'); } };
24
48
  const bundleLabel = BUNDLE_BY_OPTIONS.find((option) => Number(option.value) === row.bundle_by)?.label ?? `Unknown (${row.bundle_by})`;
25
- return <DetailView icon="bi-diagram-3" title={row.name || `Rule set #${row.id}`} subtitle={`${row.category} · priority ${row.priority} · first active match wins`} chips={[{ text: row.is_active ? 'ACTIVE' : 'INACTIVE', tone: row.is_active ? 'success' : 'muted' }, { text: `${rules.data?.count ?? 0} CONDITIONS`, tone: duplicateIndexes.length ? 'warning' : 'info' }]} active={canManage ? { value: row.is_active, onChange: (next) => void setActive(next) } : undefined} onClose={onClose} menuContext={row} contextMenu={canManage ? [{ label: 'Edit rule set', onSelect: () => void openRuleSetEditor(row) }, { label: 'Edit handler chain', onSelect: () => void openHandlerChainEditor(row) }, { divider: true }, { label: 'Delete rule set and conditions', danger: true, onSelect: async () => { if (!(await modal.confirm({ title: 'Delete rule set?', message: `Delete “${row.name}” and all ${rules.data?.count ?? 0} conditions?`, confirmText: 'Delete rule set', danger: true }))) return; await destroy.mutateAsync({ id }); onClose(); } }] : []} sections={[
49
+ return <DetailView icon="bi-diagram-3" title={row.name || `Rule set #${row.id}`} subtitle={`${row.category} · priority ${row.priority} · first active match wins`} chips={[{ text: row.is_active ? 'ACTIVE' : 'INACTIVE', tone: row.is_active ? 'success' : 'muted' }, { text: `${rules.data?.count ?? 0} CONDITIONS`, tone: duplicateIndexes.length ? 'warning' : 'info' }]} active={canManage ? { value: row.is_active, disabled: save.isPending, onChange: (next) => void setActive(next) } : undefined} onClose={onClose} menuContext={row} contextMenu={canManage ? [{ label: 'Edit rule set', onSelect: () => void openRuleSetEditor(row) }, { label: 'Edit handler chain', onSelect: () => void openHandlerChainEditor(row) }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: save.isPending, onSelect: () => void setActive(!row.is_active) }] : []} sections={[
26
50
  { key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-stack"><FlatRow label="State">{row.is_active ? 'Active' : 'Inactive'}</FlatRow><FlatRow label="Category"><code>{row.category}</code></FlatRow><FlatRow label="Priority">{row.priority} (lower is evaluated first within this category)</FlatRow><FlatRow label="Matching">{row.match_by === 0 ? 'All conditions' : row.match_by === 1 ? 'Any condition' : `Unknown (${row.match_by})`}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> },
27
- { key: 'conditions', label: 'Conditions', icon: 'bi-funnel', render: () => <div>{duplicateIndexes.length > 0 && <div className="rule-priority-warning">Duplicate condition indexes {duplicateIndexes.join(', ')} have undefined tie order.</div>}<ModelTable model={RuleModel} title="Conditions" columns={ruleColumns} searchable={false} defaultParams={{ parent: id, sort: 'index' }} onRowClick={canManage ? (rule) => void openRuleEditor(id, rule, nextIndex) : undefined} {...(canManage ? { addLabel: 'Add condition', onAdd: () => void openRuleEditor(id, undefined, nextIndex), selectable: true, batchActions: ruleActions } : {})} /></div> },
51
+ { key: 'conditions', label: 'Conditions', icon: 'bi-funnel', render: () => <div>{duplicateIndexes.length > 0 && <div className="rule-priority-warning">Duplicate condition indexes {duplicateIndexes.join(', ')} have undefined tie order.</div>}<ModelTable model={RuleModel} title="Conditions" columns={ruleColumns} searchable={false} fixedParams={{ parent: id }} defaultParams={{ sort: 'index' }} onRowClick={canManage ? (rule) => void openRuleEditor(id, rule, nextIndex) : undefined} {...(canManage ? { addLabel: 'Add condition', onAdd: () => void openRuleEditor(id, undefined, nextIndex), selectable: true, batchActions: ruleActions } : {})} /></div> },
28
52
  { key: 'triggering', label: 'Triggering', icon: 'bi-lightning', render: () => <div className="detail-stack"><FlatRow label="Bundle by">{bundleLabel}</FlatRow><FlatRow label="Bundle window">{row.bundle_minutes == null ? 'Unbounded' : row.bundle_minutes === 0 ? 'Disabled (no time bundling)' : `${row.bundle_minutes} minutes`}</FlatRow><FlatRow label="Trigger">{row.trigger_count == null ? 'Immediately on first event' : `${row.trigger_count} events${row.trigger_window == null ? '' : ` within ${row.trigger_window} minutes`}`}</FlatRow><FlatRow label="Re-trigger">{row.retrigger_every == null ? 'Once per incident' : `Every ${row.retrigger_every} additional events`}</FlatRow></div> },
29
53
  { key: 'handler', label: 'Handler', icon: 'bi-diagram-3', render: () => <div><div className="handler-section-actions"><p>{runtimeEffectiveHandlerChain(chain).length} runtime-effective dispatch step(s). Publication follows the order below; completion is asynchronous.</p>{canManage && <button type="button" className="btn btn-primary" onClick={() => void openHandlerChainEditor(row)}>Edit chain</button>}</div><HandlerChainBuilder value={row.handler ?? ''} onChange={() => undefined} disabled />{handlerIssues.length > 0 && <p className="dim">Legacy warnings remain visible and byte-preserved until explicitly changed.</p>}</div> },
30
54
  { key: 'agent', label: 'Agent prompt', icon: 'bi-stars', render: () => <div className="detail-stack"><FlatRow label="Prompt">{typeof row.metadata.agent_prompt === 'string' ? row.metadata.agent_prompt : 'No agent prompt configured.'}</FlatRow><p className="dim">The prompt is stored in RuleSet metadata. General edits merge only targeted metadata keys.</p></div> },
@@ -0,0 +1,7 @@
1
+ /** Empty ALL and ANY condition sets both match every event server-side. */
2
+ export function conditionRemovalEffect(parent: { match_by: number; is_active: boolean }, count: number, selected: number): string {
3
+ if (parent.match_by !== 0 && parent.match_by !== 1) throw new Error('Unknown matching mode; refresh and inspect before removing conditions.');
4
+ if (selected < 1 || selected > count) throw new Error('Condition selection changed; refresh before removing conditions.');
5
+ if (count === selected) return `${parent.is_active ? 'This active' : 'This inactive'} rule set becomes catch-all: either matching mode accepts every event without conditions.`;
6
+ return parent.match_by === 0 ? 'ALL mode: removal broadens matching because fewer conditions must hold.' : 'ANY mode: removal narrows matching because fewer alternatives can match.';
7
+ }
@@ -113,6 +113,11 @@ export interface GeoLocatedIPRow {
113
113
  whitelisted_reason: string | null;
114
114
  whitelisted_until: number | null;
115
115
 
116
+ firewall_generation: number;
117
+ firewall_pending: boolean;
118
+ firewall_sync_error: string;
119
+ firewall_observed_at: number | null;
120
+
116
121
  expires_at: number | null;
117
122
 
118
123
  /** `extra` on every graph — computed Python properties. */
@@ -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.