portal-mojo 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/DetailView.md +19 -15
- package/docs/README.md +1 -0
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +13 -5
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/feedback.md +10 -0
- package/docs/forms.md +10 -0
- package/docs/popover.md +15 -0
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +47 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +6 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +89 -5
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/modal.tsx +8 -4
|
@@ -1,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
|
}
|
|
@@ -2,19 +2,30 @@
|
|
|
2
2
|
// Port of web-mojo `admin/security/IPSetView.js` (TabView: Configuration +
|
|
3
3
|
// CIDR Data, with a fleet-operations kebab), rebuilt on the house detail
|
|
4
4
|
// modal with every fleet-affecting action behind an armed confirmation.
|
|
5
|
-
import { useState } from 'react';
|
|
6
5
|
import { useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
|
+
import { useCan } from '../../client/runtime';
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
Badge,
|
|
10
|
+
DetailView,
|
|
11
|
+
Eyebrow,
|
|
12
|
+
FlatRow,
|
|
13
|
+
StatusPanel,
|
|
14
|
+
fmt,
|
|
15
|
+
modal,
|
|
16
|
+
toast,
|
|
10
17
|
} from '../../ui';
|
|
11
|
-
import {
|
|
18
|
+
import { promptEditIPSet } from './IPSetEditor';
|
|
12
19
|
import {
|
|
13
|
-
IPSET_CACHE_ONLY_HELP,
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
IPSET_CACHE_ONLY_HELP,
|
|
21
|
+
IPSET_KIND_BADGE_OPTIONS,
|
|
22
|
+
IPSET_MANAGE_PERMS,
|
|
23
|
+
IPSET_SOURCE_OPTIONS,
|
|
24
|
+
IPSetModel,
|
|
25
|
+
isCacheOnlyIPSet,
|
|
26
|
+
useIPSetCidrData,
|
|
27
|
+
type IPSetRow,
|
|
16
28
|
} from './models';
|
|
17
|
-
import { promptEditIPSet } from './IPSetEditor';
|
|
18
29
|
|
|
19
30
|
const DASH = <span className="dim-italic">—</span>;
|
|
20
31
|
|
|
@@ -100,12 +111,14 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
100
111
|
const qc = useQueryClient();
|
|
101
112
|
const { data: row, isPending, error } = IPSetModel.useOne(id);
|
|
102
113
|
const canManage = useCan(IPSET_MANAGE_PERMS).can;
|
|
103
|
-
const
|
|
114
|
+
const allowed = useRef(canManage); allowed.current = canManage;
|
|
115
|
+
useEffect(() => { allowed.current = canManage; return () => { allowed.current = false; }; }, [canManage]);
|
|
116
|
+
|
|
104
117
|
const sync = IPSetModel.useAction('sync');
|
|
105
118
|
const enable = IPSetModel.useAction('enable');
|
|
106
119
|
const disable = IPSetModel.useAction('disable');
|
|
107
120
|
const refresh = IPSetModel.useAction('refresh_source');
|
|
108
|
-
|
|
121
|
+
|
|
109
122
|
const save = IPSetModel.useSave();
|
|
110
123
|
|
|
111
124
|
if (isPending) return <div className="modal-pad dim">Loading IP set…</div>;
|
|
@@ -114,6 +127,7 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
114
127
|
const cacheOnly = isCacheOnlyIPSet(row);
|
|
115
128
|
|
|
116
129
|
const run = async (label: string, fn: () => Promise<unknown>) => {
|
|
130
|
+
if (!allowed.current) return;
|
|
117
131
|
try {
|
|
118
132
|
await fn();
|
|
119
133
|
toast.success(label);
|
|
@@ -126,7 +140,7 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
126
140
|
|
|
127
141
|
const onEdit = async () => {
|
|
128
142
|
const changes = await promptEditIPSet(row);
|
|
129
|
-
if (!changes) return;
|
|
143
|
+
if (!changes || !allowed.current) return;
|
|
130
144
|
try {
|
|
131
145
|
await save.mutateAsync({ id, changes });
|
|
132
146
|
// A `data` save re-runs set_data() server-side, so the detailed
|
|
@@ -149,6 +163,12 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
149
163
|
...(cacheOnly ? [{ icon: 'bi-database-lock', text: 'CACHE-ONLY', tone: 'warning' as const }] : []),
|
|
150
164
|
...(row.sync_error ? [{ icon: 'bi-exclamation-triangle', text: 'Sync error', tone: 'danger' as const }] : []),
|
|
151
165
|
]}
|
|
166
|
+
contextMenu={[
|
|
167
|
+
{ label: 'Edit IP set', permissions: IPSET_MANAGE_PERMS, disabled: save.isPending, onSelect: () => void onEdit() },
|
|
168
|
+
{ label: row.is_enabled ? 'Disable & remove from fleet' : 'Enable & sync', permissions: IPSET_MANAGE_PERMS, disabled: cacheOnly || enable.isPending || disable.isPending, onSelect: async () => { if (await modal.confirm({ title: row.is_enabled ? 'Disable fleet enforcement?' : 'Enable fleet enforcement?', message: `${row.name}: changes this set on every fleet instance.`, confirmText: row.is_enabled ? 'Disable' : 'Enable' })) await run('IP set state updated', () => (row.is_enabled ? disable : enable).mutateAsync({ id })); } },
|
|
169
|
+
{ label: 'Sync to fleet', permissions: IPSET_MANAGE_PERMS, disabled: cacheOnly || sync.isPending, onSelect: async () => { if (await modal.confirm({ title: 'Sync to fleet?', message: `Push ${row.name} to every fleet instance now.`, confirmText: 'Sync' })) await run('Sync requested', () => sync.mutateAsync({ id })); } },
|
|
170
|
+
{ label: 'Refresh source', permissions: IPSET_MANAGE_PERMS, disabled: refresh.isPending, onSelect: async () => { if (await modal.confirm({ title: 'Refresh source?', message: `Replace stored CIDRs for ${row.name} from its provider list.`, confirmText: 'Refresh' })) await run('Source refresh requested', () => refresh.mutateAsync({ id })); } },
|
|
171
|
+
]}
|
|
152
172
|
sections={[
|
|
153
173
|
{
|
|
154
174
|
key: 'config', label: 'Configuration', icon: 'bi-sliders', render: () => (
|
|
@@ -164,58 +184,7 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
164
184
|
meta={row.last_synced == null
|
|
165
185
|
? 'Never synced to the fleet.'
|
|
166
186
|
: `Last synced ${fmt.datetime(row.last_synced)} · ${fmt.relative(row.last_synced)}`}
|
|
167
|
-
|
|
168
|
-
<div className="netsec-action-row">
|
|
169
|
-
{row.is_enabled ? (
|
|
170
|
-
<ArmedButton
|
|
171
|
-
className="btn-compact"
|
|
172
|
-
icon="bi-toggle-off"
|
|
173
|
-
label="Disable & remove from fleet"
|
|
174
|
-
armedLabel="Click again — every fleet instance drops this set from iptables"
|
|
175
|
-
onConfirm={() => run('IP set disabled and removed from the fleet', () => disable.mutateAsync({ id }))}
|
|
176
|
-
/>
|
|
177
|
-
) : (
|
|
178
|
-
<ArmedButton
|
|
179
|
-
className="btn-compact"
|
|
180
|
-
icon="bi-toggle-on"
|
|
181
|
-
label="Enable & sync"
|
|
182
|
-
armedLabel={cacheOnly
|
|
183
|
-
? 'Click again — the server will refuse this (cache-only list)'
|
|
184
|
-
: `Click again — ${row.cidr_count.toLocaleString()} ranges are kernel-blocked fleet-wide`}
|
|
185
|
-
onConfirm={() => run('IP set enabled and synced', () => enable.mutateAsync({ id }))}
|
|
186
|
-
/>
|
|
187
|
-
)}
|
|
188
|
-
<ArmedButton
|
|
189
|
-
className="btn-compact"
|
|
190
|
-
icon="bi-broadcast"
|
|
191
|
-
label="Sync to fleet"
|
|
192
|
-
armedLabel="Click again — pushes this set to every fleet instance now"
|
|
193
|
-
onConfirm={() => run('Sync broadcast to the fleet', () => sync.mutateAsync({ id }))}
|
|
194
|
-
/>
|
|
195
|
-
<ArmedButton
|
|
196
|
-
className="btn-compact"
|
|
197
|
-
icon="bi-arrow-clockwise"
|
|
198
|
-
label="Refresh source"
|
|
199
|
-
armedLabel="Click again — refetches the provider list and replaces the stored CIDRs"
|
|
200
|
-
onConfirm={() => run('Source refresh requested', () => refresh.mutateAsync({ id }))}
|
|
201
|
-
/>
|
|
202
|
-
<button className="btn btn-compact" onClick={() => void onEdit()}>
|
|
203
|
-
<i className="bi bi-pencil" /> Edit
|
|
204
|
-
</button>
|
|
205
|
-
{canDelete && (
|
|
206
|
-
<ArmedButton
|
|
207
|
-
className="btn-compact btn-danger"
|
|
208
|
-
icon="bi-trash"
|
|
209
|
-
label="Delete"
|
|
210
|
-
armedLabel="Click again — deletes the set and removes it from every fleet instance. This cannot be undone."
|
|
211
|
-
onConfirm={() => run('IP set deleted', async () => {
|
|
212
|
-
await remove.mutateAsync({ id });
|
|
213
|
-
onClose();
|
|
214
|
-
})}
|
|
215
|
-
/>
|
|
216
|
-
)}
|
|
217
|
-
</div>
|
|
218
|
-
) : null}
|
|
187
|
+
|
|
219
188
|
/>
|
|
220
189
|
|
|
221
190
|
{cacheOnly && (
|