portal-mojo 0.2.3 → 0.2.5
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/ModelTable.md +26 -0
- package/docs/README.md +3 -1
- package/docs/admin-fleet-configuration.md +50 -0
- package/docs/admin-phonehub.md +11 -1
- package/docs/admin-push.md +85 -6
- package/docs/admin-settings.md +10 -0
- package/docs/admin-signin.md +73 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +60 -1
- package/docs/forms.md +41 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +3 -3
- package/docs/realtime.md +5 -0
- package/package.json +1 -1
- package/src/admin/credentials/group-api-keys.tsx +20 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +21 -0
- package/src/admin/domains/identity.ts +7 -1
- package/src/admin/domains/operations.ts +8 -1
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
- package/src/admin/fleet-configuration/data.ts +89 -0
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/index.ts +3 -0
- package/src/admin/messaging/push/PushPage.tsx +61 -13
- package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
- package/src/admin/messaging/push/api.ts +25 -1
- package/src/admin/messaging/push/models.ts +7 -6
- package/src/admin/phonehub/PhoneHubPage.tsx +19 -8
- package/src/admin/phonehub/api.ts +5 -5
- package/src/admin/public/identity.ts +1 -0
- package/src/admin/public/operations.ts +1 -0
- package/src/admin/registry.ts +4 -2
- package/src/admin/settings/SettingDetail.tsx +2 -1
- package/src/admin/settings/SettingValue.tsx +37 -0
- package/src/admin/settings/SettingsPage.tsx +2 -1
- package/src/admin/signin/SigninPage.tsx +397 -0
- package/src/admin/signin/api.ts +83 -0
- package/src/admin/signin/index.ts +4 -0
- package/src/client/fleet-mock.ts +61 -0
- package/src/client/mock.ts +240 -11
- package/src/client/realtime.ts +12 -0
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- 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/toast.tsx +48 -5
|
@@ -4,13 +4,14 @@ 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
|
-
import { fetchPushGroupChoices, fetchPushStats, parseFcmServiceAccount, savePushConfigImperative, testPushConfigConnection, type FcmCredentialEdit } from './api';
|
|
7
|
+
import { fetchPushGroupChoices, fetchPushStats, parseFcmServiceAccount, savePushConfigImperative, testPushConfigConnection, type FcmCredentialEdit, type PushTestResult } from './api';
|
|
8
8
|
import {
|
|
9
9
|
PUSH_CONFIG_MANAGE_PERMISSIONS,
|
|
10
10
|
PUSH_CONFIG_TEST_PERMISSIONS,
|
|
11
11
|
PUSH_CONFIG_VIEW_PERMISSIONS,
|
|
12
12
|
PUSH_DELIVERY_VIEW_PERMISSIONS,
|
|
13
13
|
PUSH_DEVICE_VIEW_PERMISSIONS,
|
|
14
|
+
PUSH_DEVICE_TEST_PERMISSIONS,
|
|
14
15
|
PUSH_GROUP_DIRECTORY_PERMISSIONS,
|
|
15
16
|
PUSH_METRICS_PERMISSIONS,
|
|
16
17
|
PUSH_TEMPLATE_MANAGE_PERMISSIONS,
|
|
@@ -25,32 +26,79 @@ import {
|
|
|
25
26
|
type PushTemplateRow,
|
|
26
27
|
} from './models';
|
|
27
28
|
|
|
29
|
+
import { openPushDeviceTest, PushTestResultView, usePushRequestOwner } from './PushTestDialog';
|
|
30
|
+
|
|
28
31
|
const relationLabel=(value:{id:number;name?:string;display_name?:string}|null,fallback='—')=>value?.name??value?.display_name??(value?`#${value.id}`:fallback);
|
|
29
|
-
const statusTone=(status:string)=>status==='
|
|
32
|
+
const statusTone=(status:string)=>status==='accepted'||status==='delivered'?'success':status==='failed'||status==='rejected'||status==='blocked'?'danger':'warning';
|
|
33
|
+
const outcomeLabel=(row:PushDeliveryRow)=>row.push_outcome??row.status;
|
|
34
|
+
const outcomeHelp=(row:PushDeliveryRow)=>({accepted:'FCM accepted the message. Device receipt has not been confirmed.',delivered:'The device reported receipt of this notification.',simulated:'Simulation only. No notification was sent to FCM.',unknown:'Provider acceptance is unknown. Check the device before sending again.',sent:'Recorded as sent. This older record does not establish provider acceptance or device receipt.',pending:'No confirmed provider acceptance is recorded.',rejected:'FCM rejected this message.',blocked:'The send was blocked before provider acceptance.',failed:'The send attempt failed.'})[outcomeLabel(row)]??'No delivery confirmation is available.';
|
|
30
35
|
|
|
31
|
-
export function PushStatsPanel(){const query=useQuery({queryKey:['/api/account/devices/push/stats','caller'],queryFn:fetchPushStats,staleTime:30_000});const row=query.data;return <div className="push-stats"><section><h2>Your activity</h2><p className="dim">
|
|
36
|
+
export function PushStatsPanel(){const query=useQuery({queryKey:['/api/account/devices/push/stats','caller'],queryFn:fetchPushStats,staleTime:30_000});const row=query.data;return <div className="push-stats"><section><h2>Your activity</h2><p className="dim">Activity for your account. Recorded sent counts may include simulated sends.</p><div className="push-kpi-grid"><KPITile label="Recorded sent" value={row?.total_sent} loading={query.isPending}/><KPITile label="Failed" value={row?.total_failed} tone="bad" loading={query.isPending}/><KPITile label="Pending" value={row?.total_pending} loading={query.isPending}/></div></section><section><h2>Your devices</h2><div className="push-kpi-grid push-kpi-grid-two"><KPITile label="Registered devices" value={row?.registered_devices} loading={query.isPending}/><KPITile label="Enabled devices" value={row?.enabled_devices} loading={query.isPending}/></div></section>{query.error&&<div className="form-alert">Caller push statistics are unavailable.</div>}</div>;}
|
|
32
37
|
export function PushMetricsPanel(){return <div className="panel panel-pad"><p className="eyebrow">Global metrics account</p><MetricsChart title="Push delivery activity" slugs={['push_sent','push_failed']} seriesLabels={{push_sent:'Sent',push_failed:'Failed'}} account="global" defaultRange="7d" defaultGranularity="hours" defaultType="line"/></div>;}
|
|
33
38
|
|
|
34
39
|
const deviceColumns:Column<PushDeviceRow>[]=[{key:'last_seen',label:'Last seen',sortable:true,hideable:false,render:r=>fmt.datetime(r.last_seen)},{key:'device_name',label:'Device',sortable:true,render:r=>r.device_name||r.device_id},{key:'user',label:'User',render:r=>relationLabel(r.user)},{key:'platform',label:'Platform',sortable:true,render:r=><Badge tone="info">{r.platform}</Badge>},{key:'app_version',label:'App version',render:r=>r.app_version||'—'},{key:'push_enabled',label:'Push',sortable:true,render:r=><Badge tone={r.push_enabled?'success':'muted'}>{r.push_enabled?'enabled':'disabled'}</Badge>}];
|
|
35
40
|
const deviceFilters:FilterDef[]=[{key:'platform',label:'Platform',type:'select',options:['ios','android','web'].map(value=>({value,label:value}))},{key:'push_enabled',label:'Push enabled',type:'boolean'},{key:'is_active',label:'Active',type:'boolean'}];
|
|
36
|
-
function DeviceDetail({id,onClose}:{id:number;onClose:()=>void})
|
|
41
|
+
function DeviceDetail({id,onClose}:{id:number;onClose:()=>void}) {
|
|
42
|
+
const query=PushDeviceModel.useOne(id);const canSend=useCan(PUSH_DEVICE_TEST_PERMISSIONS).can;const canView=useCan(PUSH_DEVICE_VIEW_PERMISSIONS).can;
|
|
43
|
+
if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading registered device…':'Registered device unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
|
|
44
|
+
const row=query.data;
|
|
45
|
+
return <DetailView title={row.device_name||row.device_id} subtitle={`${row.platform} · ${relationLabel(row.user)}`} icon="bi-phone" onClose={onClose}
|
|
46
|
+
chips={[{text:row.is_active===null?'Activity unknown':row.is_active?'Active':'Inactive',tone:row.is_active?'success':'muted'},{text:row.push_enabled?'Push enabled':'Push disabled',tone:row.push_enabled?'info':'warning'}]}
|
|
47
|
+
contextMenu={canSend&&canView?[{label:'Send test push',icon:'bi-send',onSelect:()=>void openPushDeviceTest(row)}]:[]}
|
|
48
|
+
sections={[{key:'device',label:'Overview',icon:'bi-device-ssd',render:()=><div className="detail-section"><FlatRow label="User">{relationLabel(row.user)}</FlatRow><FlatRow label="Registration">#{row.id}</FlatRow><FlatRow label="Device ID"><code>{row.device_id}</code></FlatRow><FlatRow label="Platform">{row.platform}</FlatRow><FlatRow label="OS version">{row.os_version||'—'}</FlatRow><FlatRow label="App version">{row.app_version||'—'}</FlatRow><FlatRow label="Last seen">{fmt.datetime(row.last_seen)}</FlatRow></div>},
|
|
49
|
+
{key:'preferences',label:'Preferences',icon:'bi-sliders',render:()=><div className="detail-section"><FlatRow label="Push notifications">{row.push_enabled?'Enabled':'Disabled'}</FlatRow>{Object.keys(row.push_preferences).length?Object.entries(row.push_preferences).map(([key,value])=><FlatRow key={key} label={key}>{value?'Enabled':'Disabled'}</FlatRow>):<p className="dim">All notification categories use their default preference.</p>}<p className="dim">Test pushes respect the device’s test category preference.</p></div>}]} />;
|
|
50
|
+
}
|
|
37
51
|
export function PushDevicesPage(){return <ModelTable model={PushDeviceModel} eyebrow="Communications · Push" title="Registered Devices" columns={deviceColumns} filters={deviceFilters} defaultSort="-last_seen" searchable searchPlaceholder="Search device name or device id" columnChooser persistState persistKey="admin:push:devices" onRowClick={row=>void modal.detail(close=><DeviceDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
38
52
|
|
|
39
|
-
const deliveryColumns:Column<PushDeliveryRow>[]=[{key:'created',label:'Created',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},{key:'title',label:'Title',render:r=>r.title||`[${r.category} data]`},{key:'user',label:'User',render:r=>relationLabel(r.user)},{key:'device',label:'Device',render:r=>relationLabel(r.device)},{key:'category',label:'Category',sortable:true},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={statusTone(r
|
|
53
|
+
const deliveryColumns:Column<PushDeliveryRow>[]=[{key:'created',label:'Created',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},{key:'title',label:'Title',render:r=>r.title||`[${r.category} data]`},{key:'user',label:'User',render:r=>relationLabel(r.user)},{key:'device',label:'Device',render:r=>relationLabel(r.device)},{key:'category',label:'Category',sortable:true},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={statusTone(outcomeLabel(r))}>{outcomeLabel(r)}</Badge>}];
|
|
40
54
|
const deliveryFilters:FilterDef[]=[{key:'status',label:'Status',type:'select',options:['pending','sent','delivered','failed'].map(value=>({value,label:value}))},{key:'category',label:'Category',type:'text'},{key:'created',label:'Created',type:'daterange'}];
|
|
41
|
-
function DeliveryDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushDeliveryModel.useOne(id);if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading delivery…':'Delivery unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={row.title||`[${row.category} data]`} subtitle={`${row
|
|
55
|
+
function DeliveryDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushDeliveryModel.useOne(id);if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading delivery…':'Delivery unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={row.title||`[${row.category} data]`} subtitle={`${outcomeLabel(row)} · ${relationLabel(row.device)}`} icon="bi-bell" chips={[{text:outcomeLabel(row),tone:statusTone(outcomeLabel(row))}]} onClose={onClose} sections={[{key:'preview',label:'Notification',icon:'bi-app-indicator',render:()=><div className="detail-section"><div className="push-preview"><strong>{row.title||'Data-only notification'}</strong><p>{row.body||'No visible message body.'}</p></div><FlatRow label="Category">{row.category}</FlatRow><FlatRow label="Action URL"><code>{row.action_url||'—'}</code></FlatRow></div>},{key:'delivery',label:'Delivery',icon:'bi-truck',render:()=><div className="detail-section"><p className="push-test-result">{outcomeHelp(row)}</p><FlatRow label="User">{relationLabel(row.user)}</FlatRow><FlatRow label="Device">{relationLabel(row.device)}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Sent">{row.sent_at?fmt.datetime(row.sent_at):'—'}</FlatRow><FlatRow label="Delivered">{row.delivered_at?fmt.datetime(row.delivered_at):'—'}</FlatRow><FlatRow label="Error">{row.error_message||'—'}</FlatRow><p className="dim">A sent timestamp records a send attempt; receipt is confirmed only by a device delivery report.</p></div>}]} />;}
|
|
42
56
|
export function PushDeliveriesPage(){return <ModelTable model={PushDeliveryModel} eyebrow="Communications · Push" title="Deliveries" columns={deliveryColumns} filters={deliveryFilters} defaultSort="-created" searchable searchPlaceholder="Search title or category" autoRefresh={20} columnChooser persistState persistKey="admin:push:deliveries" onRowClick={row=>void modal.detail(close=><DeliveryDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
43
57
|
|
|
44
58
|
function jsonText(value:Record<string,unknown>):string{return Object.keys(value).length?JSON.stringify(value,null,2):'';}
|
|
45
59
|
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>;}
|
|
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>}<
|
|
60
|
+
function TemplateEditor({row,close,pending}:{row?:PushTemplateRow;pending:{current:boolean};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()=>{if(pending.current)return;pending.current=true;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{pending.current=false;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>}<fieldset disabled={busy} className="form-grid push-fields"><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></fieldset><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>;}
|
|
47
61
|
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>}];
|
|
48
|
-
function TemplateDetail({id,onClose}:{id:number;onClose:()=>void})
|
|
49
|
-
|
|
62
|
+
function TemplateDetail({id,onClose}:{id:number;onClose:()=>void}) {
|
|
63
|
+
const query=PushTemplateModel.useOne(id);const save=PushTemplateModel.useSave();const canManage=useCan(PUSH_TEMPLATE_MANAGE_PERMISSIONS).can;
|
|
64
|
+
if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading template…':'Template unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
|
|
65
|
+
const row=query.data;const edit=()=>void openTemplateEditor(row,()=>query.refetch());
|
|
66
|
+
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');}};
|
|
67
|
+
return <DetailView title={row.name} subtitle={`${row.category} · ${relationLabel(row.group,'System template')}`} icon="bi-file-earmark-richtext" onClose={onClose}
|
|
68
|
+
active={canManage?{value:row.is_active,disabled:save.isPending,onChange:next=>void toggle(next)}:undefined}
|
|
69
|
+
contextMenu={canManage?[{label:'Edit template',disabled:save.isPending,onSelect:edit},{label:row.is_active?'Deactivate':'Reactivate',disabled:save.isPending,onSelect:()=>void toggle(!row.is_active)}]:[]}
|
|
70
|
+
sections={[{key:'overview',label:'Overview',icon:'bi-info-circle',render:()=><div className="detail-section"><FlatRow label="Template ID">#{row.id}</FlatRow><FlatRow label="Scope">{relationLabel(row.group,'System template')}</FlatRow><FlatRow label="Category">{row.category}</FlatRow><FlatRow label="Priority">{row.priority}</FlatRow><FlatRow label="Active">{row.is_active?'Yes':'No'}</FlatRow></div>},
|
|
71
|
+
{key:'template',label:'Notification',icon:'bi-card-text',render:()=><div className="detail-section"><div className="push-preview"><strong>{row.title_template||'Data-only notification'}</strong><p>{row.body_template||'No visible message body.'}</p></div><FlatRow label="Action URL"><code>{row.action_url||'—'}</code></FlatRow><FlatRow label="Data keys">{Object.keys(row.data_template).join(', ')||'—'}</FlatRow></div>},
|
|
72
|
+
{key:'variables',label:'Variables',icon:'bi-braces',render:()=><div className="detail-section">{Object.keys(row.variables).length?Object.entries(row.variables).map(([key,value])=><FlatRow key={key} label={key}>{String(value??'—')}</FlatRow>):<p className="dim">No variables documented.</p>}</div>}]} />;
|
|
73
|
+
}
|
|
74
|
+
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 openTemplateEditor():undefined} onRowClick={row=>void modal.detail(close=><TemplateDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
50
75
|
|
|
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>}<
|
|
76
|
+
function ConfigEditor({row,close,pending}:{row?:PushConfigRow;pending:{current:boolean};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()=>{if(pending.current)return;pending.current=true;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'};pending.current=false;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>}<fieldset disabled={busy} className="form-grid push-fields"><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>Simulation mode (no real sends)</span></label></fieldset><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>;}
|
|
52
77
|
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>}];
|
|
53
|
-
function ConfigDetail({id,onClose}:{id:number;onClose:()=>void})
|
|
54
|
-
|
|
78
|
+
function ConfigDetail({id,onClose}:{id:number;onClose:()=>void}) {
|
|
79
|
+
const query=PushConfigModel.useOne(id);const qc=useQueryClient();const [saving,setSaving]=useState(false);const [testing,setTesting]=useState(false);const testPending=useRef(false);
|
|
80
|
+
const canManage=useCan(PUSH_CONFIG_MANAGE_PERMISSIONS).can;const tester=useCan(PUSH_CONFIG_TEST_PERMISSIONS);const canTest=tester.can;
|
|
81
|
+
const revision=`${id}:${query.data?.modified}:${canTest}:${tester.me?.id}`;const own=usePushRequestOwner(revision);
|
|
82
|
+
const [result,setResult]=useState<{value:PushTestResult;at:number;revision:string}|null>(null);
|
|
83
|
+
if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading configuration…':'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
|
|
84
|
+
const row=query.data;const busy=saving||testing;
|
|
85
|
+
const edit=()=>{if(!canManage||testPending.current||saving)return;setResult(null);void openConfigEditor(row,()=>query.refetch());};
|
|
86
|
+
const test=async()=>{if(!canTest||testPending.current||saving)return;testPending.current=true;setTesting(true);setResult(null);const current=own();try{const value=await testPushConfigConnection(id);if(current())setResult({value,at:Date.now()/1000,revision});}finally{testPending.current=false;setTesting(false);}};
|
|
87
|
+
const toggle=async(next:boolean)=>{if(!canManage||busy||testPending.current)return;setResult(null);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);}};
|
|
88
|
+
const shown=result?.revision===revision?result:null;
|
|
89
|
+
return <DetailView title={row.name} subtitle={`${relationLabel(row.group,'System default')} · ${row.fcm_project_id||'FCM project unavailable'}`} icon="bi-broadcast-pin" onClose={onClose}
|
|
90
|
+
chips={[{text:row.test_mode?'Simulation mode':'Real sends',tone:row.test_mode?'warning':'info'}]}
|
|
91
|
+
active={canManage?{value:row.is_active,disabled:busy,onChange:next=>void toggle(next)}:undefined}
|
|
92
|
+
contextMenu={[...(canManage?[{label:'Edit configuration',disabled:busy,onSelect:edit},{label:row.is_active?'Deactivate':'Reactivate',disabled:busy,onSelect:()=>void toggle(!row.is_active)}]:[]),...(canTest?[{label:testing?'Checking FCM…':'Check FCM connection',disabled:busy,onSelect:()=>void test()}]:[])]}
|
|
93
|
+
sections={[{key:'overview',label:'Overview',icon:'bi-sliders',render:()=><div className="detail-section"><FlatRow label="Scope">{relationLabel(row.group,'System default')}{row.group?` · #${row.group.id}`:''}</FlatRow><FlatRow label="Configuration ID">#{row.id}</FlatRow><FlatRow label="Active">{row.is_active?'Yes':'No'}</FlatRow><FlatRow label="Send mode">{row.test_mode?'Simulation — device sends are skipped':'Real notifications'}</FlatRow><FlatRow label="Default sound">{row.default_sound}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Updated">{fmt.datetime(row.modified)}</FlatRow><p className="dim">A user’s active organization configuration takes precedence over the system default. The device test shows which configuration applies.</p>{shown&&<PushTestResultView result={shown.value} at={shown.at}/>}</div>},
|
|
94
|
+
{key:'connection',label:'Connection',icon:'bi-plug',render:()=><div className="detail-section"><FlatRow label="Provider">Firebase Cloud Messaging (HTTP v1)</FlatRow><FlatRow label="FCM project">{row.fcm_project_id||'Not reported'}</FlatRow><FlatRow label="Service account">{row.fcm_client_email||'Not reported'}</FlatRow><FlatRow label="Credentials">{row.has_fcm_credentials===null?'Presence not reported':row.has_fcm_credentials?'Stored':'Missing'}</FlatRow>
|
|
95
|
+
<p className="dim">Check FCM connection verifies the stored service account’s permission to send to this project. It contacts FCM even in simulation mode and does not deliver a notification.</p>
|
|
96
|
+
{testing?<p role="status">Checking FCM connection…</p>:shown?<PushTestResultView result={shown.value} at={shown.at}/>:<p className="dim">No connection check for this configuration in this view.</p>}
|
|
97
|
+
</div>}]} />;
|
|
98
|
+
}
|
|
99
|
+
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 openConfigEditor():undefined} onRowClick={row=>void modal.detail(close=><ConfigDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
100
|
+
|
|
101
|
+
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({...Object.fromEntries([...searchParams].filter(([name])=>name==='demo')),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">Inspect registered devices, send test notifications, and verify your push configuration.</p></div><Tabs ariaLabel="Push administration areas" variant="underline-all" items={items} activeKey={requested} onActiveKeyChange={select}/></div>;}
|
|
55
102
|
|
|
56
|
-
|
|
103
|
+
function openTemplateEditor(row?:PushTemplateRow,onSaved?:()=>unknown){const pending={current:false};return modal.open(close=><TemplateEditor row={row} pending={pending} close={saved=>{close(saved);if(saved)void onSaved?.();}}/>,{size:'lg',canDismiss:()=>!pending.current});}
|
|
104
|
+
function openConfigEditor(row?:PushConfigRow,onSaved?:()=>unknown){const pending={current:false};return modal.open(close=><ConfigEditor row={row} pending={pending} close={saved=>{close(saved);if(saved)void onSaved?.();}}/>,{size:'lg',canDismiss:()=>!pending.current});}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useCan } from '../../../client/runtime';
|
|
4
|
+
import { Badge, FlatRow, fmt, modal } from '../../../ui';
|
|
5
|
+
import { fetchPushDeviceReadiness, sendPushDeviceTest, type PushReadiness, type PushTestResult } from './api';
|
|
6
|
+
import { PUSH_DEVICE_TEST_PERMISSIONS, PUSH_DEVICE_VIEW_PERMISSIONS, PushDeliveryModel, PushDeviceModel, type PushDeviceRow } from './models';
|
|
7
|
+
|
|
8
|
+
// A completion belongs to the mounted record and permission snapshot that started it.
|
|
9
|
+
export function usePushRequestOwner(key:string) {
|
|
10
|
+
const owner=useRef({key,live:true});
|
|
11
|
+
if(owner.current.key!==key)owner.current={key,live:true};
|
|
12
|
+
useEffect(()=>{const current=owner.current;current.live=true;return()=>{current.live=false;};},[key]);
|
|
13
|
+
return ()=>{const current=owner.current;return ()=>current===owner.current&¤t.live;};
|
|
14
|
+
}
|
|
15
|
+
export function PushTestResultView({result,at}:{result:PushTestResult;at:number}) {
|
|
16
|
+
const labels={validated:'FCM connection verified',accepted:'Accepted by FCM',rejected:'Rejected by FCM',blocked:'Test blocked',unknown:'Acceptance unknown'};
|
|
17
|
+
return <div className={`push-test-result ${result.success?'':'push-test-attention'}`} role="status">
|
|
18
|
+
<Badge tone={result.success?'success':result.outcome==='unknown'?'warning':'danger'}>{labels[result.outcome]}</Badge>
|
|
19
|
+
<p>{result.message}</p><p className="dim">Checked {fmt.datetime(at)}</p>
|
|
20
|
+
{result.delivery_id&&<FlatRow label="Delivery record">#{result.delivery_id}</FlatRow>}
|
|
21
|
+
</div>;
|
|
22
|
+
}
|
|
23
|
+
export function openPushDeviceTest(row:PushDeviceRow) {
|
|
24
|
+
const pending={current:false};
|
|
25
|
+
return modal.open(close=><PushTestDialog row={row} pending={pending} close={()=>close(null)}/>,{size:'md',canDismiss:()=>!pending.current});
|
|
26
|
+
}
|
|
27
|
+
function PushTestDialog({row,pending,close}:{row:PushDeviceRow;pending:{current:boolean};close:()=>void}) {
|
|
28
|
+
const sender=useCan(PUSH_DEVICE_TEST_PERMISSIONS);const viewer=useCan(PUSH_DEVICE_VIEW_PERMISSIONS);
|
|
29
|
+
const can=sender.can&&viewer.can;const qc=useQueryClient();
|
|
30
|
+
const ownerKey=`${row.id}:${can}:${sender.me?.id}`;const own=usePushRequestOwner(ownerKey);
|
|
31
|
+
const attemptedAt=useRef<number|null>(null);
|
|
32
|
+
const [checked,setReadiness]=useState<{value:PushReadiness;ownerKey:string}|null>(null);const readiness=checked?.ownerKey===ownerKey?checked.value:null;const [loadError,setLoadError]=useState('');
|
|
33
|
+
const [title,setTitle]=useState('Push test');const [message,setMessage]=useState('This is a test notification.');
|
|
34
|
+
const [busy,setBusy]=useState(false);const [completed,setResult]=useState<{value:PushTestResult;at:number;ownerKey:string}|null>(null);
|
|
35
|
+
const result=completed?.ownerKey===ownerKey?completed:!busy&&attemptedAt.current!==null?{at:attemptedAt.current,value:{success:false,outcome:'unknown' as const,message:'A test was already attempted in this dialog. Check the device and delivery history before starting another test.',error_code:null,message_id:null,delivery_id:null,device_id:null,config:null}}:null;
|
|
36
|
+
useEffect(()=>{const current=own();setReadiness(null);setResult(null);setLoadError('');if(can)void fetchPushDeviceReadiness(row.id).then(value=>{if(current())setReadiness({value,ownerKey});}).catch(()=>{if(current())setLoadError('Readiness could not be checked. Confirm your permission and try opening this dialog again.');});return()=>{};},[ownerKey]);
|
|
37
|
+
const send=async()=>{
|
|
38
|
+
if(pending.current||attemptedAt.current!==null||!can||!readiness?.ready||result||!title.trim()||!message.trim())return;
|
|
39
|
+
attemptedAt.current=Date.now()/1000;pending.current=true;setBusy(true);const current=own();
|
|
40
|
+
try {const value=await sendPushDeviceTest(row.id,title,message);if(current())setResult({value,at:Date.now()/1000,ownerKey});}
|
|
41
|
+
finally {pending.current=false;setBusy(false);void qc.invalidateQueries({queryKey:PushDeliveryModel.keys.root});void qc.invalidateQueries({queryKey:PushDeviceModel.keys.root});void qc.invalidateQueries({queryKey:['/api/account/devices/push/stats']});}
|
|
42
|
+
};
|
|
43
|
+
const config=result?.value.config??readiness?.config;
|
|
44
|
+
return <div className="modal-pad push-test-dialog"><h2 className="modal-title">Send test push</h2>
|
|
45
|
+
<p className="modal-message">Send one visible notification to <strong>{row.device_name||row.device_id}</strong>.</p>
|
|
46
|
+
<div className="push-test-target"><FlatRow label="Device">{row.platform} · #{row.id}</FlatRow><FlatRow label="User">{row.user?.display_name??row.user?.name??(row.user?`#${row.user.id}`:'Unknown')}</FlatRow>
|
|
47
|
+
<FlatRow label="Configuration">{config?`${config.name} · #${config.id}`:'Not available'}</FlatRow>
|
|
48
|
+
<FlatRow label="Scope">{config?(config.group?.name??(config.group?`Group #${config.group.id}`:'System default')):'—'}</FlatRow>
|
|
49
|
+
<FlatRow label="FCM project">{config?.fcm_project_id??'—'}</FlatRow>
|
|
50
|
+
</div>
|
|
51
|
+
{!can?<p className="form-alert" role="alert">Your permission to test this device is no longer available.</p>:result?<PushTestResultView result={result.value} at={result.at}/>:<>
|
|
52
|
+
<p className={!readiness?.ready?'form-alert':'dim'} role="status">{loadError||readiness?.message||'Checking device readiness…'}</p>
|
|
53
|
+
<fieldset disabled={busy} className="push-fields"><label className="field"><span className="field-label">Title</span><input className="input" maxLength={200} value={title} onChange={e=>setTitle(e.target.value)}/></label>
|
|
54
|
+
<label className="field"><span className="field-label">Message</span><textarea className="input" rows={3} maxLength={1000} value={message} onChange={e=>setMessage(e.target.value)}/></label></fieldset>
|
|
55
|
+
<p className="dim">FCM acceptance does not confirm the notification appeared. Check the selected device after sending.</p>
|
|
56
|
+
</>}
|
|
57
|
+
<div className="modal-actions"><button className="btn" disabled={busy} onClick={close}>{result?'Close':'Cancel'}</button>{can&&!result&&<button className="btn btn-primary" disabled={busy||!readiness?.ready||!title.trim()||!message.trim()} onClick={()=>void send()}>{busy?'Sending…':'Send notification'}</button>}</div>
|
|
58
|
+
</div>;
|
|
59
|
+
}
|
|
@@ -13,5 +13,29 @@ export function validateFcmServiceAccount(value:unknown):Record<string,unknown>{
|
|
|
13
13
|
export function parseFcmServiceAccount(source:string):Record<string,unknown>{let value:unknown;try{value=JSON.parse(source);}catch{throw new Error('Service account must be valid JSON.');}return validateFcmServiceAccount(value);}
|
|
14
14
|
export function buildPushConfigPayload(scalars:Record<string,unknown>,credential:FcmCredentialEdit):Record<string,unknown>{const body={...scalars};if(credential.mode==='clear')body.fcm_service_account=null;if(credential.mode==='replace')body.fcm_service_account=validateFcmServiceAccount(credential.value);return body;}
|
|
15
15
|
export async function savePushConfigImperative(qc:QueryClient,id:number|null,scalars:Record<string,unknown>,credential:FcmCredentialEdit):Promise<PushConfigRow>{const payload=buildPushConfigPayload(scalars,credential);const path=id==null?PushConfigModel.endpoint:`${PushConfigModel.endpoint}/${id}`;try{const response=await mojoCall(path,{method:'POST',body:payload});const row=sanitizePushConfigRow(response.data as PushConfigRow);qc.setQueryData(PushConfigModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PushConfigModel.keys.root});return row;}finally{delete payload.fcm_service_account;}}
|
|
16
|
-
export async function testPushConfigConnection(id:number):Promise<{success:boolean;message:string;test_mode:boolean;note:string|null;fcm_version:string|null}>{const response=await mojoCall(`${PushConfigModel.endpoint}/${id}/test`,{method:'POST',body:{}});const row=(response.data??response) as Record<string,unknown>;return {success:row.success!==false,message:typeof row.message==='string'?row.message.slice(0,500):'FCM connection test completed.',test_mode:Boolean(row.test_mode),note:typeof row.note==='string'?row.note.slice(0,500):null,fcm_version:typeof row.fcm_version==='string'?row.fcm_version.slice(0,30):null};}
|
|
17
16
|
export async function fetchPushGroupChoices():Promise<PushRelation[]>{const response=await mojoList<Record<string,unknown>>('/api/group',{graph:'basic',sort:'name',start:0,size:PUSH_GROUP_CHOICE_LIMIT});return response.rows.flatMap(row=>{const id=Number(row.id);return Number.isFinite(id)&&typeof row.name==='string'?[{id,name:row.name.slice(0,120)}]:[];});}
|
|
17
|
+
|
|
18
|
+
export type PushTestOutcome = 'validated'|'accepted'|'rejected'|'blocked'|'unknown';
|
|
19
|
+
export interface PushTestResult {success:boolean;outcome:PushTestOutcome;message:string;error_code:string|null;message_id:string|null;delivery_id:number|null;device_id:number|null;config:PushTestConfig|null}
|
|
20
|
+
export interface PushTestConfig {id:number;name:string;group:PushRelation|null;fcm_project_id:string|null;test_mode:boolean;is_active:boolean;has_fcm_credentials:boolean}
|
|
21
|
+
export interface PushReadiness {ready:boolean;message:string;device_id:number;config:PushTestConfig|null}
|
|
22
|
+
const object=(value:unknown):Record<string,unknown>=>value&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
|
23
|
+
const text=(value:unknown,max=200):string|null=>typeof value==='string'?value.slice(0,max):null;
|
|
24
|
+
const id=(value:unknown):number|null=>typeof value==='number'&&Number.isSafeInteger(value)&&value>0?value:null;
|
|
25
|
+
const TEST_MESSAGES:Record<string,string>={
|
|
26
|
+
inactive_device:'This device is inactive.',push_disabled:'Push notifications are disabled on this device.',category_disabled:'This device has opted out of test notifications.',missing_token:'This device has no registered push token. Re-register it from the app.',
|
|
27
|
+
no_config:'No active push configuration applies to this user.',inactive_config:'The selected configuration is inactive.',test_mode:'Simulation mode is enabled. Turn it off in the selected configuration before sending a real test.',missing_credentials:'The selected configuration has no FCM credentials.',
|
|
28
|
+
invalid_credentials:'The stored FCM credentials could not be loaded.',authentication_failed:'FCM authentication failed. Review the stored service account.',provider_unavailable:'FCM could not be reached.',
|
|
29
|
+
INVALID_ARGUMENT:'FCM rejected the message or device registration.',UNREGISTERED:'FCM no longer recognizes this device. Re-register it from the app.',SENDER_ID_MISMATCH:'The device belongs to a different Firebase project.',QUOTA_EXCEEDED:'The FCM quota was exceeded.',UNAVAILABLE:'FCM is temporarily unavailable.',INTERNAL:'FCM reported an internal error.',THIRD_PARTY_AUTH_ERROR:'FCM could not authenticate with the device platform.',PERMISSION_DENIED:'FCM denied this project or service account permission.',UNAUTHENTICATED:'FCM rejected the authentication.',NOT_FOUND:'FCM could not find the target.',provider_rejected:'FCM rejected the request.',
|
|
30
|
+
};
|
|
31
|
+
function testConfig(value:unknown):PushTestConfig|null {const row=object(value);const configId=id(row.id);if(!configId)return null;const group=object(row.group);const groupId=id(group.id);if(row.group!==null&&!groupId)return null;return {id:configId,name:text(row.name,100)??`Configuration #${configId}`,group:groupId?{id:groupId,name:text(group.name,120)??`Group #${groupId}`}:null,fcm_project_id:text(row.fcm_project_id),test_mode:row.test_mode===true,is_active:row.is_active===true,has_fcm_credentials:row.has_fcm_credentials===true};}
|
|
32
|
+
function testResult(value:unknown,validation=false):PushTestResult {
|
|
33
|
+
const envelope=object(value);const row=object(envelope.data??value);const messageId=text(row.message_id,500);
|
|
34
|
+
const success=envelope.status!==false&&row.success===true&&row.outcome===(validation?'validated':'accepted')&&Boolean(messageId?.match(/^projects\/[^/]+\/messages\/.+/))&&(!validation||row.validation_only===true);
|
|
35
|
+
const outcome:PushTestOutcome=success?(validation?'validated':'accepted'):row.success===false&&['rejected','blocked','unknown'].includes(String(row.outcome))?row.outcome as PushTestOutcome:validation?'blocked':'unknown';
|
|
36
|
+
const code=text(row.error_code,60);const message=success?(validation?'FCM validated this project and service account. No notification was delivered.':'FCM accepted this notification. Check the device to confirm it appeared.'):outcome==='unknown'?'Acceptance is unknown. Check the device and delivery history before sending another test.':(Object.hasOwn(TEST_MESSAGES,code??'')?TEST_MESSAGES[code??'']:null)??'The check did not return a valid provider result. Review the configuration and try the check again.';
|
|
37
|
+
return {success,outcome,message,error_code:code&&Object.hasOwn(TEST_MESSAGES,code)?code:null,message_id:success?messageId:null,delivery_id:id(row.delivery_id),device_id:id(row.device_id),config:testConfig(row.config)};
|
|
38
|
+
}
|
|
39
|
+
export async function testPushConfigConnection(configId:number):Promise<PushTestResult>{try{return testResult(await mojoCall(`${PushConfigModel.endpoint}/${configId}/test`,{method:'POST',body:{}}),true);}catch(error){const failure=object(error);return testResult({status:false,data:failure.data},true);}}
|
|
40
|
+
export async function fetchPushDeviceReadiness(deviceId:number):Promise<PushReadiness>{const response=await mojoCall('/api/account/devices/push/test',{params:{device_id:deviceId}});const row=object(response.data);const config=testConfig(row.config);const ready=response.status!==false&&row.ready===true&&row.device_id===deviceId&&Boolean(config?.is_active&&config.has_fcm_credentials&&!config.test_mode);return {ready,device_id:deviceId,config,message:ready?'Ready for a real test. Provider credentials and device delivery have not yet been verified.':(Object.hasOwn(TEST_MESSAGES,String(row.error_code))?TEST_MESSAGES[String(row.error_code)]:null)??'Readiness could not be confirmed. Close this dialog and check the device and configuration.'};}
|
|
41
|
+
export async function sendPushDeviceTest(deviceId:number,title:string,message:string):Promise<PushTestResult>{try{const result=testResult(await mojoCall('/api/account/devices/push/test',{method:'POST',body:{device_id:deviceId,title,message}}));if(result.device_id!==deviceId)return testResult({});return result;}catch(error){const failure=object(error);if(failure.data)return testResult({status:false,data:failure.data});if([400,401,403,404].includes(Number(failure.status)))return {...testResult({success:false,outcome:'blocked'}),message:'The server refused this test. Check your permission and the device configuration.'};return testResult({});}}
|
|
@@ -3,6 +3,7 @@ import { defineModel, registerNonFilterParams, type Params } from '../../../clie
|
|
|
3
3
|
registerNonFilterParams('push_surface');
|
|
4
4
|
|
|
5
5
|
export const PUSH_DEVICE_VIEW_PERMISSIONS = ['sys.view_devices', 'sys.manage_devices', 'sys.comms', 'sys.owner', 'sys.manage_users'];
|
|
6
|
+
export const PUSH_DEVICE_TEST_PERMISSIONS = ['sys.send_notifications', 'sys.comms'];
|
|
6
7
|
export const PUSH_DEVICE_MANAGE_PERMISSIONS = ['sys.manage_devices', 'sys.comms', 'sys.owner'];
|
|
7
8
|
export const PUSH_DELIVERY_VIEW_PERMISSIONS = ['sys.view_notifications', 'sys.manage_notifications', 'sys.comms', 'sys.owner', 'sys.manage_users'];
|
|
8
9
|
export const PUSH_DELIVERY_MANAGE_PERMISSIONS = ['sys.manage_notifications', 'sys.comms'];
|
|
@@ -20,19 +21,19 @@ export const PUSH_ADMIN_PERMISSIONS = [...new Set([
|
|
|
20
21
|
])];
|
|
21
22
|
|
|
22
23
|
export interface PushRelation { id:number; name?:string; display_name?:string }
|
|
23
|
-
export interface PushDeviceRow { id:number; device_id:string; platform:'ios'|'android'|'web'|string; device_name:string; app_version:string; os_version:string; push_enabled:boolean; push_preferences:Record<string,boolean>; last_seen:number; user:PushRelation|null }
|
|
24
|
-
export interface PushDeliveryRow { id:number; created:number; title:string|null; body:string|null; category:string; action_url:string|null; status:'pending'|'sent'|'delivered'|'failed'|string; sent_at:number|null; delivered_at:number|null; error_message:string|null; user:PushRelation|null; device:PushRelation|null }
|
|
24
|
+
export interface PushDeviceRow { id:number; device_id:string; platform:'ios'|'android'|'web'|string; device_name:string; app_version:string; os_version:string; push_enabled:boolean; is_active:boolean|null; push_preferences:Record<string,boolean>; last_seen:number; user:PushRelation|null }
|
|
25
|
+
export interface PushDeliveryRow { id:number; created:number; title:string|null; body:string|null; category:string; action_url:string|null; push_outcome:string|null; status:'pending'|'sent'|'delivered'|'failed'|string; sent_at:number|null; delivered_at:number|null; error_message:string|null; user:PushRelation|null; device:PushRelation|null }
|
|
25
26
|
export interface PushTemplateRow { id:number; name:string; title_template:string|null; body_template:string|null; action_url:string|null; data_template:Record<string,unknown>; category:string; priority:'low'|'normal'|'high'; variables:Record<string,unknown>; is_active:boolean; group:PushRelation|null }
|
|
26
|
-
export interface PushConfigRow { id:number; created:number; modified:number; name:string; test_mode:boolean; default_sound:string; is_active:boolean; fcm_project_id:string|null; group:PushRelation|null }
|
|
27
|
+
export interface PushConfigRow { id:number; created:number; modified:number; name:string; test_mode:boolean; default_sound:string; is_active:boolean; fcm_project_id:string|null; fcm_client_email:string|null; has_fcm_credentials:boolean|null; group:PushRelation|null }
|
|
27
28
|
|
|
28
29
|
const asText=(value:unknown,max=2000):string|null=>typeof value==='string'?value.slice(0,max):null;
|
|
29
30
|
const asEpoch=(value:unknown):number|null=>typeof value==='number'&&Number.isFinite(value)?value:null;
|
|
30
31
|
function relation(value:unknown):PushRelation|null { if(!value||typeof value!=='object')return null;const row=value as Record<string,unknown>;const id=Number(row.id);if(!Number.isFinite(id))return null;return {id,...(typeof row.name==='string'?{name:row.name.slice(0,120)}:{}),...(typeof row.display_name==='string'?{display_name:row.display_name.slice(0,120)}:{})}; }
|
|
31
32
|
function safeObject(value:unknown,limit=50):Record<string,unknown>{if(!value||typeof value!=='object'||Array.isArray(value))return {};const out:Record<string,unknown>={};for(const [key,item] of Object.entries(value).slice(0,limit)){if(typeof item==='string')out[key.slice(0,100)]=item.slice(0,2000);else if(typeof item==='number'||typeof item==='boolean'||item==null)out[key.slice(0,100)]=item;}return out;}
|
|
32
|
-
export function sanitizePushDeviceRow(input:PushDeviceRow):PushDeviceRow {const row=input as unknown as Record<string,unknown>;const preferences=safeObject(row.push_preferences);return {id:Number(row.id),device_id:String(row.device_id??'').slice(0,255),platform:String(row.platform??''),device_name:String(row.device_name??'').slice(0,100),app_version:String(row.app_version??'').slice(0,50),os_version:String(row.os_version??'').slice(0,50),push_enabled:Boolean(row.push_enabled),push_preferences:Object.fromEntries(Object.entries(preferences).filter((entry):entry is [string,boolean]=>typeof entry[1]==='boolean')),last_seen:asEpoch(row.last_seen)??0,user:relation(row.user)};}
|
|
33
|
-
export function sanitizePushDeliveryRow(input:PushDeliveryRow):PushDeliveryRow {const row=input as unknown as Record<string,unknown>;return {id:Number(row.id),created:asEpoch(row.created)??0,title:asText(row.title,200),body:asText(row.body,10_000),category:String(row.category??'').slice(0,50),action_url:asText(row.action_url,2000),status:String(row.status??''),sent_at:asEpoch(row.sent_at),delivered_at:asEpoch(row.delivered_at),error_message:asText(row.error_message,2000),user:relation(row.user),device:relation(row.device)};}
|
|
33
|
+
export function sanitizePushDeviceRow(input:PushDeviceRow):PushDeviceRow {const row=input as unknown as Record<string,unknown>;const preferences=safeObject(row.push_preferences);return {id:Number(row.id),device_id:String(row.device_id??'').slice(0,255),platform:String(row.platform??''),device_name:String(row.device_name??'').slice(0,100),app_version:String(row.app_version??'').slice(0,50),os_version:String(row.os_version??'').slice(0,50),push_enabled:Boolean(row.push_enabled),is_active:typeof row.is_active==='boolean'?row.is_active:null,push_preferences:Object.fromEntries(Object.entries(preferences).filter((entry):entry is [string,boolean]=>typeof entry[1]==='boolean')),last_seen:asEpoch(row.last_seen)??0,user:relation(row.user)};}
|
|
34
|
+
export function sanitizePushDeliveryRow(input:PushDeliveryRow):PushDeliveryRow {const row=input as unknown as Record<string,unknown>;return {id:Number(row.id),created:asEpoch(row.created)??0,title:asText(row.title,200),body:asText(row.body,10_000),category:String(row.category??'').slice(0,50),action_url:asText(row.action_url,2000),status:String(row.status??''),push_outcome:['accepted','delivered','simulated','rejected','blocked','unknown','pending','sent','failed'].includes(String(row.push_outcome))?String(row.push_outcome):null,sent_at:asEpoch(row.sent_at),delivered_at:asEpoch(row.delivered_at),error_message:asText(row.error_message,2000),user:relation(row.user),device:relation(row.device)};}
|
|
34
35
|
export function sanitizePushTemplateRow(input:PushTemplateRow):PushTemplateRow {const row=input as unknown as Record<string,unknown>;const priority=['low','normal','high'].includes(String(row.priority))?String(row.priority) as PushTemplateRow['priority']:'normal';return {id:Number(row.id),name:String(row.name??'').slice(0,100),title_template:asText(row.title_template,200),body_template:asText(row.body_template,10_000),action_url:asText(row.action_url,2000),data_template:safeObject(row.data_template),category:String(row.category??'general').slice(0,50),priority,variables:safeObject(row.variables),is_active:Boolean(row.is_active),group:relation(row.group)};}
|
|
35
|
-
export function sanitizePushConfigRow(input:PushConfigRow):PushConfigRow {const row=input as unknown as Record<string,unknown>;return {id:Number(row.id),created:asEpoch(row.created)??0,modified:asEpoch(row.modified)??0,name:String(row.name??'').slice(0,100),test_mode:Boolean(row.test_mode),default_sound:String(row.default_sound??'default').slice(0,50),is_active:Boolean(row.is_active),fcm_project_id:asText(row.fcm_project_id,200),group:relation(row.group)};}
|
|
36
|
+
export function sanitizePushConfigRow(input:PushConfigRow):PushConfigRow {const row=input as unknown as Record<string,unknown>;return {id:Number(row.id),created:asEpoch(row.created)??0,modified:asEpoch(row.modified)??0,name:String(row.name??'').slice(0,100),test_mode:Boolean(row.test_mode),default_sound:String(row.default_sound??'default').slice(0,50),is_active:Boolean(row.is_active),fcm_project_id:asText(row.fcm_project_id,200),fcm_client_email:asText(row.fcm_client_email,254),has_fcm_credentials:typeof row.has_fcm_credentials==='boolean'?row.has_fcm_credentials:null,group:relation(row.group)};}
|
|
36
37
|
|
|
37
38
|
const BASE_KEYS=new Set(['start','size','search']);
|
|
38
39
|
function listParams(params:Params,graph:'basic'|'default',filters:readonly string[],sorts:readonly string[],defaultSort:string,dateRange=false):Params{const out:Params={graph,start:params.start??0,size:params.size??25,sort:defaultSort};for(const key of BASE_KEYS)if(params[key]!=null&¶ms[key]!=='')out[key]=params[key];for(const key of filters)if(params[key]!=null&¶ms[key]!=='')out[key]=params[key];const sort=typeof params.sort==='string'?params.sort:'';if(sorts.includes(sort.replace(/^-/,'')))out.sort=sort;if(dateRange&&(params.dr_start||params.dr_end)){out.dr_field='created';if(params.dr_start)out.dr_start=params.dr_start;if(params.dr_end)out.dr_end=params.dr_end;}return out;}
|
|
@@ -82,7 +82,7 @@ const providerCredentialFields: Record<PhoneProvider, PhoneSecretField[]> = {
|
|
|
82
82
|
function providerOperationalNote(provider: PhoneProvider): string {
|
|
83
83
|
if (provider === 'mojo') return 'Mojo Remote is used by the group-aware SMS delivery path.';
|
|
84
84
|
if (provider === 'aws') return 'Stored AWS credentials can be tested here. AWS outbound delivery is not wired yet.';
|
|
85
|
-
return '
|
|
85
|
+
return 'SMS delivery uses the saved Twilio credential pair, or global settings when neither credential is stored.';
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function providerVisibleRows(row: PhoneConfigRow): Array<[string, string]> {
|
|
@@ -276,6 +276,7 @@ function ConfigDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
276
276
|
|
|
277
277
|
|
|
278
278
|
const [testing, setTesting] = useState(false);
|
|
279
|
+
const [testResult, setTestResult] = useState<Awaited<ReturnType<typeof testPhoneConfigImperative>> | null>(null);
|
|
279
280
|
if (!query.data) return <div className="modal-pad">{query.isLoading ? 'Loading provider configuration…' : 'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
|
|
280
281
|
const row = query.data;
|
|
281
282
|
const toggle = async (next: boolean) => {
|
|
@@ -286,30 +287,40 @@ function ConfigDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
|
286
287
|
finally { setSaving(false); }
|
|
287
288
|
};
|
|
288
289
|
const meta = providerMeta[row.provider];
|
|
289
|
-
const edit = () => void modal.open((close) => <ConfigEditor row={row} close={async (saved) => { close(saved); if (saved) await query.refetch(); }} />, { size: 'lg' });
|
|
290
|
+
const edit = () => void modal.open((close) => <ConfigEditor row={row} close={async (saved) => { close(saved); if (saved) { setTestResult(null); await query.refetch(); } }} />, { size: 'lg' });
|
|
290
291
|
const test = async () => {
|
|
291
292
|
setTesting(true);
|
|
293
|
+
setTestResult(null);
|
|
292
294
|
try {
|
|
293
295
|
const result = await testPhoneConfigImperative(id);
|
|
294
|
-
|
|
296
|
+
setTestResult(result);
|
|
297
|
+
toast[result.testMode ? 'warning' : result.status ? 'success' : 'error'](result.message);
|
|
295
298
|
} catch (reason) {
|
|
296
|
-
|
|
299
|
+
const message = reason instanceof Error ? reason.message : 'Connection test failed';
|
|
300
|
+
setTestResult({ status: false, message, testMode: false, errorCode: null });
|
|
301
|
+
toast.error(message);
|
|
297
302
|
} finally {
|
|
298
303
|
setTesting(false);
|
|
299
304
|
}
|
|
300
305
|
};
|
|
301
306
|
|
|
307
|
+
const resultNotice = testResult && <div className={testResult.status && !testResult.testMode ? 'known-card' : 'form-alert'} role={testResult.status ? 'status' : 'alert'}>
|
|
308
|
+
<h3>Connection test: {testResult.testMode ? 'Not tested' : testResult.status ? 'Passed' : 'Failed'}</h3>
|
|
309
|
+
<p>{testResult.message}</p>
|
|
310
|
+
{testResult.errorCode === 'missing_credentials' && <p>Use Edit configuration to save the provider credentials, then test again. Blank credential fields keep any saved value unchanged.</p>}
|
|
311
|
+
</div>;
|
|
312
|
+
|
|
302
313
|
return <DetailView
|
|
303
314
|
title={row.name}
|
|
304
|
-
subtitle={relationLabel(row.group)}
|
|
315
|
+
subtitle={`Scope: ${relationLabel(row.group)}`}
|
|
305
316
|
icon={meta.icon}
|
|
306
317
|
chips={[{ text: meta.label, tone: 'info' }, { text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }, ...(row.test_mode ? [{ text: 'Test mode', tone: 'warning' as const }] : [])]}
|
|
307
318
|
onClose={onClose}
|
|
308
319
|
active={canManage ? { value: row.is_active, disabled: saving, onChange: next => void toggle(next) } : undefined}
|
|
309
|
-
contextMenu={canManage ? [{ label: 'Edit configuration', onSelect: edit }, { label: 'Test stored connection', disabled: testing, onSelect: () => void test() }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: saving, onSelect: () => void toggle(!row.is_active) }] : []}
|
|
320
|
+
contextMenu={canManage ? [{ label: 'Edit configuration', disabled: testing, onSelect: edit }, { label: 'Test stored connection', disabled: testing, onSelect: () => void test() }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: saving, onSelect: () => void toggle(!row.is_active) }] : []}
|
|
310
321
|
sections={[
|
|
311
|
-
{ key: 'overview', label: 'Overview', icon: 'bi-sliders', render: () => <div className="detail-section"
|
|
312
|
-
{ key: 'connection', label: 'Connection', icon: 'bi-plug', render: () => <div className="detail-section"><div className="section-eyebrow">{meta.label} settings</div>{providerVisibleRows(row).map(([label, value]) => <FlatRow key={label} label={label}>{value}</FlatRow>)}<FlatRow label="Credentials"><span><i className="bi bi-shield-lock" /> Write-only; edit to replace or clear</span></FlatRow><div className={`phonehub-provider-note${row.provider === 'mojo' ? '' : ' is-warning'}`}><i className={`bi ${row.provider === 'mojo' ? 'bi-info-circle' : 'bi-exclamation-triangle'}`} /><span>{providerOperationalNote(row.provider)}</span></div>{row.test_mode && <div className="phonehub-provider-note is-warning"><i className="bi bi-beaker" /><span>Test mode only short-circuits the connection test. It does not prevent SMS delivery.</span></div>}<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> }
|
|
322
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-sliders', render: () => <div className="detail-section">{resultNotice}<div className="phonehub-provider-summary"><i className={`bi ${meta.icon}`} /><div><span className="eyebrow">Provider</span><h3>{meta.label}</h3><p>{meta.description}</p></div></div><div className="phonehub-summary-grid"><div className="known-card"><span>Scope</span><strong>{relationLabel(row.group)}</strong></div><div className="known-card"><span>Automatic lookup</span><strong>{row.lookup_enabled ? `Enabled · ${row.lookup_cache_days} days` : 'Disabled'}</strong></div><div className="known-card"><span>Last modified</span><strong>{fmt.datetime(row.modified)}</strong></div></div><p className="dim">An active group configuration wins; otherwise Phone Hub falls back to the first active system default.</p></div> },
|
|
323
|
+
{ key: 'connection', label: 'Connection', icon: 'bi-plug', render: () => <div className="detail-section">{resultNotice}<FlatRow label="Scope">{relationLabel(row.group)}</FlatRow><div className="section-eyebrow">{meta.label} settings</div>{providerVisibleRows(row).map(([label, value]) => <FlatRow key={label} label={label}>{value}</FlatRow>)}<FlatRow label="Credentials"><span><i className="bi bi-shield-lock" /> Write-only; edit to replace or clear</span></FlatRow><div className={`phonehub-provider-note${row.provider === 'mojo' ? '' : ' is-warning'}`}><i className={`bi ${row.provider === 'mojo' ? 'bi-info-circle' : 'bi-exclamation-triangle'}`} /><span>{providerOperationalNote(row.provider)}</span></div>{row.test_mode && <div className="phonehub-provider-note is-warning"><i className="bi bi-beaker" /><span>Test mode only short-circuits the connection test. It does not prevent SMS delivery.</span></div>}<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> }
|
|
313
324
|
]}
|
|
314
325
|
/>;
|
|
315
326
|
}
|
|
@@ -42,13 +42,13 @@ export async function savePhoneConfigImperative(qc:QueryClient,id:number|null,sc
|
|
|
42
42
|
const row=sanitizePhoneConfigRow(response.data as PhoneConfigRow);
|
|
43
43
|
qc.setQueryData(PhoneConfigModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PhoneConfigModel.keys.root});return row;
|
|
44
44
|
}
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
export async function testPhoneConfigImperative(id:number):Promise<{status:boolean;message:string}>{
|
|
45
|
+
// PhoneConfig returns `success` as its provider verdict; envelope `status`
|
|
46
|
+
// only says the REST action ran. A failed test resolves for inline display.
|
|
47
|
+
export async function testPhoneConfigImperative(id:number):Promise<{status:boolean;message:string;testMode:boolean;errorCode:string|null}>{
|
|
49
48
|
const response=await mojoCall(`${PhoneConfigModel.endpoint}/${id}`,{method:'POST',body:{test_connection:1}});
|
|
50
49
|
const data=(response.data??response) as Record<string,unknown>;
|
|
51
|
-
|
|
50
|
+
const status=data.success===true;
|
|
51
|
+
return {status,testMode:data.test_mode===true,errorCode:typeof data.error==='string'?data.error:null,message:typeof data.message==='string'?data.message:status?'Connection verified.':'The server did not confirm the provider connection.'};
|
|
52
52
|
}
|
|
53
53
|
export async function fetchPhoneGroupChoices():Promise<PhoneRelation[]>{
|
|
54
54
|
const result=await mojoList<Record<string,unknown>>('/api/group',{graph:'basic',sort:'name',start:0,size:PHONE_GROUP_CHOICE_LIMIT});
|
package/src/admin/registry.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AdminSection } from './core';
|
|
2
2
|
import './dns/setup';
|
|
3
3
|
import { DASHBOARD_ADMIN_SECTION, MONITORING_ADMIN_SECTION, CLOUDWATCH_ADMIN_SECTION } from './domains/observability';
|
|
4
|
-
import { USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION } from './domains/identity';
|
|
5
|
-
import { SETTINGS_ADMIN_SECTION, JOBS_ADMIN_SECTION } from './domains/operations';
|
|
4
|
+
import { USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION, SIGNIN_ADMIN_SECTION } from './domains/identity';
|
|
5
|
+
import { SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION } from './domains/operations';
|
|
6
6
|
import {
|
|
7
7
|
SECURITY_OPERATIONS_ADMIN_SECTION, BOUNCER_ADMIN_SECTION,
|
|
8
8
|
DEVICE_INTEL_ADMIN_SECTION, GEOIP_ADMIN_SECTION, NETWORK_SECURITY_ADMIN_SECTION,
|
|
@@ -20,9 +20,11 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
|
|
20
20
|
USERS_ADMIN_SECTION,
|
|
21
21
|
MEMBERS_ADMIN_SECTION,
|
|
22
22
|
CREDENTIALS_ADMIN_SECTION,
|
|
23
|
+
SIGNIN_ADMIN_SECTION,
|
|
23
24
|
MONITORING_ADMIN_SECTION,
|
|
24
25
|
CLOUDWATCH_ADMIN_SECTION,
|
|
25
26
|
SETTINGS_ADMIN_SECTION,
|
|
27
|
+
FLEET_CONFIGURATION_ADMIN_SECTION,
|
|
26
28
|
SECURITY_OPERATIONS_ADMIN_SECTION,
|
|
27
29
|
BOUNCER_ADMIN_SECTION,
|
|
28
30
|
DEVICE_INTEL_ADMIN_SECTION,
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
SETTINGS_PERMISSIONS, SettingModel, settingGroupLabel, type SettingRow,
|
|
5
5
|
} from './model';
|
|
6
6
|
import { showSettingEditor } from './SettingEditor';
|
|
7
|
+
import { SettingValue } from './SettingValue';
|
|
7
8
|
|
|
8
9
|
export function SettingDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
9
10
|
const { data: row, isPending, error } = SettingModel.useOne(id);
|
|
@@ -26,7 +27,7 @@ export function SettingDetail({ id, onClose }: { id: number; onClose: () => void
|
|
|
26
27
|
<Eyebrow>Configuration</Eyebrow>
|
|
27
28
|
<FlatRow label="Key"><code>{row.key}</code></FlatRow>
|
|
28
29
|
<FlatRow label="Value">
|
|
29
|
-
<
|
|
30
|
+
<SettingValue row={row} />
|
|
30
31
|
</FlatRow>
|
|
31
32
|
<FlatRow label="Scope">{settingGroupLabel(row.group)}</FlatRow>
|
|
32
33
|
<FlatRow label="Storage">
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { JsonBlock } from '../../ui';
|
|
2
|
+
import type { SettingRow } from './model';
|
|
3
|
+
|
|
4
|
+
function structuredValue(value: string): Record<string, unknown> | unknown[] | null {
|
|
5
|
+
if (!/^[\s]*[\[{]/.test(value)) return null;
|
|
6
|
+
try {
|
|
7
|
+
const parsed: unknown = JSON.parse(value);
|
|
8
|
+
return parsed !== null && typeof parsed === 'object' ? parsed as Record<string, unknown> | unknown[] : null;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SettingValuePreview({ row }: { row: SettingRow }) {
|
|
15
|
+
if (row.is_secret) return <code>{row.display_value || '******'}</code>;
|
|
16
|
+
const value = row.display_value || '';
|
|
17
|
+
const json = structuredValue(value);
|
|
18
|
+
const compact = value.replace(/\s+/g, ' ');
|
|
19
|
+
const shortened = compact.length > 80 || /[\r\n]/.test(value);
|
|
20
|
+
if (!json && !shortened) return <code>{value || '—'}</code>;
|
|
21
|
+
const summary = json
|
|
22
|
+
? `JSON ${Array.isArray(json) ? `array · ${json.length} items` : `object · ${Object.keys(json).length} keys`}`
|
|
23
|
+
: `${compact.slice(0, 80)}${compact.length > 80 ? '…' : ''}`;
|
|
24
|
+
return (
|
|
25
|
+
<div style={{ maxWidth: '32ch' }}>
|
|
26
|
+
<code style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{summary}</code>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SettingValue({ row }: { row: SettingRow }) {
|
|
32
|
+
if (row.is_secret) return <code>{row.display_value || '******'}</code>;
|
|
33
|
+
const value = row.value ?? row.display_value ?? '';
|
|
34
|
+
const json = structuredValue(value);
|
|
35
|
+
if (json) return <JsonBlock value={json} label="JSON value" defaultOpen />;
|
|
36
|
+
return <pre style={{ margin: 0, maxHeight: '25rem', overflow: 'auto', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', font: 'inherit' }}><code>{value || '—'}</code></pre>;
|
|
37
|
+
}
|