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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A preview is an estimate for an immutable filter tuple, never a snapshot. */
|
|
2
|
+
export interface PurgeParameters { daysOld: number; status: string | null }
|
|
3
|
+
export interface PurgePreviewIdentity { id: number; params: Readonly<PurgeParameters> }
|
|
4
|
+
export function createPurgePreviewGuard() {
|
|
5
|
+
let generation = 0;
|
|
6
|
+
let accepted: PurgePreviewIdentity | null = null;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
return {
|
|
9
|
+
begin(params: PurgeParameters): PurgePreviewIdentity {
|
|
10
|
+
accepted = null;
|
|
11
|
+
return { id: ++generation, params: Object.freeze({ ...params }) };
|
|
12
|
+
},
|
|
13
|
+
accept(identity: PurgePreviewIdentity): boolean {
|
|
14
|
+
if (disposed || identity.id !== generation) return false;
|
|
15
|
+
accepted = identity;
|
|
16
|
+
return true;
|
|
17
|
+
},
|
|
18
|
+
execution(identity: PurgePreviewIdentity): Readonly<PurgeParameters> | null {
|
|
19
|
+
return !disposed && accepted === identity && identity.id === generation ? identity.params : null;
|
|
20
|
+
},
|
|
21
|
+
current(identity: PurgePreviewIdentity) { return !disposed && identity.id === generation; },
|
|
22
|
+
invalidate() { generation++; accepted = null; },
|
|
23
|
+
dispose() { disposed = true; generation++; accepted = null; },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
// · clear-queue's `confirm:"yes"` is sent only AFTER the armed confirmation.
|
|
14
14
|
// Pre-satisfying a server-side safety gate removes it.
|
|
15
15
|
// · every destructive control is an ArmedButton, not a plain button.
|
|
16
|
-
import { useState, type ReactNode } from 'react';
|
|
17
|
-
import { ArmedButton, JsonBlock, modal, toast } from '../../../ui';
|
|
16
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
18
17
|
import { useCan } from '../../../client/runtime';
|
|
18
|
+
import { ArmedButton, JsonBlock, modal, toast } from '../../../ui';
|
|
19
19
|
import {
|
|
20
20
|
cleanupConsumers,
|
|
21
21
|
clearQueue,
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
} from '../control';
|
|
34
34
|
import { JOBS_MANAGE_PERMS, JOB_STATUS_OPTIONS } from '../models';
|
|
35
35
|
|
|
36
|
+
import { createPurgePreviewGuard, type PurgePreviewIdentity } from '../purge-preview';
|
|
37
|
+
|
|
36
38
|
const ALL_CHANNELS = '';
|
|
37
39
|
|
|
38
40
|
function OperationRow({ label, icon, description, children, note }: {
|
|
@@ -56,63 +58,69 @@ function OperationRow({ label, icon, description, children, note }: {
|
|
|
56
58
|
|
|
57
59
|
// ── Purge dialog: preview, then arm ───────────────────────────────────
|
|
58
60
|
|
|
59
|
-
function PurgeDialog({ onClose }: { onClose: () => void }) {
|
|
61
|
+
export function PurgeDialog({ onClose, onExecuting }: { onClose: () => void; onExecuting?: (value: boolean) => void }) {
|
|
60
62
|
const [days, setDays] = useState(30);
|
|
61
63
|
const [status, setStatus] = useState('');
|
|
62
|
-
const
|
|
64
|
+
const guard = useRef(createPurgePreviewGuard());
|
|
65
|
+
const [preview, setPreview] = useState<(PurgePreviewIdentity & { result: PurgeResult }) | null>(null);
|
|
63
66
|
const [busy, setBusy] = useState(false);
|
|
64
|
-
|
|
67
|
+
const [executing, setExecuting] = useState(false);
|
|
68
|
+
const executionLock = useRef(false);
|
|
69
|
+
const mounted = useRef(false);
|
|
70
|
+
const [outcome, setOutcome] = useState<PurgeResult | null>(null);
|
|
71
|
+
const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
guard.current = createPurgePreviewGuard(); mounted.current = true;
|
|
74
|
+
return () => { mounted.current = false; guard.current.dispose(); };
|
|
75
|
+
}, []);
|
|
76
|
+
const invalidate = () => { if (executionLock.current) return; guard.current.invalidate(); setPreview(null); setBusy(false); };
|
|
65
77
|
const runPreview = async () => {
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
if (!canManage || executionLock.current) return;
|
|
79
|
+
const identity = guard.current.begin({ daysOld: Math.max(0, Math.floor(days)), status: status || null });
|
|
80
|
+
setBusy(true); setPreview(null); setOutcome(null);
|
|
68
81
|
try {
|
|
69
|
-
const result = await purgeJobs({
|
|
70
|
-
setPreview(result);
|
|
82
|
+
const result = await purgeJobs({ ...identity.params, dryRun: true });
|
|
83
|
+
if (guard.current.accept(identity)) setPreview(Object.assign(identity, { result }));
|
|
71
84
|
} catch (error) {
|
|
72
|
-
toast.error(error instanceof Error ? error.message : 'Purge preview failed');
|
|
73
|
-
} finally {
|
|
74
|
-
setBusy(false);
|
|
75
|
-
}
|
|
85
|
+
if (guard.current.current(identity)) toast.error(error instanceof Error ? error.message : 'Purge preview failed');
|
|
86
|
+
} finally { if (guard.current.current(identity)) setBusy(false); }
|
|
76
87
|
};
|
|
77
|
-
|
|
78
88
|
const runPurge = async () => {
|
|
79
|
-
|
|
89
|
+
const params = preview && guard.current.execution(preview);
|
|
90
|
+
if (!canManage || busy || executionLock.current || !params) return;
|
|
91
|
+
executionLock.current = true; onExecuting?.(true); setExecuting(true);
|
|
92
|
+
guard.current.invalidate(); setPreview(null); setBusy(true);
|
|
80
93
|
try {
|
|
81
|
-
const result = await purgeJobs({
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
onClose();
|
|
86
|
-
} catch (error) {
|
|
87
|
-
toast.error(error instanceof Error ? error.message : 'Purge failed');
|
|
88
|
-
} finally {
|
|
89
|
-
setBusy(false);
|
|
90
|
-
}
|
|
94
|
+
const result = await purgeJobs({ ...params });
|
|
95
|
+
if (mounted.current) { setOutcome(result); setPreview(null); }
|
|
96
|
+
} catch (error) { if (mounted.current) toast.error(error instanceof Error ? error.message : 'Purge failed'); }
|
|
97
|
+
finally { executionLock.current = false; onExecuting?.(false); if (mounted.current) { setExecuting(false); setBusy(false); } }
|
|
91
98
|
};
|
|
92
|
-
|
|
93
|
-
const previewCount = preview && isPurgeDryRun(preview) ? preview.count : null;
|
|
99
|
+
const previewCount = preview && isPurgeDryRun(preview.result) ? preview.result.count : null;
|
|
94
100
|
|
|
95
101
|
return (
|
|
96
102
|
<div className="modal-pad">
|
|
97
103
|
<h2 className="modal-title">Purge old jobs</h2>
|
|
98
104
|
<p className="dim">
|
|
99
105
|
Deletes job rows older than the cutoff, and their events and logs with them. Preview first — the
|
|
100
|
-
|
|
106
|
+
preview is an estimate. Execution recalculates the cutoff; deleted counts include cascaded event and log rows.
|
|
101
107
|
</p>
|
|
102
108
|
<label className="jobs-field">
|
|
103
109
|
<span>Older than (days)</span>
|
|
104
110
|
<input
|
|
105
111
|
type="number" className="input input-compact" min={0} step={1}
|
|
112
|
+
disabled={executing}
|
|
106
113
|
value={days}
|
|
107
|
-
onChange={(event) => { setDays(Math.max(0, Number(event.target.value) || 0));
|
|
114
|
+
onChange={(event) => { setDays(Math.max(0, Math.floor(Number(event.target.value) || 0))); invalidate(); }}
|
|
108
115
|
/>
|
|
109
116
|
</label>
|
|
110
117
|
<label className="jobs-field">
|
|
111
118
|
<span>Status filter</span>
|
|
112
119
|
<select
|
|
113
120
|
className="input input-compact"
|
|
121
|
+
disabled={executing}
|
|
114
122
|
value={status}
|
|
115
|
-
onChange={(event) => { setStatus(event.target.value);
|
|
123
|
+
onChange={(event) => { setStatus(event.target.value); invalidate(); }}
|
|
116
124
|
>
|
|
117
125
|
<option value="">Every status</option>
|
|
118
126
|
{JOB_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
@@ -123,23 +131,25 @@ function PurgeDialog({ onClose }: { onClose: () => void }) {
|
|
|
123
131
|
<p className={`jobs-note ${previewCount > 0 ? 'jobs-note-warn' : 'jobs-note-info'}`}>
|
|
124
132
|
<i className="bi bi-info-circle" />
|
|
125
133
|
{previewCount > 0
|
|
126
|
-
? <> <strong>{previewCount}</strong> job(s) would be deleted, created before {new Date(preview.cutoff).toLocaleString()}.</>
|
|
134
|
+
? <> <strong>{previewCount}</strong> job(s) would be deleted, created before {new Date(preview.result.cutoff).toLocaleString()}.</>
|
|
127
135
|
: <> Nothing matches — no job is older than that cutoff.</>}
|
|
128
136
|
</p>
|
|
129
137
|
)}
|
|
130
138
|
|
|
139
|
+
{outcome && <div className="jobs-note jobs-note-info"><h3>Execution result (actual cutoff)</h3><JsonBlock value={outcome} defaultOpen /></div>}
|
|
131
140
|
<div className="modal-actions">
|
|
132
|
-
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
141
|
+
<button type="button" className="btn" disabled={executing} onClick={() => { invalidate(); onClose(); }}>Cancel</button>
|
|
133
142
|
<button type="button" className="btn" disabled={busy} onClick={() => void runPreview()}>
|
|
134
143
|
{busy && !preview ? 'Previewing…' : 'Preview'}
|
|
135
144
|
</button>
|
|
136
145
|
<ArmedButton
|
|
146
|
+
key={preview?.id ?? 'no-preview'}
|
|
137
147
|
label="Purge"
|
|
138
148
|
armedLabel={previewCount != null ? `Click again to delete ${previewCount} job(s)` : 'Click again to purge'}
|
|
139
149
|
className="btn-danger-ghost"
|
|
140
150
|
// Arming before a preview would be arming a number nobody
|
|
141
151
|
// has seen.
|
|
142
|
-
disabled={busy || previewCount == null || previewCount === 0}
|
|
152
|
+
disabled={!canManage || busy || previewCount == null || previewCount === 0}
|
|
143
153
|
onConfirm={runPurge}
|
|
144
154
|
/>
|
|
145
155
|
</div>
|
|
@@ -208,6 +218,7 @@ export function JobOperationsSection({ channels, channelsPending, onChanged }: {
|
|
|
208
218
|
onChanged?: () => void;
|
|
209
219
|
}) {
|
|
210
220
|
const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
|
|
221
|
+
const purgeExecuting = useRef(false);
|
|
211
222
|
const [channel, setChannel] = useState(ALL_CHANNELS);
|
|
212
223
|
const noChannels = !channelsPending && channels.length === 0;
|
|
213
224
|
// A stale selection must never become a request for a channel that no
|
|
@@ -368,8 +379,8 @@ export function JobOperationsSection({ channels, channelsPending, onChanged }: {
|
|
|
368
379
|
<button
|
|
369
380
|
type="button" className="btn btn-compact btn-danger-ghost" disabled={!canManage}
|
|
370
381
|
onClick={() => void modal.open((close) => (
|
|
371
|
-
<PurgeDialog onClose={() => { close(null); onChanged?.(); }} />
|
|
372
|
-
), { size: 'sm' })}
|
|
382
|
+
<PurgeDialog onExecuting={value => { purgeExecuting.current = value; }} onClose={() => { if (!purgeExecuting.current) { close(null); onChanged?.(); } }} />
|
|
383
|
+
), { size: 'sm', canDismiss: () => !purgeExecuting.current })}
|
|
373
384
|
>
|
|
374
385
|
Purge…
|
|
375
386
|
</button>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useState } from 'react';
|
|
3
3
|
import { ArmedButton, Badge, DetailView, FlatRow, ModelTable, SchemaForm, fmt, formModal, modal, toast, type Column, type FilterDef, type FormData } from '../../ui';
|
|
4
|
-
import { auditEmailDomain,
|
|
4
|
+
import { auditEmailDomain, reconcileEmailDomain, saveEmailDomainAtomic } from './api';
|
|
5
5
|
import { exportEmailDomains } from './data';
|
|
6
6
|
import { openEmailDomainWizard } from './EmailDomainWizard';
|
|
7
7
|
import { EmailDomainModel, type EmailDomainRow } from './models';
|
|
@@ -13,5 +13,5 @@ const fields=[{name:'name',label:'Domain',type:'text',required:true},{name:'regi
|
|
|
13
13
|
|
|
14
14
|
function DomainCreate({close}:{close:(value:unknown)=>void}) { const qc=useQueryClient(); const [draft,setDraft]=useState<FormData|null>(null);const [awsKey,setAwsKey]=useState('');const [awsSecret,setAwsSecret]=useState(''); return <div className="modal-pad"><h2 className="modal-title">Create email domain</h2><p className="form-alert">Creation may run best-effort SES audit/reconcile. A created row does not mean AWS converged.</p>{draft?<><p>Ready to create <b>{String(draft.name)}</b>. Optional credentials are write-only and discarded immediately after submission.</p><label className="field"><span className="field-label">AWS access key</span><input type="password" autoComplete="off" value={awsKey} onChange={event=>setAwsKey(event.target.value)}/></label><label className="field"><span className="field-label">AWS secret</span><input type="password" autoComplete="new-password" value={awsSecret} onChange={event=>setAwsSecret(event.target.value)}/></label><div className="modal-actions"><button className="btn" onClick={()=>setDraft(null)}>Back</button><ArmedButton label="Create domain" armedLabel="Click again to create and run best-effort AWS setup" onConfirm={async()=>{const payload={...draft,...(awsKey?{aws_key:awsKey}:{}),...(awsSecret?{aws_secret:awsSecret}:{})};setAwsKey('');setAwsSecret('');setDraft(null);try{const row=await saveEmailDomainAtomic(qc,null,payload);toast.success('Domain row created. AWS convergence is best effort.');close(row);}catch(error){toast.error(redactMessagingError(error));}}} /></div></>:<SchemaForm fields={[...fields]} submitText="Review" onCancel={()=>close(null)} onSubmit={setDraft}/>}</div>; }
|
|
15
15
|
function CredentialDialog({id,close}:{id:number;close:()=>void}){const qc=useQueryClient();const [key,setKey]=useState('');const [secret,setSecret]=useState('');const submit=async()=>{if(!key&&!secret)return;const payload={...(key?{aws_key:key}:{}),...(secret?{aws_secret:secret}:{})};setKey('');setSecret('');try{await saveEmailDomainAtomic(qc,id,payload);toast.success('AWS credentials replaced');close();}catch(error){toast.error(redactMessagingError(error));}};return <div className="modal-pad"><h2 className="modal-title">Replace AWS credentials</h2><p>Write-only values are never prefilled or cached.</p><label className="field"><span className="field-label">AWS access key</span><input type="password" autoComplete="off" value={key} onChange={e=>setKey(e.target.value)}/></label><label className="field"><span className="field-label">AWS secret</span><input type="password" autoComplete="new-password" value={secret} onChange={e=>setSecret(e.target.value)}/></label><div className="modal-actions"><button className="btn" onClick={close}>Cancel</button><ArmedButton label="Replace credentials" armedLabel="Click again to replace write-only credentials" disabled={!key&&!secret} onConfirm={submit}/></div></div>}
|
|
16
|
-
function DomainDetail({id,onClose}:{id:number;onClose:()=>void}) { const qc=useQueryClient(); const query=EmailDomainModel.useOne(id); if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading…':query.error instanceof Error?redactMessagingError(query.error):'Domain unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>; const row=query.data; const edit=async()=>{const data=await formModal({title:`Edit ${row.name}`,fields:[...fields].filter(f=>f.name!=='name'),initial:row as unknown as FormData});if(!data)return;try{await saveEmailDomainAtomic(qc,id,data);toast.success('Domain row updated');await query.refetch();}catch(error){toast.error(redactMessagingError(error));}}; const audit=async()=>{if(!await modal.confirm({title:'Audit SES domain?',message:'Audit performs a provider request and persists readiness fields.',confirmText:'Run audit'}))return;try{const result=await auditEmailDomain(qc,id);await modal.open((close)=><div className="modal-pad"><h2 className="modal-title">Audit result</h2><Badge tone={result.audit_pass?'success':'danger'}>{result.audit_pass?'Pass':'Attention needed'}</Badge>{Object.entries(result.checks).map(([k,v])=><FlatRow key={k} label={k.replaceAll('_',' ')}>{v?'Pass':'Fail'}</FlatRow>)}{result.items.map((item,index)=><FlatRow key={`${item.resource}:${index}`} label={item.resource}>{item.status}</FlatRow>)}{result.recommendations.map((recommendation,index)=><div className="known-card" key={`${recommendation.resource}:${index}`}><h3>{recommendation.resource||'Recommendation'}{recommendation.severity?` · ${recommendation.severity}`:''}</h3>{recommendation.action&&<p>{recommendation.action}</p>}{recommendation.explanation&&<p className="dim">{recommendation.explanation}</p>}</div>)}<div className="modal-actions"><button className="btn" onClick={()=>close(null)}>Close</button></div></div>);}catch(e){toast.error(redactMessagingError(e));}}; const reconcile=async()=>{if(!await modal.confirm({title:'Reconcile AWS resources?',message:'This may change SES identities, SNS topics, and receiving rules. It does not write DNS.',confirmText:'Reconcile'}))return;try{await reconcileEmailDomain(qc,id);toast.success('Reconcile completed');}catch(e){toast.error(redactMessagingError(e));}}; const onboard=()=>void openEmailDomainWizard(row,qc).catch(error=>toast.error(redactMessagingError(error)));
|
|
16
|
+
function DomainDetail({id,onClose}:{id:number;onClose:()=>void}) { const qc=useQueryClient(); const query=EmailDomainModel.useOne(id); if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading…':query.error instanceof Error?redactMessagingError(query.error):'Domain unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>; const row=query.data; const edit=async()=>{const data=await formModal({title:`Edit ${row.name}`,fields:[...fields].filter(f=>f.name!=='name'),initial:row as unknown as FormData});if(!data)return;try{await saveEmailDomainAtomic(qc,id,data);toast.success('Domain row updated');await query.refetch();}catch(error){toast.error(redactMessagingError(error));}}; const audit=async()=>{if(!await modal.confirm({title:'Audit SES domain?',message:'Audit performs a provider request and persists readiness fields.',confirmText:'Run audit'}))return;try{const result=await auditEmailDomain(qc,id);await modal.open((close)=><div className="modal-pad"><h2 className="modal-title">Audit result</h2><Badge tone={result.audit_pass?'success':'danger'}>{result.audit_pass?'Pass':'Attention needed'}</Badge>{Object.entries(result.checks).map(([k,v])=><FlatRow key={k} label={k.replaceAll('_',' ')}>{v?'Pass':'Fail'}</FlatRow>)}{result.items.map((item,index)=><FlatRow key={`${item.resource}:${index}`} label={item.resource}>{item.status}</FlatRow>)}{result.recommendations.map((recommendation,index)=><div className="known-card" key={`${recommendation.resource}:${index}`}><h3>{recommendation.resource||'Recommendation'}{recommendation.severity?` · ${recommendation.severity}`:''}</h3>{recommendation.action&&<p>{recommendation.action}</p>}{recommendation.explanation&&<p className="dim">{recommendation.explanation}</p>}</div>)}<div className="modal-actions"><button className="btn" onClick={()=>close(null)}>Close</button></div></div>);}catch(e){toast.error(redactMessagingError(e));}}; const reconcile=async()=>{if(!await modal.confirm({title:'Reconcile AWS resources?',message:'This may change SES identities, SNS topics, and receiving rules. It does not write DNS.',confirmText:'Reconcile'}))return;try{await reconcileEmailDomain(qc,id);toast.success('Reconcile completed');}catch(e){toast.error(redactMessagingError(e));}}; const onboard=()=>void openEmailDomainWizard(row,qc).catch(error=>toast.error(redactMessagingError(error))); return <DetailView title={row.name} subtitle={`${row.region} · ${row.dns_mode}`} icon="bi-envelope-at" chips={[{text:row.status,tone:row.status==='verified'||row.status==='ready'?'success':'warning'}]} onClose={onClose} contextMenu={[{label:'Edit domain',onSelect:()=>void edit()},{label:'Credentials…',onSelect:()=>void modal.open(close=><CredentialDialog id={id} close={()=>close(null)}/>)},{label:'Onboard…',onSelect:onboard},{label:'Audit…',onSelect:()=>void audit()},{label:'Reconcile…',onSelect:()=>void reconcile()}]} initialSection="overview" sections={[{key:'overview',label:'Overview',icon:'bi-info-circle',render:()=><div className="detail-section"><FlatRow label="Status">{row.status}</FlatRow><FlatRow label="Receiving">{row.receiving_enabled?'Enabled':'Disabled'}</FlatRow><FlatRow label="S3 bucket">{row.s3_inbound_bucket||'—'}</FlatRow><FlatRow label="S3 prefix">{row.s3_inbound_prefix||'—'}</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>},]} />; }
|
|
17
17
|
export function EmailDomainsPage(){return <ModelTable model={EmailDomainModel} eyebrow="Communications · SES" title="Email Domains" columns={columns} filters={filters} defaultSort="name" searchable searchPlaceholder="Search domain, region, or status" columnChooser persistState persistKey="admin:email:domains" exportFormats={['csv','json']} exporter={exportEmailDomains} addLabel="Create domain" onAdd={()=>void modal.open(close=><DomainCreate close={close}/>,{size:'lg'})} onRowClick={row=>void modal.detail(close=><DomainDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import { ArmedButton, DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column } from '../../ui';
|
|
4
|
-
import { deleteMessagingRow } from './api';
|
|
2
|
+
import { DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column } from '../../ui';
|
|
5
3
|
import { exportEmailTemplates } from './data';
|
|
6
4
|
import { EmailTemplateModel, type EmailTemplateRow } from './models';
|
|
7
5
|
import { SandboxedEmailPreview } from './SandboxedEmailPreview';
|
|
@@ -9,5 +7,5 @@ import { redactMessagingError } from './sanitize';
|
|
|
9
7
|
|
|
10
8
|
const columns:Column<EmailTemplateRow>[]=[{key:'name',label:'Template',sortable:true,hideable:false},{key:'modified',label:'Modified',sortable:true,render:r=>fmt.datetime(r.modified)},{key:'created',label:'Created',sortable:true,render:r=>fmt.date(r.created)}];
|
|
11
9
|
function TemplateEditor({row,onSave,onClose}:{row?:EmailTemplateRow;onSave:(changes:Record<string,unknown>)=>Promise<void>;onClose:()=>void}){const [name,setName]=useState(row?.name??'');const [subject,setSubject]=useState(row?.subject_template??'');const [html,setHtml]=useState(row?.html_template??'');const [plain,setPlain]=useState(row?.text_template??'');const [busy,setBusy]=useState(false);const [error,setError]=useState('');const submit=async()=>{if(!name.trim())return;setBusy(true);setError('');try{await onSave({name:name.trim(),subject_template:subject,html_template:html,text_template:plain});onClose();}catch(reason){setError(redactMessagingError(reason));}finally{setBusy(false);}};return <div className="modal-pad"><h2 className="modal-title">{row?'Edit':'Create'} email template</h2>{error&&<div className="form-alert" role="alert">{error}</div>}<label className="field"><span className="field-label">Template name</span><input value={name} onChange={e=>setName(e.target.value)} required/></label><Tabs items={[{key:'subject',label:'Subject',panel:<label className="field"><span className="field-label">Subject template</span><textarea value={subject} onChange={e=>setSubject(e.target.value)}/></label>},{key:'html',label:'HTML',panel:<><label className="field"><span className="field-label">HTML template</span><textarea value={html} onChange={e=>setHtml(e.target.value)}/></label><SandboxedEmailPreview html={html}/></>},{key:'text',label:'Plain text',panel:<label className="field"><span className="field-label">Plain-text template</span><textarea value={plain} onChange={e=>setPlain(e.target.value)}/></label>}]} /><div className="modal-actions"><button className="btn" disabled={busy} onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save template'}</button></div></div>}
|
|
12
|
-
function TemplateDetail({id,onClose}:{id:number;onClose:()=>void}){const
|
|
10
|
+
function TemplateDetail({id,onClose}:{id:number;onClose:()=>void}){const query=EmailTemplateModel.useOne(id);const save=EmailTemplateModel.useSave();if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading template…':query.error instanceof Error?redactMessagingError(query.error):'Template unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><TemplateEditor row={row} onClose={()=>close(null)} onSave={async changes=>{await save.mutateAsync({id,changes});toast.success('Template updated');await query.refetch();}}/>);return <DetailView title={row.name} subtitle={row.subject_template||'No subject template'} icon="bi-file-earmark-richtext" onClose={onClose} contextMenu={[{label:'Edit template',onSelect:edit}]} sections={[{key:'content',label:'Content',icon:'bi-layout-text-window',render:()=><div className="detail-section"><Tabs items={[{key:'subject',label:'Subject',panel:<FlatRow label="Subject">{row.subject_template||'—'}</FlatRow>},{key:'html',label:'HTML',panel:<SandboxedEmailPreview html={row.html_template}/>},{key:'text',label:'Plain text',panel:<SandboxedEmailPreview text={row.text_template}/>}]} /></div>},]} />;}
|
|
13
11
|
export function EmailTemplatesPage(){const save=EmailTemplateModel.useSave();const add=()=>void modal.open(close=><TemplateEditor onClose={()=>close(null)} onSave={async changes=>{await save.mutateAsync({id:null,changes});toast.success('Template created');}}/>);return <ModelTable model={EmailTemplateModel} eyebrow="Communications · SES" title="Email Templates" columns={columns} defaultSort="name" searchable searchPlaceholder="Search template names" columnChooser persistState persistKey="admin:email:templates" exportFormats={['csv','json']} exporter={exportEmailTemplates} addLabel="Create template" onAdd={add} onRowClick={row=>void modal.detail(close=><TemplateDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Badge, DetailView, FlatRow, ModelTable, fmt, formModal, modal, toast, type Column, type FilterDef, type FormData } from '../../ui';
|
|
3
|
+
import { saveMailbox, sendEmail } from './api';
|
|
4
4
|
import { EmailDomainModel, MailboxModel, type MailboxRow, type SendEmailInput } from './models';
|
|
5
5
|
import { redactMessagingError } from './sanitize';
|
|
6
6
|
|
|
@@ -11,5 +11,5 @@ function domainId(row:MailboxRow){return typeof row.domain==='number'?row.domain
|
|
|
11
11
|
function domainName(row:MailboxRow){return typeof row.domain==='object'?row.domain?.name??'':'';}
|
|
12
12
|
function validMatch(email:string,domain:string){return !domain||email.toLowerCase().endsWith(`@${domain.toLowerCase()}`);}
|
|
13
13
|
async function openSend(row:MailboxRow,qc:ReturnType<typeof useQueryClient>){const data=await formModal({title:`Send test from ${row.email}`,intro:'Only executable send fields are forwarded. A 2xx failed row is still a failed attempt.',fields:[{name:'to',label:'Recipients (comma separated)',type:'text',required:true},{name:'reply_to',label:'Reply-to',type:'email'},{name:'template_name',label:'Template name (optional)',type:'text'},{name:'template_context',label:'Template context JSON',type:'textarea'},{name:'subject',label:'Subject',type:'text'},{name:'body_text',label:'Plain text',type:'textarea'},{name:'body_html',label:'HTML',type:'textarea'}]});if(!data)return;const recipients=String(data.to??'').split(',').map(v=>v.trim()).filter(Boolean);if(!recipients.length){toast.error('Enter at least one recipient');return;}if(!await modal.confirm({title:'Submit email?',message:<><p>Sender: <b>{row.email}</b></p><p>Recipients: {recipients.join(', ')}</p><p>Submission remains pending until webhook updates.</p></>,confirmText:'Submit once'}))return;let context:Record<string,unknown>|undefined;try{if(data.template_context){const parsed=JSON.parse(String(data.template_context));if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();context=parsed as Record<string,unknown>;}}catch{toast.error('Template context must be a plain JSON object');return;}const input:SendEmailInput={from_email:row.email,to:recipients,...(data.reply_to?{reply_to:String(data.reply_to)}:{}),...(data.template_name?{template_name:String(data.template_name),template_context:context??{}}:{subject:String(data.subject??''),body_text:String(data.body_text??''),body_html:String(data.body_html??'')})};try{const sent=await sendEmail(qc,input);if(sent.status==='failed')toast.error('The delivery attempt failed. Open Sent Messages for details.');else toast.success('Message submitted; delivery is pending webhook confirmation.');}catch(e){toast.error(redactMessagingError(e));}}
|
|
14
|
-
function MailboxDetail({id,onClose}:{id:number;onClose:()=>void}){const qc=useQueryClient();const query=MailboxModel.useOne(id);if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading mailbox…':query.error instanceof Error?redactMessagingError(query.error):'Mailbox unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=async()=>{const data=await formModal({title:`Edit ${row.email}`,fields:[...mailboxFields],initial:{...row,domain:domainId(row)??''} as unknown as FormData});if(!data)return;try{const chosen=await EmailDomainModel.fetchOne(qc,Number(data.domain));if(!validMatch(String(data.email),chosen.name)){toast.error(`Email must belong to ${chosen.name}`);return;}await saveMailbox(qc,id,data);toast.success('Mailbox updated; competing defaults were refreshed');await query.refetch();}catch(error){toast.error(redactMessagingError(error));}};
|
|
14
|
+
function MailboxDetail({id,onClose}:{id:number;onClose:()=>void}){const qc=useQueryClient();const query=MailboxModel.useOne(id);if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading mailbox…':query.error instanceof Error?redactMessagingError(query.error):'Mailbox unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=async()=>{const data=await formModal({title:`Edit ${row.email}`,fields:[...mailboxFields],initial:{...row,domain:domainId(row)??''} as unknown as FormData});if(!data)return;try{const chosen=await EmailDomainModel.fetchOne(qc,Number(data.domain));if(!validMatch(String(data.email),chosen.name)){toast.error(`Email must belong to ${chosen.name}`);return;}await saveMailbox(qc,id,data);toast.success('Mailbox updated; competing defaults were refreshed');await query.refetch();}catch(error){toast.error(redactMessagingError(error));}};return <DetailView title={row.email} subtitle={domainName(row)||`Domain #${domainId(row)}`} icon="bi-inbox" onClose={onClose} contextMenu={[{label:'Edit mailbox',onSelect:()=>void edit()},{label:'Send test…',disabled:!row.allow_outbound,onSelect:()=>void openSend(row,qc)}]} initialSection="overview" sections={[{key:'overview',label:'Overview',icon:'bi-info-circle',render:()=><div className="detail-section"><FlatRow label="Inbound">{row.allow_inbound?'Enabled':'Disabled'}</FlatRow><FlatRow label="Outbound">{row.allow_outbound?'Enabled':'Disabled'}</FlatRow><FlatRow label="System default">{row.is_system_default?'Yes':'No'}</FlatRow><FlatRow label="Domain default">{row.is_domain_default?'Yes':'No'}</FlatRow><FlatRow label="Async handler">{row.async_handler||'—'}</FlatRow></div>},]} />;}
|
|
15
15
|
export function MailboxesPage(){const qc=useQueryClient();const add=async()=>{const data=await formModal({title:'Create mailbox',fields:[...mailboxFields]});if(!data)return;try{const chosen=await EmailDomainModel.fetchOne(qc,Number(data.domain));if(!validMatch(String(data.email),chosen.name)){toast.error(`Email must belong to ${chosen.name}`);return;}await saveMailbox(qc,null,data);toast.success('Mailbox created');}catch(error){toast.error(redactMessagingError(error));}};return <ModelTable model={MailboxModel} eyebrow="Communications · SES" title="Mailboxes" columns={columns} filters={filters} defaultSort="email" searchable searchPlaceholder="Search mailbox addresses" columnChooser persistState persistKey="admin:email:mailboxes" addLabel="Create mailbox" onAdd={()=>void add()} onRowClick={row=>void modal.detail(close=><MailboxDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
-
import { ArmedButton, Badge, DetailView, FlatRow, ModelTable, fmt, groupByDay, modal, toast, type BatchAction, type Column, type FilterDef, type Section } from '../../ui';
|
|
3
1
|
import { useCan } from '../../client/runtime';
|
|
4
|
-
import {
|
|
2
|
+
import { Badge, DetailView, FlatRow, ModelTable, fmt, groupByDay, modal, toast, type BatchAction, type Column, type FilterDef, type Section } from '../../ui';
|
|
5
3
|
import { exportPublicMessages } from './data';
|
|
6
|
-
import {
|
|
4
|
+
import { PUBLIC_MESSAGE_MANAGE_PERMISSIONS, PublicMessageModel, type PublicMessageRow } from './models';
|
|
7
5
|
import { publicMessageMailto, redactMessagingError } from './sanitize';
|
|
8
6
|
|
|
9
7
|
const columns:Column<PublicMessageRow>[]=[{key:'created',label:'Received',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},{key:'kind',label:'Kind',sortable:true},{key:'name',label:'Name',sortable:true},{key:'email',label:'Email',sortable:true},{key:'subject',label:'Subject',sortable:true},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={r.status==='open'?'warning':'success'}>{r.status}</Badge>}];
|
|
10
8
|
const filters:FilterDef[]=[{key:'kind__in',label:'Kind',type:'multiselect',options:[{value:'contact_us',label:'Contact us'},{value:'support',label:'Support'}]},{key:'status__in',label:'Status',type:'multiselect',options:[{value:'open',label:'Open'},{value:'closed',label:'Closed'}]},{key:'created',label:'Received',type:'daterange'}];
|
|
11
9
|
export function publicMessageGroupLabel(group:PublicMessageRow['group']):string{return group==null?'—':typeof group==='object'?group.name??`#${group.id}`:`#${group}`;}
|
|
12
|
-
function PublicMessageDetail({id,onClose}:{id:number;onClose:()=>void}){const
|
|
10
|
+
function PublicMessageDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PublicMessageModel.useOne(id);const save=PublicMessageModel.useSave();const canManage=useCan(PUBLIC_MESSAGE_MANAGE_PERMISSIONS).can;if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading contact message…':query.error instanceof Error?redactMessagingError(query.error):'Contact message unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const setStatus=async(status:'open'|'closed')=>{try{await save.mutateAsync({id,changes:{status}});toast.success(`Message marked ${status}`);await query.refetch();}catch(error){toast.error(redactMessagingError(error));}};const mailto=publicMessageMailto(row);const group=publicMessageGroupLabel(row.group);const sections:Section[]=[{key:'message',label:'Message',icon:'bi-envelope-open',render:()=><div className="detail-section"><div className="storage-action-row">{mailto&&<a className="btn" href={mailto}>Open mail app</a>}</div><FlatRow label="Kind">{row.kind}</FlatRow><FlatRow label="Group">{group}</FlatRow><FlatRow label="Received">{fmt.datetime(row.created)}</FlatRow><div className="known-card"><h3>Message</h3><p className="pre-wrap">{row.message||'—'}</p></div></div>},{key:'context',label:'PII & context',icon:'bi-person-vcard',render:()=><div className="detail-section"><p className="form-alert">This section contains personally identifiable request data.</p><FlatRow label="IP address">{row.ip_address||'—'}</FlatRow><FlatRow label="User agent">{row.user_agent||'—'}</FlatRow>{Object.entries(row.metadata??{}).map(([key,value])=><FlatRow key={key} label={key.replaceAll('_',' ')}>{value}</FlatRow>)}{row.metadata_withheld&&<p className="dim">Additional metadata was withheld by the safe projection.</p>}</div>}];return <DetailView title={row.subject} subtitle={`${row.name} · ${row.email}`} icon="bi-chat-left-text" chips={[{text:row.status,tone:row.status==='open'?'warning':'success'}]} onClose={onClose} contextMenu={canManage?[{label:row.status==='open'?'Mark closed':'Mark open',disabled:save.isPending,onSelect:()=>void setStatus(row.status==='open'?'closed':'open')}]:[]} sections={sections} />;}
|
|
13
11
|
export function PublicMessagesPage(){const canManage=useCan(PUBLIC_MESSAGE_MANAGE_PERMISSIONS).can;const save=PublicMessageModel.useSave();const update=async(row:PublicMessageRow,status:'open'|'closed')=>{try{return await save.mutateAsync({id:row.id,changes:{status}});}catch(error){throw new Error(redactMessagingError(error));}};const actions:BatchAction<PublicMessageRow>[]=[{key:'open',label:'Mark open',eligible:r=>r.status!=='open',run:r=>update(r,'open')},{key:'closed',label:'Mark closed',eligible:r=>r.status!=='closed',run:r=>update(r,'closed')}];const grouping=groupByDay<PublicMessageRow>('created');return <ModelTable model={PublicMessageModel} eyebrow="Communications · Support" title="Contact Messages" columns={columns} filters={filters} defaultSort="-created" searchable searchPlaceholder="Search name, email, subject, or message" selectable={canManage} batchActions={canManage?actions:[]} {...grouping} columnChooser persistState persistKey="admin:messaging:public" exportFormats={['csv','json']} exporter={exportPublicMessages} onRowClick={row=>void modal.detail(close=><PublicMessageDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
import { useRef, useState } from 'react';
|
|
2
1
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
3
|
import { useSearchParams } from 'react-router-dom';
|
|
4
4
|
import { KPITile, MetricsChart } from '../../../charts';
|
|
5
5
|
import { useCan } from '../../../client/runtime';
|
|
6
6
|
import { Badge, DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column, type FilterDef } from '../../../ui';
|
|
7
7
|
import { fetchPushGroupChoices, fetchPushStats, parseFcmServiceAccount, savePushConfigImperative, testPushConfigConnection, type FcmCredentialEdit } from './api';
|
|
8
8
|
import {
|
|
9
|
-
PUSH_CONFIG_MANAGE_PERMISSIONS,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
PUSH_CONFIG_MANAGE_PERMISSIONS,
|
|
10
|
+
PUSH_CONFIG_TEST_PERMISSIONS,
|
|
11
|
+
PUSH_CONFIG_VIEW_PERMISSIONS,
|
|
12
|
+
PUSH_DELIVERY_VIEW_PERMISSIONS,
|
|
13
|
+
PUSH_DEVICE_VIEW_PERMISSIONS,
|
|
14
|
+
PUSH_GROUP_DIRECTORY_PERMISSIONS,
|
|
15
|
+
PUSH_METRICS_PERMISSIONS,
|
|
16
|
+
PUSH_TEMPLATE_MANAGE_PERMISSIONS,
|
|
17
|
+
PUSH_TEMPLATE_VIEW_PERMISSIONS,
|
|
18
|
+
PushConfigModel,
|
|
19
|
+
PushDeliveryModel,
|
|
20
|
+
PushDeviceModel,
|
|
21
|
+
PushTemplateModel,
|
|
22
|
+
type PushConfigRow,
|
|
23
|
+
type PushDeliveryRow,
|
|
24
|
+
type PushDeviceRow,
|
|
25
|
+
type PushTemplateRow,
|
|
14
26
|
} from './models';
|
|
15
27
|
|
|
16
28
|
const relationLabel=(value:{id:number;name?:string;display_name?:string}|null,fallback='—')=>value?.name??value?.display_name??(value?`#${value.id}`:fallback);
|
|
@@ -33,12 +45,12 @@ function jsonText(value:Record<string,unknown>):string{return Object.keys(value)
|
|
|
33
45
|
function parseJsonObject(value:string,label:string):Record<string,unknown>{if(!value.trim())return {};let parsed:unknown;try{parsed=JSON.parse(value);}catch{throw new Error(`${label} must be valid JSON.`);}if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error(`${label} must be a JSON object.`);return parsed as Record<string,unknown>;}
|
|
34
46
|
function TemplateEditor({row,close}:{row?:PushTemplateRow;close:(value:PushTemplateRow|null)=>void}){const save=PushTemplateModel.useSave();const canGroups=useCan(PUSH_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['push','group-choices'],queryFn:fetchPushGroupChoices,enabled:canGroups,staleTime:60_000});const [name,setName]=useState(row?.name??'');const [category,setCategory]=useState(row?.category??'general');const [priority,setPriority]=useState<PushTemplateRow['priority']>(row?.priority??'normal');const [group,setGroup]=useState(row?.group?String(row.group.id):'');const [title,setTitle]=useState(row?.title_template??'');const [body,setBody]=useState(row?.body_template??'');const [actionUrl,setActionUrl]=useState(row?.action_url??'');const [data,setData]=useState(jsonText(row?.data_template??{}));const [variables,setVariables]=useState(jsonText(row?.variables??{}));const [active,setActive]=useState(row?.is_active??true);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const submit=async()=>{setBusy(true);setError('');try{const dataTemplate=parseJsonObject(data,'Data template');const variableDocs=parseJsonObject(variables,'Variables');if(!title.trim()&&!body.trim()&&!Object.keys(dataTemplate).length)throw new Error('Provide a title, body, or data template.');const saved=await save.mutateAsync({id:row?.id??null,changes:{name:name.trim(),category:category.trim()||'general',priority,group:group?Number(group):null,title_template:title||null,body_template:body||null,action_url:actionUrl||null,data_template:dataTemplate,variables:variableDocs,is_active:active}});toast.success(row?'Push template updated':'Push template created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Template could not be saved.');}finally{setBusy(false);}};return <div className="modal-pad push-editor"><h2 className="modal-title">{row?'Edit':'Create'} push template</h2>{error&&<div className="form-alert" role="alert">{error}</div>}<div className="form-grid"><label className="field col-6"><span className="field-label">Name</span><input className="input" value={name} onChange={e=>setName(e.target.value)}/></label><label className="field col-3"><span className="field-label">Category</span><input className="input" value={category} onChange={e=>setCategory(e.target.value)}/></label><label className="field col-3"><span className="field-label">Priority</span><select className="input" value={priority} onChange={e=>setPriority(e.target.value as PushTemplateRow['priority'])}><option value="low">Low</option><option value="normal">Normal</option><option value="high">High</option></select></label>{canGroups?<label className="field col-12"><span className="field-label">Group</span><select className="input" value={group} onChange={e=>setGroup(e.target.value)}><option value="">System template</option>{groups.data?.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>:<div className="field col-12"><span className="field-label">Group</span><div className="input" aria-readonly="true">{relationLabel(row?.group??null,'System template')}</div></div>}<label className="field col-12"><span className="field-label">Title template</span><input className="input" value={title} onChange={e=>setTitle(e.target.value)}/></label><label className="field col-12"><span className="field-label">Body template</span><textarea className="input" rows={4} value={body} onChange={e=>setBody(e.target.value)}/></label><label className="field col-12"><span className="field-label">Action URL template</span><input className="input" value={actionUrl} onChange={e=>setActionUrl(e.target.value)}/></label><label className="field col-6"><span className="field-label">Data template JSON</span><textarea className="input" rows={6} value={data} onChange={e=>setData(e.target.value)}/></label><label className="field col-6"><span className="field-label">Variable documentation JSON</span><textarea className="input" rows={6} value={variables} onChange={e=>setVariables(e.target.value)}/></label><label className="check col-4"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/><span>Active</span></label></div><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save template'}</button></div></div>;}
|
|
35
47
|
const templateColumns:Column<PushTemplateRow>[]=[{key:'name',label:'Template',sortable:true,hideable:false},{key:'category',label:'Category',sortable:true},{key:'group',label:'Scope',render:r=>relationLabel(r.group,'System')},{key:'priority',label:'Priority',sortable:true,render:r=><Badge tone={r.priority==='high'?'warning':'info'}>{r.priority}</Badge>},{key:'is_active',label:'Active',sortable:true,render:r=><Badge tone={r.is_active?'success':'muted'}>{r.is_active?'active':'inactive'}</Badge>}];
|
|
36
|
-
function TemplateDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushTemplateModel.useOne(id);const canManage=useCan(PUSH_TEMPLATE_MANAGE_PERMISSIONS).can;if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading template…':'Template unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><TemplateEditor row={row} close={async saved=>{close(saved);if(saved)await query.refetch();}}/>,{size:'lg'});return <DetailView title={row.name} subtitle={`${row.category} · ${relationLabel(row.group,'System')}`} icon="bi-file-earmark-richtext" onClose={onClose} sections={[{key:'template',label:'Template',icon:'bi-card-text',render:()=><div className="detail-section"
|
|
48
|
+
function TemplateDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushTemplateModel.useOne(id);const save=PushTemplateModel.useSave();const canManage=useCan(PUSH_TEMPLATE_MANAGE_PERMISSIONS).can;if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading template…':'Template unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><TemplateEditor row={row} close={async saved=>{close(saved);if(saved)await query.refetch();}}/>,{size:'lg'});const toggle=async(next:boolean)=>{if(!canManage||save.isPending)return;try{await save.mutateAsync({id,changes:{is_active:next}});await query.refetch();}catch(error){toast.error(error instanceof Error?error.message:"State change failed");}};return <DetailView title={row.name} subtitle={`${row.category} · ${relationLabel(row.group,'System')}`} icon="bi-file-earmark-richtext" onClose={onClose} active={canManage?{value:row.is_active,disabled:save.isPending,onChange:next=>void toggle(next)}:undefined} contextMenu={canManage?[{label:'Edit template',onSelect:edit},{label:row.is_active?'Deactivate':'Reactivate',disabled:save.isPending,onSelect:()=>void toggle(!row.is_active)}]:[]} sections={[{key:'template',label:'Template',icon:'bi-card-text',render:()=><div className="detail-section"><FlatRow label="Title">{row.title_template||'—'}</FlatRow><div className="known-card"><h3>Body</h3><p className="pre-wrap">{row.body_template||'—'}</p></div><FlatRow label="Action URL"><code>{row.action_url||'—'}</code></FlatRow><FlatRow label="Data keys">{Object.keys(row.data_template).join(', ')||'—'}</FlatRow><FlatRow label="Variables">{Object.keys(row.variables).join(', ')||'—'}</FlatRow></div>}]} />;}
|
|
37
49
|
export function PushTemplatesPage(){const canManage=useCan(PUSH_TEMPLATE_MANAGE_PERMISSIONS).can;return <ModelTable model={PushTemplateModel} eyebrow="Communications · Push" title="Templates" columns={templateColumns} defaultSort="name" searchable searchPlaceholder="Search name or category" columnChooser persistState persistKey="admin:push:templates" addLabel="Create template" onAdd={canManage?()=>void modal.open(close=><TemplateEditor close={close}/>,{size:'lg'}):undefined} onRowClick={row=>void modal.detail(close=><TemplateDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
38
50
|
|
|
39
51
|
function ConfigEditor({row,close}:{row?:PushConfigRow;close:(value:PushConfigRow|null)=>void}){const qc=useQueryClient();const canGroups=useCan(PUSH_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['push','group-choices'],queryFn:fetchPushGroupChoices,enabled:canGroups,staleTime:60_000});const credentialRef=useRef<HTMLTextAreaElement>(null);const [clearCredential,setClearCredential]=useState(false);const [name,setName]=useState(row?.name??'');const [group,setGroup]=useState(row?.group?String(row.group.id):'');const [sound,setSound]=useState(row?.default_sound??'default');const [active,setActive]=useState(row?.is_active??true);const [testMode,setTestMode]=useState(row?.test_mode??false);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const toggleClear=()=>{if(clearCredential){setClearCredential(false);return;}if(window.confirm('Clear the stored FCM service account on save?'))setClearCredential(true);};const submit=async()=>{setBusy(true);setError('');let credential:FcmCredentialEdit={mode:'untouched'};try{const source=credentialRef.current?.value??'';if(clearCredential)credential={mode:'clear',confirmed:true};else if(source.trim())credential={mode:'replace',value:parseFcmServiceAccount(source)};const saved=await savePushConfigImperative(qc,row?.id??null,{name:name.trim(),group:group?Number(group):null,default_sound:sound.trim()||'default',is_active:active,test_mode:testMode},credential);toast.success(row?'Push configuration updated':'Push configuration created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Configuration could not be saved.');}finally{if(credentialRef.current)credentialRef.current.value='';credential={mode:'untouched'};setBusy(false);}};return <div className="modal-pad push-editor"><h2 className="modal-title">{row?'Edit':'Create'} push configuration</h2><p className="dim">The FCM service account is write-only and never prefills. Leave blank to keep it, enter a validated service-account JSON object to replace it, or explicitly confirm clear.</p>{error&&<div className="form-alert" role="alert">{error}</div>}<div className="form-grid"><label className="field col-8"><span className="field-label">Name</span><input className="input" value={name} onChange={e=>setName(e.target.value)}/></label><label className="field col-4"><span className="field-label">Default sound</span><input className="input" value={sound} onChange={e=>setSound(e.target.value)}/></label>{canGroups?<label className="field col-12"><span className="field-label">Group</span><select className="input" value={group} onChange={e=>setGroup(e.target.value)}><option value="">System default</option>{groups.data?.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>:<div className="field col-12"><span className="field-label">Group</span><div className="input" aria-readonly="true">{relationLabel(row?.group??null,'System default')}</div></div>}<label className="field col-12"><span className="field-label">FCM service account JSON</span><textarea ref={credentialRef} className="input" rows={9} autoComplete="off" spellCheck={false} disabled={clearCredential}/><span className="field-help">Required fields: type=service_account, project_id, client_email, and a PEM private_key.</span></label><div className="col-12"><button type="button" className={`btn${clearCredential?' btn-danger':''}`} onClick={toggleClear}>{clearCredential?'Keep stored credential':'Clear stored credential…'}</button></div><label className="check col-4"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/><span>Active</span></label><label className="check col-4"><input type="checkbox" checked={testMode} onChange={e=>setTestMode(e.target.checked)}/><span>Test mode</span></label></div><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save configuration'}</button></div></div>;}
|
|
40
52
|
const configColumns:Column<PushConfigRow>[]=[{key:'name',label:'Configuration',sortable:true,hideable:false},{key:'group',label:'Scope',render:r=>relationLabel(r.group,'System default')},{key:'fcm_project_id',label:'FCM project',render:r=>r.fcm_project_id||'Not reported'},{key:'test_mode',label:'Test mode',sortable:true,render:r=><Badge tone={r.test_mode?'warning':'muted'}>{r.test_mode?'yes':'no'}</Badge>},{key:'is_active',label:'Active',sortable:true,render:r=><Badge tone={r.is_active?'success':'muted'}>{r.is_active?'active':'inactive'}</Badge>}];
|
|
41
|
-
function ConfigDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushConfigModel.useOne(id);const canManage=useCan(PUSH_CONFIG_MANAGE_PERMISSIONS).can;const canTest=useCan(PUSH_CONFIG_TEST_PERMISSIONS).can;if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading configuration…':'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><ConfigEditor row={row} close={async saved=>{close(saved);if(saved)await query.refetch();}}/>,{size:'lg'});const test=async()=>{try{const result=await testPushConfigConnection(id);toast.success(result.message);}catch{toast.error('FCM connection test failed. Review the stored service account and server logs.');}};return <DetailView title={row.name} subtitle={`${relationLabel(row.group,'System default')} · ${row.fcm_project_id||'FCM project unavailable'}`} icon="bi-broadcast-pin" onClose={onClose} sections={[{key:'configuration',label:'Configuration',icon:'bi-sliders',render:()=><div className="detail-section"><
|
|
53
|
+
function ConfigDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushConfigModel.useOne(id);const qc=useQueryClient();const [saving,setSaving]=useState(false);const canManage=useCan(PUSH_CONFIG_MANAGE_PERMISSIONS).can;const canTest=useCan(PUSH_CONFIG_TEST_PERMISSIONS).can;if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading configuration…':'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><ConfigEditor row={row} close={async saved=>{close(saved);if(saved)await query.refetch();}}/>,{size:'lg'});const test=async()=>{try{const result=await testPushConfigConnection(id);toast.success(result.message);}catch{toast.error('FCM connection test failed. Review the stored service account and server logs.');}};const toggle=async(next:boolean)=>{if(!canManage||saving)return;setSaving(true);try{await savePushConfigImperative(qc,id,{is_active:next},{mode:"untouched"});await query.refetch();}catch(error){toast.error(error instanceof Error?error.message:"State change failed");}finally{setSaving(false);}};return <DetailView title={row.name} subtitle={`${relationLabel(row.group,'System default')} · ${row.fcm_project_id||'FCM project unavailable'}`} icon="bi-broadcast-pin" onClose={onClose} active={canManage?{value:row.is_active,disabled:saving,onChange:next=>void toggle(next)}:undefined} contextMenu={[...(canManage?[{label:'Edit configuration',onSelect:edit},{label:row.is_active?'Deactivate':'Reactivate',disabled:saving,onSelect:()=>void toggle(!row.is_active)}]:[]),...(canTest?[{label:'Test stored credentials',onSelect:()=>void test()}]:[])]} sections={[{key:'configuration',label:'Configuration',icon:'bi-sliders',render:()=><div className="detail-section"><FlatRow label="FCM project">{row.fcm_project_id||'—'}</FlatRow><FlatRow label="Default sound">{row.default_sound}</FlatRow><FlatRow label="Active">{row.is_active?'Yes':'No'}</FlatRow><FlatRow label="Test mode">{row.test_mode?'Yes — sends are faked':'No'}</FlatRow><p className="dim">The connection test uses the backend's dummy registration token to validate credentials. This portal never accepts a device token and never sends a caller test notification.</p></div>}]} />;}
|
|
42
54
|
export function PushConfigsPage(){const canManage=useCan(PUSH_CONFIG_MANAGE_PERMISSIONS).can;return <ModelTable model={PushConfigModel} eyebrow="Communications · Push" title="Configuration" columns={configColumns} defaultSort="name" searchable searchPlaceholder="Search configuration names" columnChooser persistState persistKey="admin:push:configs" addLabel="Create configuration" onAdd={canManage?()=>void modal.open(close=><ConfigEditor close={close}/>,{size:'lg'}):undefined} onRowClick={row=>void modal.detail(close=><ConfigDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
43
55
|
|
|
44
56
|
export function PushPage(){const [searchParams,setSearchParams]=useSearchParams();const devices=useCan(PUSH_DEVICE_VIEW_PERMISSIONS);const deliveries=useCan(PUSH_DELIVERY_VIEW_PERMISSIONS);const templates=useCan(PUSH_TEMPLATE_VIEW_PERMISSIONS);const config=useCan(PUSH_CONFIG_VIEW_PERMISSIONS);const metrics=useCan(PUSH_METRICS_PERMISSIONS);if(devices.isLoading||deliveries.isLoading||templates.isLoading||config.isLoading||metrics.isLoading)return <div className="panel panel-pad dim">Loading Push permissions…</div>;const items=[{key:'stats',label:'Your stats',panel:<PushStatsPanel/>},...(metrics.can?[{key:'metrics',label:'Global metrics',panel:<PushMetricsPanel/>}]:[]),...(devices.can?[{key:'devices',label:'Devices',panel:<PushDevicesPage/>}]:[]),...(deliveries.can?[{key:'deliveries',label:'Deliveries',panel:<PushDeliveriesPage/>}]:[]),...(templates.can?[{key:'templates',label:'Templates',panel:<PushTemplatesPage/>}]:[]),...(config.can?[{key:'config',label:'Config',panel:<PushConfigsPage/>}]:[])];const requested=searchParams.get('push_surface')??'stats';const select=(key:string)=>setSearchParams({push_surface:key},{replace:true});return <div className="push-page"><div className="push-heading"><p className="eyebrow">Communications</p><h1>Push Notifications</h1><p className="dim">One global, no-group control plane. Each tab has an independent backend-derived gate; only the active authorized tab mounts and fetches.</p></div><Tabs ariaLabel="Push administration areas" variant="underline-all" items={items} activeKey={requested} onActiveKeyChange={select}/></div>;}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { DetailView, JsonBlock, fmt, toast, type Tone } from '../../ui';
|
|
3
3
|
import type { LogRow } from './models';
|
|
4
4
|
|
|
5
5
|
export type StoredLogKind = 'request' | 'response' | 'message';
|
|
@@ -111,23 +111,7 @@ export function LogInspector({ log, onClose }: { log: LogRow; onClose?: () => vo
|
|
|
111
111
|
const route = [log.method, log.path].filter(Boolean).join(' ') || 'No request route';
|
|
112
112
|
|
|
113
113
|
return (
|
|
114
|
-
<div className="monitoring-inspector">
|
|
115
|
-
<header className="monitoring-inspector-head">
|
|
116
|
-
<div>
|
|
117
|
-
<div className="eyebrow">Stored {recordKind} record</div>
|
|
118
|
-
<h2>{route}</h2>
|
|
119
|
-
<p>{fmt.datetime(log.created)} · log #{log.id}</p>
|
|
120
|
-
</div>
|
|
121
|
-
<div className="monitoring-inspector-actions">
|
|
122
|
-
<Badge tone={LEVEL_TONE[log.level.toLowerCase()] ?? 'muted'}>{log.level}</Badge>
|
|
123
|
-
{onClose && (
|
|
124
|
-
<button type="button" className="btn-icon" onClick={onClose} aria-label="Close inspector" title="Close">
|
|
125
|
-
<i className="bi bi-x-lg" />
|
|
126
|
-
</button>
|
|
127
|
-
)}
|
|
128
|
-
</div>
|
|
129
|
-
</header>
|
|
130
|
-
|
|
114
|
+
<DetailView title={route} subtitle={`Stored ${recordKind} record · ${fmt.datetime(log.created)} · log #${log.id}`} icon="bi-journal-text" onClose={onClose} chips={[{ text: log.level, tone: LEVEL_TONE[log.level.toLowerCase()] ?? 'muted' }]} sections={[{ key: 'record', label: 'Record', icon: 'bi-file-text', render: () => <div className="monitoring-inspector">
|
|
131
115
|
<div className="monitoring-facts">
|
|
132
116
|
<Fact label="Kind">{log.kind || 'message'}</Fact>
|
|
133
117
|
<Fact label="User">{log.username || (log.uid ? `#${log.uid}` : '—')}</Fact>
|
|
@@ -155,6 +139,6 @@ export function LogInspector({ log, onClose }: { log: LogRow; onClose?: () => vo
|
|
|
155
139
|
The API exposes no request/response correlation key, so no paired record is claimed.
|
|
156
140
|
</p>
|
|
157
141
|
)}
|
|
158
|
-
</div>
|
|
142
|
+
</div> }]} />
|
|
159
143
|
);
|
|
160
144
|
}
|