portal-mojo 0.2.4 → 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/docs/README.md +2 -0
- package/docs/admin-fleet-configuration.md +50 -0
- package/docs/admin-push.md +85 -6
- package/docs/admin-settings.md +10 -0
- package/docs/admin-signin.md +73 -0
- package/docs/realtime.md +5 -0
- package/package.json +1 -1
- 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/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/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 +239 -10
- package/src/client/realtime.ts +12 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mojoCall, withFreshAuth } from '../../client/runtime';
|
|
2
|
+
|
|
3
|
+
export const FLEET_HEALTH_SCOPE = 'request_service_jobs_and_dependencies';
|
|
4
|
+
export const FLEET_ENDPOINT = '/api/account/admin/fleet';
|
|
5
|
+
export interface FleetEntry {
|
|
6
|
+
key: string; label: string; section: string; description: string;
|
|
7
|
+
value_type: 'boolean' | 'integer' | 'string' | 'list' | 'object';
|
|
8
|
+
sensitive: boolean; restart_required: boolean; overridden: boolean;
|
|
9
|
+
current?: unknown; default?: unknown; configured?: boolean;
|
|
10
|
+
min_value?: number | null; max_value?: number | null; max_length?: number; max_items?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface FleetNode {
|
|
13
|
+
hostname: string; revision: string; published: boolean; installed: boolean;
|
|
14
|
+
restart_requested: boolean; restarted: boolean; healthy: boolean;
|
|
15
|
+
status: string; error_code?: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface FleetReport {
|
|
18
|
+
status: string; nodes: FleetNode[]; healthy_everywhere: boolean;
|
|
19
|
+
error_code?: string; observed_at?: string; health_scope?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface FleetState {
|
|
22
|
+
schema_version: number; revision: string | null; version_id: string | null;
|
|
23
|
+
entries: FleetEntry[]; published: boolean; loaded_revision: string | null;
|
|
24
|
+
pending_restart: boolean; publish_configured: boolean; fleet: FleetReport;
|
|
25
|
+
}
|
|
26
|
+
export interface FleetHistory { versions: { version_id: string; current: boolean; published_at: string | null }[]; truncated: boolean }
|
|
27
|
+
export interface FleetOperation extends FleetReport { operation_id: string; revision: string; job_status?: string }
|
|
28
|
+
export type FleetChanges = Record<string, { action: 'clear' } | { action: 'set'; value: unknown }>;
|
|
29
|
+
export interface FleetPublication { published: boolean; revision: string; applied: boolean; pending_restart: boolean }
|
|
30
|
+
|
|
31
|
+
/** Project read responses before Query cache sees them. Never trust a secret value/default. */
|
|
32
|
+
export function sanitizeFleetState(state: FleetState): FleetState {
|
|
33
|
+
if (state?.schema_version !== 1 || !Array.isArray(state.entries) || !state.fleet || !Array.isArray(state.fleet.nodes)) {
|
|
34
|
+
throw new Error('Fleet configuration returned an unsupported response');
|
|
35
|
+
}
|
|
36
|
+
return { ...state, entries: state.entries.map((entry) => {
|
|
37
|
+
const { current, default: fallback, ...safe } = entry;
|
|
38
|
+
return entry.sensitive ? safe : { ...safe, current, default: fallback };
|
|
39
|
+
}) };
|
|
40
|
+
}
|
|
41
|
+
export function displayFleetValue(value: unknown): string {
|
|
42
|
+
if (value == null) return 'Not set';
|
|
43
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
44
|
+
}
|
|
45
|
+
export function fleetChange(entry: FleetEntry, mode: string, input: string): FleetChanges[string] | null {
|
|
46
|
+
if (mode === 'keep') return null;
|
|
47
|
+
if (mode === 'clear') return { action: 'clear' };
|
|
48
|
+
if (mode !== 'set') throw new Error('Choose a supported change');
|
|
49
|
+
if (entry.sensitive && input === '') return null;
|
|
50
|
+
let value: unknown = input;
|
|
51
|
+
if (entry.value_type === 'boolean') {
|
|
52
|
+
if (!['true', 'false'].includes(input)) throw new Error(`${entry.label}: choose true or false`);
|
|
53
|
+
value = input === 'true';
|
|
54
|
+
} else if (entry.value_type === 'integer') {
|
|
55
|
+
if (!/^-?\d+$/.test(input)) throw new Error(`${entry.label}: enter a whole number`);
|
|
56
|
+
value = Number(input);
|
|
57
|
+
if (!Number.isSafeInteger(value) || (entry.min_value != null && Number(value) < entry.min_value)
|
|
58
|
+
|| (entry.max_value != null && Number(value) > entry.max_value)) throw new Error(`${entry.label}: number is outside the allowed range`);
|
|
59
|
+
} else if (entry.value_type === 'list' || entry.value_type === 'object') {
|
|
60
|
+
try { value = JSON.parse(input); } catch { throw new Error(`${entry.label}: enter valid JSON`); }
|
|
61
|
+
if (entry.value_type === 'list' ? !Array.isArray(value) : value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
62
|
+
throw new Error(`${entry.label}: enter a JSON ${entry.value_type}`);
|
|
63
|
+
}
|
|
64
|
+
if (entry.max_items != null && Object.keys(value as object).length > entry.max_items) throw new Error(`${entry.label}: too many entries`);
|
|
65
|
+
} else if (entry.max_length != null && input.length > entry.max_length) throw new Error(`${entry.label}: value is too long`);
|
|
66
|
+
return { action: 'set', value };
|
|
67
|
+
}
|
|
68
|
+
export function fleetIsHealthy(report: FleetReport | undefined): boolean {
|
|
69
|
+
return report?.health_scope === FLEET_HEALTH_SCOPE && report.healthy_everywhere === true && report.nodes.length > 0
|
|
70
|
+
&& report.nodes.every((node) => node.installed && node.restarted && node.healthy && !node.error_code);
|
|
71
|
+
}
|
|
72
|
+
export function operationFinished(operation: FleetOperation): boolean {
|
|
73
|
+
// A completed job only dispatched sync; operation reads observe actual activation.
|
|
74
|
+
return ['healthy', 'failed', 'expired', 'canceled', 'superseded', 'timed_out'].includes(operation.status)
|
|
75
|
+
|| ['failed', 'expired', 'canceled'].includes(operation.job_status ?? '');
|
|
76
|
+
}
|
|
77
|
+
export async function readFleet(): Promise<FleetState> {
|
|
78
|
+
return sanitizeFleetState((await mojoCall(FLEET_ENDPOINT)).data as FleetState);
|
|
79
|
+
}
|
|
80
|
+
export async function readFleetHistory(): Promise<FleetHistory> {
|
|
81
|
+
return (await mojoCall(`${FLEET_ENDPOINT}/history`)).data as FleetHistory;
|
|
82
|
+
}
|
|
83
|
+
export async function readFleetOperation(id: string): Promise<FleetOperation> {
|
|
84
|
+
return (await mojoCall(`${FLEET_ENDPOINT}/operation/${encodeURIComponent(id)}`)).data as FleetOperation;
|
|
85
|
+
}
|
|
86
|
+
/** Imperative writes deliberately avoid TanStack MutationCache (submitted secrets persist there). */
|
|
87
|
+
export async function writeFleet<T>(body: Record<string, unknown>): Promise<T> {
|
|
88
|
+
return withFreshAuth(async () => (await mojoCall(FLEET_ENDPOINT, { method: 'POST', body })).data as T);
|
|
89
|
+
}
|
package/src/admin/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './security';
|
|
|
12
12
|
export * from './incidents';
|
|
13
13
|
export * from './rules';
|
|
14
14
|
export * from './identity';
|
|
15
|
+
export * from './signin';
|
|
15
16
|
export * from './jobs';
|
|
16
17
|
export * from './network';
|
|
17
18
|
export * from './dns';
|
|
@@ -22,3 +23,5 @@ export * from './phonehub';
|
|
|
22
23
|
export * from './dashboard';
|
|
23
24
|
export * from './assistant';
|
|
24
25
|
export { ADMIN_SECTIONS } from './registry';
|
|
26
|
+
export { FleetConfigurationPage, FleetNodeStatus } from './fleet-configuration/FleetConfigurationPage';
|
|
27
|
+
export { FLEET_CONFIGURATION_ADMIN_SECTION } from './domains/operations';
|
|
@@ -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;}
|
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
|
+
}
|