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.
- package/README.md +3 -0
- package/docs/DetailView.md +19 -15
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +2 -1
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +24 -6
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +70 -1
- package/docs/forms.md +51 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +18 -3
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +67 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +11 -5
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +90 -6
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/detail/MetadataSection.tsx +46 -10
- package/src/ui/form-autosave.ts +134 -23
- package/src/ui/guardrail.tsx +125 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +8 -4
- 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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
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
|
-
|
|
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 {
|
|
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);
|
|
11
|
-
const
|
|
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 ? [{
|
|
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) }, {
|
|
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}
|
|
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 {
|
|
2
|
+
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
|
|
4
3
|
import type { RecordFeedItem } from '../../client/record-feed';
|
|
4
|
+
import { createTicketNoteAdapter } from '../../client/record-feed';
|
|
5
|
+
import { mojoSave, useCan } from '../../client/runtime';
|
|
5
6
|
import {
|
|
6
|
-
Badge,
|
|
7
|
-
|
|
7
|
+
Badge,
|
|
8
|
+
CollectionSelect,
|
|
9
|
+
DetailView,
|
|
10
|
+
MarkdownView,
|
|
11
|
+
ModelTable,
|
|
12
|
+
RecordFeed,
|
|
13
|
+
SchemaForm,
|
|
14
|
+
fmt,
|
|
15
|
+
modal,
|
|
16
|
+
toast,
|
|
17
|
+
type Column,
|
|
18
|
+
type Field,
|
|
19
|
+
type FilterDef,
|
|
20
|
+
type FormData,
|
|
21
|
+
type Tone,
|
|
8
22
|
} from '../../ui';
|
|
9
23
|
import { AssistantContextLauncher } from '../assistant/launchers';
|
|
10
|
-
import { createTicketNoteAdapter } from '../../client/record-feed';
|
|
11
24
|
import {
|
|
12
|
-
MaestroItemLinkModel,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
MaestroItemLinkModel,
|
|
26
|
+
TICKET_MANAGE_PERMS,
|
|
27
|
+
TICKET_USER_LOOKUP_PERMS,
|
|
28
|
+
TicketModel,
|
|
29
|
+
buildTicketActionResponseBody,
|
|
30
|
+
invalidateTicketDependents,
|
|
31
|
+
isTicketActionDisabled,
|
|
32
|
+
isTicketTerminal,
|
|
33
|
+
knownOptionsWithCurrent,
|
|
34
|
+
relationId,
|
|
35
|
+
relationLabel,
|
|
36
|
+
type TicketNoteAction,
|
|
37
|
+
type TicketRow,
|
|
16
38
|
} from './models';
|
|
17
39
|
|
|
18
40
|
export const TICKET_STATUSES = [
|
|
@@ -407,6 +429,12 @@ function TicketDetail({ ticketId, close }: { ticketId: number; close(): void })
|
|
|
407
429
|
]}
|
|
408
430
|
onClose={close}
|
|
409
431
|
menuContext={ticket}
|
|
432
|
+
contextMenu={canManage ? [
|
|
433
|
+
{ label: 'Edit ticket', disabled: busy, onSelect: () => void openTicketEditor(ticket) },
|
|
434
|
+
{ label: llmEnabled ? 'Disable LLM' : 'Enable LLM', disabled: busy, onSelect: () => void runLlm() },
|
|
435
|
+
{ label: 'Close ticket', disabled: busy || isTicketTerminal(ticket.status), onSelect: () => void patch({ status: 'closed' }, 'Ticket closed') },
|
|
436
|
+
{ label: 'Push to Maestro', disabled: busy || Boolean(link), onSelect: () => void push() },
|
|
437
|
+
] : []}
|
|
410
438
|
sections={[
|
|
411
439
|
{
|
|
412
440
|
key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => (
|
|
@@ -439,9 +467,9 @@ function TicketDetail({ ticketId, close }: { ticketId: number; close(): void })
|
|
|
439
467
|
),
|
|
440
468
|
},
|
|
441
469
|
...(canManage ? [{
|
|
442
|
-
key: '
|
|
470
|
+
key: 'properties', label: 'Properties', icon: 'bi-sliders', render: () => (
|
|
443
471
|
<section className="ticket-detail-controls" aria-label="Ticket controls">
|
|
444
|
-
<h4>
|
|
472
|
+
<h4>Ticket properties</h4>
|
|
445
473
|
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
446
474
|
<div className="ticket-control-grid">
|
|
447
475
|
<label className="field"><span className="field-label">Status</span>
|
|
@@ -481,12 +509,7 @@ function TicketDetail({ ticketId, close }: { ticketId: number; close(): void })
|
|
|
481
509
|
if (descriptionDraft !== (ticket.description ?? '')) void patch({ description: descriptionDraft }, 'Description updated');
|
|
482
510
|
}} />
|
|
483
511
|
</label>
|
|
484
|
-
|
|
485
|
-
<button type="button" className="btn" disabled={busy} onClick={() => void runLlm()}>{llmEnabled ? 'Disable LLM' : 'Enable LLM'}</button>
|
|
486
|
-
<button type="button" className="btn" disabled={busy || isTicketTerminal(ticket.status)} onClick={() => void patch({ status: 'closed' }, 'Ticket closed')}>Close ticket</button>
|
|
487
|
-
<button type="button" className="btn" disabled={busy || Boolean(link)} onClick={() => void push()}>{pushToMaestro.isPending ? 'Queueing…' : 'Push to Maestro'}</button>
|
|
488
|
-
<button type="button" className="btn" disabled={busy} onClick={() => void openTicketEditor(ticket)}>Edit all fields</button>
|
|
489
|
-
</div>
|
|
512
|
+
|
|
490
513
|
</section>
|
|
491
514
|
),
|
|
492
515
|
}] : []),
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useCan } from '../../client/runtime';
|
|
4
|
+
import { DetailView, FlatRow, fmt, toast } from '../../ui';
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
SHORTLINK_MANAGE_PERMISSIONS,
|
|
7
|
+
ShortlinkModel,
|
|
8
|
+
ShortlinkReconciliationError,
|
|
9
|
+
functionalShortlinkUrl,
|
|
10
|
+
refreshShortlinks,
|
|
11
|
+
setShortlinkActive,
|
|
12
|
+
useTrackedCounts,
|
|
7
13
|
} from './models';
|
|
8
14
|
|
|
9
15
|
export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
10
16
|
const queryClient = useQueryClient();
|
|
17
|
+
const canManage = useCan(SHORTLINK_MANAGE_PERMISSIONS).can;
|
|
18
|
+
const [pending, setPending] = useState(false);
|
|
11
19
|
const query = ShortlinkModel.useOne(id);
|
|
12
20
|
const [retryBlocked, setRetryBlocked] = useState(false);
|
|
13
21
|
const counts = useTrackedCounts(query.data ?? null);
|
|
@@ -15,13 +23,14 @@ export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => vo
|
|
|
15
23
|
const row = query.data;
|
|
16
24
|
const capability = functionalShortlinkUrl(row.code);
|
|
17
25
|
const act = async (action: () => Promise<unknown>, success: string): Promise<boolean> => {
|
|
18
|
-
if (retryBlocked) return false;
|
|
26
|
+
if (!canManage || pending || retryBlocked) return false;
|
|
27
|
+
setPending(true);
|
|
19
28
|
try { await action(); toast.success(success); await query.refetch(); return true; }
|
|
20
29
|
catch (error) {
|
|
21
30
|
if (error instanceof ShortlinkReconciliationError) setRetryBlocked(true);
|
|
22
31
|
toast.error(error instanceof Error ? error.message : 'Shortlink operation failed');
|
|
23
32
|
return false;
|
|
24
|
-
}
|
|
33
|
+
} finally { setPending(false); }
|
|
25
34
|
};
|
|
26
35
|
const refresh = async () => {
|
|
27
36
|
try { await refreshShortlinks(queryClient); await query.refetch(); setRetryBlocked(false); toast.success('Authoritative shortlink state refreshed'); }
|
|
@@ -29,6 +38,8 @@ export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => vo
|
|
|
29
38
|
};
|
|
30
39
|
return <DetailView title={row.code} subtitle={row.source || 'No source label'} icon="bi-link-45deg"
|
|
31
40
|
chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }]}
|
|
41
|
+
active={canManage ? { value: row.is_active, disabled: pending || retryBlocked, onChange: next => void act(() => setShortlinkActive(queryClient, row, next), next ? 'Shortlink reactivated' : 'Shortlink deactivated') } : undefined}
|
|
42
|
+
contextMenu={canManage ? [{ label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: pending || retryBlocked, onSelect: () => void act(() => setShortlinkActive(queryClient, row, !row.is_active), 'Shortlink updated') }] : []}
|
|
32
43
|
onClose={onClose} sections={[
|
|
33
44
|
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section">
|
|
34
45
|
{retryBlocked && <div className="form-alert">The last change may have persisted, but reconciliation failed. Refresh successfully before retrying.</div>}
|
|
@@ -45,11 +56,6 @@ export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => vo
|
|
|
45
56
|
<h3>Tracked records</h3><p className="dim">Human and bot values count retained tracking records. Total hits may also include requests made while tracking was off.</p>
|
|
46
57
|
{counts.isLoading ? <p>Loading counts…</p> : counts.isError ? <div className="form-alert">Tracked counts unavailable.</div> : <div className="shortlink-counts"><div><b>{counts.data?.human ?? 0}</b><span>Human records</span></div><div><b>{counts.data?.bot ?? 0}</b><span>Bot records</span></div><div><b>{counts.data?.remainder ?? row.hit_count}</b><span>Hit-count remainder</span></div></div>}
|
|
47
58
|
</div> },
|
|
48
|
-
|
|
49
|
-
<h3>{row.is_active ? 'Deactivate link' : 'Reactivate link'}</h3><p>Changes use a single non-retrying request followed by an authoritative refresh.</p>
|
|
50
|
-
<button className="btn" disabled={retryBlocked} onClick={() => void act(() => setShortlinkActive(queryClient, row, !row.is_active), row.is_active ? 'Shortlink deactivated' : 'Shortlink reactivated')}>{row.is_active ? 'Deactivate' : 'Reactivate'}</button>
|
|
51
|
-
<h3>Delete link</h3><p>Deletion is permanent. Click history is removed by the backend cascade.</p>
|
|
52
|
-
<ArmedButton label="Delete shortlink" armedLabel={`Click again to delete ${row.code}`} disabled={retryBlocked} onConfirm={async () => { if (await act(() => deleteShortlink(queryClient, row), 'Shortlink deleted')) onClose(); }} />
|
|
53
|
-
</div> },
|
|
59
|
+
|
|
54
60
|
]} />;
|
|
55
61
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useCan } from '../../client/runtime';
|
|
3
4
|
import { ArmedButton, DetailView, FlatRow, SchemaForm, fmt, modal, toast } from '../../ui';
|
|
4
5
|
import {
|
|
5
|
-
BUCKET_MANAGE_PERMS,
|
|
6
|
-
|
|
6
|
+
BUCKET_MANAGE_PERMS,
|
|
7
|
+
emptyBucket,
|
|
8
|
+
setBucketPublic,
|
|
9
|
+
type BucketEmptyResult,
|
|
10
|
+
type BucketMutationOutcome,
|
|
11
|
+
type S3BucketRow,
|
|
12
|
+
type S3FailureEvidence,
|
|
7
13
|
} from './models';
|
|
8
14
|
|
|
9
15
|
function message(error: unknown): string { return error instanceof Error ? error.message : 'Storage operation failed'; }
|
|
@@ -29,6 +35,9 @@ function Outcome({ value }: { value: BucketMutationOutcome<unknown> | null }) {
|
|
|
29
35
|
|
|
30
36
|
export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose: () => void }) {
|
|
31
37
|
const queryClient = useQueryClient();
|
|
38
|
+
const canManage = useCan(BUCKET_MANAGE_PERMS).can;
|
|
39
|
+
const permission = useRef(canManage); permission.current = canManage;
|
|
40
|
+
useEffect(() => { permission.current = canManage; return () => { permission.current = false; }; }, [canManage]);
|
|
32
41
|
const [outcome, setOutcome] = useState<BucketMutationOutcome<unknown> | null>(null);
|
|
33
42
|
const [emptyResult, setEmptyResult] = useState<BucketEmptyResult | null>(null);
|
|
34
43
|
|
|
@@ -40,13 +49,15 @@ export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose
|
|
|
40
49
|
: <>This applies the all-private Public Access Block posture to <code>{bucket.name}</code>. The operation is externally observable and not atomic.</>,
|
|
41
50
|
confirmText: isPublic ? 'Make public' : 'Make private', danger: isPublic,
|
|
42
51
|
});
|
|
43
|
-
if (!confirmed) return;
|
|
52
|
+
if (!confirmed || !permission.current) return;
|
|
44
53
|
const next = await setBucketPublic(queryClient, bucket.name, isPublic);
|
|
45
54
|
setOutcome(next);
|
|
46
55
|
if (next.data) toast.success(`${bucket.name} verified ${next.data.is_public ? 'public' : 'private'}`);
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
const confirmEmpty = async () => {
|
|
59
|
+
if (!permission.current) return;
|
|
60
|
+
let pending = false;
|
|
50
61
|
await modal.open((close) => (
|
|
51
62
|
<div className="modal-pad">
|
|
52
63
|
<h2 className="modal-title">Empty {bucket.name}</h2>
|
|
@@ -56,9 +67,13 @@ export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose
|
|
|
56
67
|
submitText="Empty bucket"
|
|
57
68
|
onCancel={() => close(null)}
|
|
58
69
|
onSubmit={async (data) => {
|
|
70
|
+
if (!permission.current) throw new Error('Permission to empty this bucket was revoked.');
|
|
59
71
|
const exact = String(data.confirm_name ?? '');
|
|
60
72
|
if (exact !== bucket.name) throw new Error('Bucket name does not match exactly. No request was sent.');
|
|
61
|
-
|
|
73
|
+
pending = true;
|
|
74
|
+
let next;
|
|
75
|
+
try { next = await emptyBucket(queryClient, bucket.name, exact); }
|
|
76
|
+
finally { pending = false; }
|
|
62
77
|
setOutcome(next);
|
|
63
78
|
if (next.error) throw next.error;
|
|
64
79
|
if (next.data) { setEmptyResult(next.data); toast.success(`${bucket.name} empty acknowledged`); }
|
|
@@ -66,12 +81,16 @@ export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose
|
|
|
66
81
|
}}
|
|
67
82
|
/>
|
|
68
83
|
</div>
|
|
69
|
-
), { size: 'sm' });
|
|
84
|
+
), { size: 'sm', canDismiss: () => !pending });
|
|
70
85
|
};
|
|
71
86
|
|
|
72
|
-
return <DetailView title={bucket.name} subtitle="Account-level S3 bucket" icon="bi-bucket" chips={[{ text: 'Global', tone: 'info' }]}
|
|
87
|
+
return <DetailView title={bucket.name} subtitle="Account-level S3 bucket" icon="bi-bucket" chips={[{ text: 'Global', tone: 'info' }]} contextMenu={canManage ? [
|
|
88
|
+
{ label: 'Make public…', danger: true, onSelect: () => void access(true) },
|
|
89
|
+
{ label: 'Make private…', onSelect: () => void access(false) },
|
|
90
|
+
{ label: 'Empty bucket…', danger: true, onSelect: () => void modal.open(close => <div className="modal-pad"><h2 className="modal-title">Empty {bucket.name}</h2><p className="modal-message">Permanently removes objects, versions, delete markers and multipart uploads. Stop writers first. The bucket is retained.</p><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Cancel</button><ArmedButton className="btn-danger" label="Empty bucket" armedLabel="Continue to exact-name confirmation" onConfirm={async () => { await confirmEmpty(); close(null); }} /></div></div>, { size: 'sm' }) },
|
|
91
|
+
] : []} sections={[
|
|
73
92
|
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><FlatRow label="Name"><code>{bucket.name}</code></FlatRow><FlatRow label="Created">{fmt.datetime(bucket.created)}</FlatRow><FlatRow label="Inventory">Complete account inventory row</FlatRow><Outcome value={outcome} />{emptyResult && <div className="storage-counts"><b>Last acknowledged empty counts</b><span>Objects {emptyResult.deleted_objects}</span><span>Versions {emptyResult.deleted_versions}</span><span>Delete markers {emptyResult.deleted_markers}</span><span>Multipart uploads {emptyResult.aborted_uploads}</span></div>}</div> },
|
|
74
|
-
{ key: 'access', label: 'Access', icon: 'bi-shield-lock', permissions: BUCKET_MANAGE_PERMS, render: () => <div className="detail-section"><h3>Verified access posture</h3><p className="dim">Every attempt refreshes inventory in a finally path. A rejection can still represent partial external change.</p
|
|
75
|
-
|
|
93
|
+
{ key: 'access', label: 'Access', icon: 'bi-shield-lock', permissions: BUCKET_MANAGE_PERMS, render: () => <div className="detail-section"><h3>Verified access posture</h3><p className="dim">Every attempt refreshes inventory in a finally path. A rejection can still represent partial external change.</p></div> },
|
|
94
|
+
|
|
76
95
|
]} initialSection="overview" onClose={onClose} />;
|
|
77
96
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from 'react';
|
|
3
3
|
import { useCan } from '../../client/runtime';
|
|
4
|
+
import { Badge, DetailView, FlatRow, fmt, modal, toast } from '../../ui';
|
|
4
5
|
import { FileView } from './FileView';
|
|
6
|
+
import { FileManagerModel, FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, USER_DIRECTORY_PERMS, runFileManagerAction, saveFileManagerAtomic, storageRelationId } from './models';
|
|
5
7
|
import { openFileManagerEditor } from './storage-dialogs';
|
|
6
|
-
import { FileManagerModel, FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, USER_DIRECTORY_PERMS, storageRelationId, runFileManagerAction, saveFileManagerAtomic } from './models';
|
|
7
8
|
|
|
8
9
|
function NestedFiles({ managerId }: { managerId: number }) {
|
|
9
10
|
const query = FileModel.useList({ file_manager: managerId, start: 0, size: 10, sort: '-created' });
|
|
@@ -15,6 +16,7 @@ function NestedFiles({ managerId }: { managerId: number }) {
|
|
|
15
16
|
|
|
16
17
|
export function FileManagerDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
17
18
|
const queryClient = useQueryClient();
|
|
19
|
+
const [pending, setPending] = useState(false);
|
|
18
20
|
const query = FileManagerModel.useOne(id);
|
|
19
21
|
const managerPermission = useCan(STORAGE_MANAGE_PERMS);
|
|
20
22
|
const groupDirectoryPermission = useCan(GROUP_DIRECTORY_PERMS);
|
|
@@ -28,9 +30,25 @@ export function FileManagerDetail({ id, onClose }: { id: number; onClose: () =>
|
|
|
28
30
|
const action = async (name: 'test_connection' | 'check_cors' | 'fix_cors') => { try { const result = await runFileManagerAction(id, name); toast.success(name === 'test_connection' ? 'Connection succeeded' : `${name.replace('_', ' ')} completed`); await modal.open((close) => <div className="modal-pad"><h2 className="modal-title">{name.replaceAll('_', ' ')}</h2><pre className="storage-result-json">{JSON.stringify(result, null, 2)}</pre><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>); } catch (error) { toast.error(error instanceof Error ? error.message : `${name} failed`); } };
|
|
29
31
|
const clone = async () => { const ok = await modal.confirm({ title: 'Clone backend?', message: <>Create a private clone of <b>{row.name}</b> under the same explicit owner scope?</>, confirmText: 'Clone' }); if (!ok) return; try { await runFileManagerAction(id, 'clone'); await FileManagerModel.invalidate(queryClient); toast.success(`${row.name} cloned`); } catch (error) { toast.error(error instanceof Error ? error.message : 'Clone failed'); } };
|
|
30
32
|
const toggle = async () => { const ok = await modal.confirm({ title: row.is_public ? 'Make backend private?' : 'Make backend public?', message: 'This changes prefix policy on the storage provider. Continue?', confirmText: row.is_public ? 'Make private' : 'Make public', danger: !row.is_public }); if (!ok) return; try { await saveFileManagerAtomic({ queryClient, id, changes: { is_public: !row.is_public } }); await query.refetch(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Access change failed'); } };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
const setActive = async (next: boolean) => {
|
|
34
|
+
if (!canManage || pending) return; setPending(true);
|
|
35
|
+
try { await saveFileManagerAtomic({ queryClient, id, changes: { is_active: next } }); await query.refetch(); }
|
|
36
|
+
catch (error) { toast.error(error instanceof Error ? error.message : 'State change failed'); }
|
|
37
|
+
finally { setPending(false); }
|
|
38
|
+
};
|
|
39
|
+
return <DetailView active={canManage ? { value: row.is_active, disabled: pending, onChange: next => void setActive(next) } : undefined}
|
|
40
|
+
contextMenu={canManage ? [
|
|
41
|
+
{ label: 'Edit backend', onSelect: () => void edit('general') },
|
|
42
|
+
{ label: 'Credentials…', onSelect: () => void edit('credentials') },
|
|
43
|
+
...(canChangeOwner ? [{ label: 'Owner scope…', onSelect: () => void edit('owner') }] : []),
|
|
44
|
+
{ label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: pending, onSelect: () => void setActive(!row.is_active) },
|
|
45
|
+
{ label: row.is_public ? 'Make private…' : 'Make public…', onSelect: () => void toggle() },
|
|
46
|
+
{ label: 'Test connection', onSelect: () => void action('test_connection') },
|
|
47
|
+
...(row.backend_type === 's3' ? [{ label: 'Check CORS', onSelect: () => void action('check_cors') }, { label: 'Fix CORS', onSelect: () => void action('fix_cors') }] : []),
|
|
48
|
+
{ label: 'Clone…', onSelect: () => void clone() },
|
|
49
|
+
] : []} title={row.name} subtitle={`${row.backend_type} · ${row.backend_url}`} icon="bi-hdd-stack" chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }, { text: row.is_public ? 'Public' : 'Private', tone: row.is_public ? 'warning' : 'success' }]} sections={[
|
|
50
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><FlatRow label="Backend"><Badge tone="info">{row.backend_type}</Badge></FlatRow><FlatRow label="Use">{row.use || '—'}</FlatRow><FlatRow label="URL"><code>{row.backend_url}</code></FlatRow><FlatRow label="Owner">{storageRelationId(row.group) != null ? `Group #${storageRelationId(row.group)}` : storageRelationId(row.user) != null ? `User #${storageRelationId(row.user)}` : 'System'}</FlatRow><FlatRow label="Default">{row.is_default ? 'Yes' : 'No'}</FlatRow><FlatRow label="Region">{row.aws_region || '—'}</FlatRow><FlatRow label="Access key">{row.aws_key_masked || 'Not configured'}</FlatRow><FlatRow label="Secret key">{row.aws_secret_masked || 'Not configured'}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> },
|
|
33
51
|
{ key: 'files', label: 'Files', icon: 'bi-files', render: () => <div className="detail-section"><h3>Recent files</h3><p className="dim">Newest 10 for this backend. This bounded child inventory does not own URL table state.</p><NestedFiles managerId={id} /></div> },
|
|
34
|
-
|
|
52
|
+
|
|
35
53
|
]} initialSection="overview" onClose={onClose} />;
|
|
36
54
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
3
|
import { useCan } from '../../client/runtime';
|
|
4
|
+
import { Badge, DataView, DetailView, FlatRow, SchemaForm, fmt, modal, toast } from '../../ui';
|
|
5
5
|
import { FilePreview, filePreviewCategory } from './FilePreview';
|
|
6
|
-
import {
|
|
7
|
-
import { FileModel,
|
|
6
|
+
import { normalizeRenditionRoles, normalizeRenditions, pollRenditionConvergence, renditionTargetSignature, type RenditionPollStop } from './file-renditions';
|
|
7
|
+
import { FileModel, STORAGE_MANAGE_PERMS, ShortLinkShareModel, createFileShare, isSafeCapabilityUrl, openCapabilityUrl, saveFileAndReconcileGroup, setFileShareActive, type FileRenditionRow } from './models';
|
|
8
8
|
|
|
9
9
|
function ShareResult({ url, close }: { url: string; close: () => void }) {
|
|
10
10
|
return <div className="modal-pad"><h2 className="modal-title">Share link created</h2><p className="modal-message">This newly minted capability is shown once from component-local state.</p><code className="storage-capability">{url}</code><div className="modal-actions"><button className="btn" onClick={() => void navigator.clipboard.writeText(url)}>Copy</button><button className="btn btn-primary" onClick={close}>Done</button></div></div>;
|
|
@@ -19,8 +19,12 @@ export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
19
19
|
const queryClient = useQueryClient();
|
|
20
20
|
const query = FileModel.useOne(id);
|
|
21
21
|
const canManage = useCan(STORAGE_MANAGE_PERMS).can;
|
|
22
|
+
const [pendingShares, setPendingShares] = useState<Set<number>>(() => new Set());
|
|
23
|
+
const shareLocks = useRef(new Set<number>());
|
|
24
|
+
const permission = useRef(canManage); permission.current = canManage;
|
|
25
|
+
useEffect(() => { permission.current = canManage; return () => { permission.current = false; }; }, [canManage]);
|
|
22
26
|
const regenerate = FileModel.useAction('regenerate_renditions');
|
|
23
|
-
|
|
27
|
+
|
|
24
28
|
const shares = ShortLinkShareModel.useList({ file: id, start: 0, size: 25 }, { enabled: canManage });
|
|
25
29
|
const alive = useRef(true);
|
|
26
30
|
const initialPollStarted = useRef(false);
|
|
@@ -65,12 +69,12 @@ export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
65
69
|
const updatePublic = async () => {
|
|
66
70
|
const next = !file.is_public;
|
|
67
71
|
const ok = await modal.confirm({ title: next ? 'Make file public?' : 'Make file private?', message: <>Change access for <b>{file.filename}</b>?</>, confirmText: next ? 'Make public' : 'Make private', danger: next });
|
|
68
|
-
if (!ok) return;
|
|
72
|
+
if (!ok || !permission.current) return;
|
|
69
73
|
try { await saveFileAndReconcileGroup(queryClient, id, { is_public: next }); await query.refetch(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Access change failed'); }
|
|
70
74
|
};
|
|
71
75
|
const createShare = async () => {
|
|
72
76
|
const values = await modal.open<Record<string, unknown>>((close) => <div className="modal-pad"><h2 className="modal-title">Create share link</h2><SchemaForm fields={[{ name: 'expire_days', type: 'text', label: 'Expire after days', help: '0 means no expiry.' }, { name: 'track_clicks', type: 'switch', label: 'Track clicks' }, { name: 'note', type: 'textarea', label: 'Audit note' }]} initial={{ expire_days: '30', track_clicks: false, note: '' }} submitText="Create share" onCancel={() => close(null as never)} onSubmit={(data) => close(data as unknown as Record<string, unknown>)} /></div>);
|
|
73
|
-
if (!values) return;
|
|
77
|
+
if (!values || !permission.current) return;
|
|
74
78
|
try {
|
|
75
79
|
const result = await createFileShare(id, { expire_days: Number(values.expire_days ?? 30), track_clicks: Boolean(values.track_clicks), note: String(values.note ?? '') });
|
|
76
80
|
await modal.open((close) => <ShareResult url={result.url} close={() => close(null)} />, { size: 'sm' });
|
|
@@ -84,7 +88,7 @@ export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
84
88
|
const normalized = normalizeRenditionRoles(roles);
|
|
85
89
|
try {
|
|
86
90
|
const ok = await modal.confirm({ title: normalized.length ? `Regenerate ${normalized.join(', ')}?` : 'Regenerate all renditions?', message: 'The renderer is asynchronous. This viewer polls finite observable state for up to 12 attempts.', confirmText: 'Queue regeneration' });
|
|
87
|
-
if (!ok) return;
|
|
91
|
+
if (!ok || !permission.current) return;
|
|
88
92
|
const currentRoles = normalizeRenditions(file.renditions).map((row) => row.role);
|
|
89
93
|
const targetRoles = normalized.length > 0 ? normalized : currentRoles.length > 0 ? currentRoles : ['thumbnail', 'preview'];
|
|
90
94
|
const before = renditionTargetSignature(file, targetRoles);
|
|
@@ -93,17 +97,25 @@ export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
93
97
|
} catch (error) { toast.error(error instanceof Error ? error.message : 'Could not queue renditions'); }
|
|
94
98
|
finally { regenerationBusyRef.current = false; setRegenerationBusy(false); }
|
|
95
99
|
};
|
|
96
|
-
const deleteOne = async () => {
|
|
97
|
-
await modal.open((close) => <div className="modal-pad"><h2 className="modal-title">Delete {file.filename}</h2><p className="modal-message">Enter the exact filename. This deletes the record, original backend object, and renditions.</p><SchemaForm fields={[{ name: 'filename', type: 'text', label: 'Exact filename', required: true }]} submitText="Delete file" onCancel={() => close(null)} onSubmit={async (data) => { if (String(data.filename ?? '') !== file.filename) throw new Error('Filename does not match. No request was sent.'); await remove.mutateAsync({ id }); close(true); onClose(); }} /></div>, { size: 'sm' });
|
|
98
|
-
};
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
|
|
102
|
+
const toggleShare = async (shareId: number, next: boolean) => {
|
|
103
|
+
if (!permission.current || shareLocks.current.has(shareId)) return;
|
|
104
|
+
shareLocks.current.add(shareId); setPendingShares(new Set(shareLocks.current));
|
|
105
|
+
try { await setFileShareActive(shareId, next); }
|
|
106
|
+
catch (error) { toast.error(error instanceof Error ? error.message : 'Share state change failed'); }
|
|
107
|
+
finally { await shares.refetch(); shareLocks.current.delete(shareId); setPendingShares(new Set(shareLocks.current)); }
|
|
108
|
+
};
|
|
109
|
+
return <DetailView contextMenu={[
|
|
110
|
+
...(isSafeCapabilityUrl(file.url) ? [{ label: 'Open file', onSelect: () => { openCapabilityUrl(file.url!); } }, { label: 'Download file', onSelect: () => { openCapabilityUrl(file.url!, true); } }, { label: 'Copy URL', onSelect: () => void navigator.clipboard.writeText(file.url!).catch(() => toast.error('Copy failed')) }] : []),
|
|
111
|
+
...(canManage ? [{ label: file.is_public ? 'Make private…' : 'Make public…', onSelect: () => void updatePublic() }, { label: 'Create share…', onSelect: () => void createShare() }, { label: 'Regenerate all renditions…', disabled: regenerationBusy || pollState === 'polling', onSelect: () => void runRegenerate() }] : []),
|
|
112
|
+
]} title={file.filename} subtitle={`${file.content_type} · ${file.file_size == null ? 'unknown size' : fmt.filesize(file.file_size)}`} icon="bi-file-earmark" chips={[{ text: file.upload_status, tone: file.upload_status === 'completed' ? 'success' : file.upload_status === 'failed' ? 'danger' : 'warning' }, { text: file.is_public ? 'Public' : 'Private', tone: file.is_public ? 'warning' : 'muted' }]} sections={[
|
|
113
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section">{!isSafeCapabilityUrl(file.url) && <p className="storage-unsafe">The backend URL is absent or unsafe; Open, Download, and Copy are refused.</p>}<FlatRow label="Filename">{file.filename}</FlatRow><FlatRow label="Category">{file.category || 'Unknown'}</FlatRow><FlatRow label="Upload status"><Badge tone={file.upload_status === 'completed' ? 'success' : 'warning'}>{file.upload_status}</Badge></FlatRow><FlatRow label="Public">{file.is_public ? 'Yes' : 'No'}</FlatRow><FlatRow label="Active">{file.is_active ? 'Yes' : 'No'}</FlatRow></div> },
|
|
102
114
|
{ key: 'preview', label: 'Preview', icon: 'bi-eye', render: () => <div className="detail-section"><FilePreview file={file} /></div> },
|
|
103
115
|
{ key: 'details', label: 'Details', icon: 'bi-card-list', render: () => <div className="detail-section"><FlatRow label="ID">{file.id}</FlatRow><FlatRow label="Content type">{file.content_type}</FlatRow><FlatRow label="Size">{file.file_size == null ? 'Unknown' : fmt.filesize(file.file_size)}</FlatRow><FlatRow label="Group">{typeof file.group === 'object' && file.group ? file.group.name || `#${file.group.id}` : file.group == null ? 'System' : `#${file.group}`}</FlatRow><FlatRow label="Backend">{typeof file.file_manager === 'object' && file.file_manager ? file.file_manager.name || `#${file.file_manager.id}` : `#${file.file_manager ?? '—'}`}</FlatRow><FlatRow label="Created">{fmt.datetime(file.created)}</FlatRow><FlatRow label="Modified">{fmt.datetime(file.modified)}</FlatRow></div> },
|
|
104
|
-
{ key: 'renditions', label: 'Renditions', icon: 'bi-images', render: () => { const pollingDisabled = regenerationBusy || pollState === 'polling'; return <div className="detail-section"><div className="storage-section-head"><div><h3>Renditions</h3><p className="dim">Derived from the role-keyed File graph; no duplicate rendition store.</p></div
|
|
105
|
-
{ key: 'shares', label: 'Shares', icon: 'bi-share', render: () => <div className="detail-section"><div className="storage-section-head"><div><h3>Visible shares</h3><p className="dim">Owner-visible rows only unless the caller separately holds global shortlink authority.</p></div>{canManage && <button className="btn btn-primary" onClick={() => void createShare()}>Create share…</button>}</div>{!canManage ? <p className="dim">Share controls are unavailable for this viewer.</p> : shares.isError ? <div className="form-alert">Visible shares unavailable. File inspection remains available.</div> : (shares.data?.rows ?? []).length === 0 ? <p className="dim">No visible shares.</p> : <div className="storage-shares">{shares.data!.rows.map((share) => <div key={share.id}><span><code>{share.code}</code><small>{share.is_active ? 'Active' : 'Inactive'} · {share.expires_at ? fmt.datetime(share.expires_at) : 'No expiry'} · {share.hit_count} hits · {share.track_clicks ? 'tracking' : 'not tracking'}{share.note ? ` · ${share.note}` : ''}</small></span><div className="storage-action-row"><button className="btn btn-compact" onClick={
|
|
116
|
+
{ key: 'renditions', label: 'Renditions', icon: 'bi-images', render: () => { const pollingDisabled = regenerationBusy || pollState === 'polling'; return <div className="detail-section"><div className="storage-section-head"><div><h3>Renditions</h3><p className="dim">Derived from the role-keyed File graph; no duplicate rendition store.</p></div></div><RenditionRows rows={renditionRows} canManage={canManage} pollingDisabled={pollingDisabled} onRegenerate={(roles) => void runRegenerate(roles)} />{pollState === 'polling' && <p className="dim"><i className="bi bi-arrow-repeat spin" /> Waiting for observable rendition change…</p>}{pollState === 'timeout' && <div className="form-alert">No convergence after 12 attempts. The job may still be running. <button className="btn btn-compact" onClick={() => void query.refetch()}>Refresh now</button></div>}{pollState && ['failed', 'expired'].includes(pollState) && <div className="form-alert">Polling stopped because upload state is {pollState}.</div>}</div>; } },
|
|
117
|
+
{ key: 'shares', label: 'Shares', icon: 'bi-share', render: () => <div className="detail-section"><div className="storage-section-head"><div><h3>Visible shares</h3><p className="dim">Owner-visible rows only unless the caller separately holds global shortlink authority.</p></div>{canManage && <button className="btn btn-primary" onClick={() => void createShare()}>Create share…</button>}</div>{!canManage ? <p className="dim">Share controls are unavailable for this viewer.</p> : shares.isError ? <div className="form-alert">Visible shares unavailable. File inspection remains available.</div> : (shares.data?.rows ?? []).length === 0 ? <p className="dim">No visible shares.</p> : <div className="storage-shares">{shares.data!.rows.map((share) => <div key={share.id}><span><code>{share.code}</code><small>{share.is_active ? 'Active' : 'Inactive'} · {share.expires_at ? fmt.datetime(share.expires_at) : 'No expiry'} · {share.hit_count} hits · {share.track_clicks ? 'tracking' : 'not tracking'}{share.note ? ` · ${share.note}` : ''}</small></span><div className="storage-action-row"><button className="btn btn-compact" disabled={pendingShares.has(share.id)} onClick={() => void toggleShare(share.id, !share.is_active)}>{share.is_active ? 'Deactivate' : 'Activate'}</button></div></div>)}</div>}</div> },
|
|
106
118
|
{ key: 'metadata', label: 'Metadata', icon: 'bi-braces', render: () => <div className="detail-section"><DataView data={file.metadata ?? {}} columns={2} /></div> },
|
|
107
|
-
|
|
119
|
+
|
|
108
120
|
]} initialSection="overview" onClose={onClose} />;
|
|
109
121
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
2
|
+
import { useState } from 'react';
|
|
4
3
|
import { useCan } from '../../client/runtime';
|
|
5
|
-
import {
|
|
4
|
+
import { Badge, CollectionSelect, ModelTable, fmt, modal, type BatchAction, type Column, type FilterDef } from '../../ui';
|
|
6
5
|
import { FileUploadSurface } from './FileUploadSurface';
|
|
6
|
+
import { FileView } from './FileView';
|
|
7
7
|
import { FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, exportFiles, openCapabilityUrl, saveFileAndReconcileGroup, type FileRow, type RelationRow } from './models';
|
|
8
8
|
|
|
9
9
|
const COLUMNS: Column<FileRow>[] = [
|
|
@@ -36,14 +36,13 @@ export function FilesPage() {
|
|
|
36
36
|
const queryClient = useQueryClient();
|
|
37
37
|
const canManage = useCan(STORAGE_MANAGE_PERMS).can;
|
|
38
38
|
const canChooseGroup = useCan(GROUP_DIRECTORY_PERMS).can;
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
const saveAccess = (row: FileRow, value: boolean) => saveFileAndReconcileGroup(queryClient, row.id, { is_public: value });
|
|
41
41
|
const batches: BatchAction<FileRow>[] = [
|
|
42
42
|
{ key: 'public', label: 'Make public', eligible: (row) => !row.is_public, confirm: 'Make the selected files public?', run: (row) => saveAccess(row, true) },
|
|
43
43
|
{ key: 'private', label: 'Make private', eligible: (row) => row.is_public, run: (row) => saveAccess(row, false) },
|
|
44
44
|
...(canChooseGroup ? [{ key: 'move', label: 'Move to group', confirm: false as const, prepare: (rows: FileRow[]) => modal.open<number>((close) => <MoveGroupDialog rows={rows} close={(value) => close(value as number)} />), run: (row: FileRow, prepared: unknown) => saveFileAndReconcileGroup(queryClient, row.id, { group: Number(prepared) }, Number(prepared)) }] : []),
|
|
45
45
|
{ key: 'download', label: 'Download selected', confirm: false, run: async (row) => { if (!openCapabilityUrl(row.url ?? '', true)) throw new Error(`${row.filename} has no safe download URL`); } },
|
|
46
|
-
{ key: 'delete', label: 'Delete', danger: true, confirm: 'Delete the selected files and their backend objects? This cannot be undone.', run: (row) => remove.mutateAsync({ id: row.id }) },
|
|
47
46
|
];
|
|
48
47
|
const table = (openUpload?: () => void) => <ModelTable model={FileModel} eyebrow="Infrastructure · Storage" title="Files" columns={COLUMNS} filters={FILTERS} presets={[{ key: 'all', label: 'All', params: {} }, { key: 'ready', label: 'Ready', params: { upload_status: 'completed' } }, { key: 'failed', label: 'Failed', params: { upload_status: 'failed' } }, { key: 'public', label: 'Public', params: { is_public: 'true' } }]} defaultSort="-created" searchable searchPlaceholder="Search filename or content type" selectable={canManage} batchActions={canManage ? batches : []} columnChooser persistState persistKey="admin:storage:files" exportFormats={['csv', 'json']} exporter={exportFiles} onAdd={openUpload} addLabel="Add File" onRowClick={(row) => showFileView(row.id)} />;
|
|
49
48
|
// Do not mount picker/drop/manager queries for view-only operators.
|