portal-mojo 0.2.4 → 0.2.6
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-identity-directories-sources.json +130 -0
- package/docs/admin-identity-directories.md +60 -0
- package/docs/admin-identity-users.md +6 -1
- package/docs/admin-members.md +16 -0
- package/docs/admin-network-security.md +1 -1
- package/docs/admin-push.md +85 -6
- package/docs/admin-settings.md +10 -0
- package/docs/admin-signin.md +73 -0
- package/docs/menus-and-access.md +6 -2
- package/docs/realtime.md +5 -0
- package/package.json +1 -1
- package/src/admin/core/index.ts +4 -4
- package/src/admin/domains/identity.ts +25 -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/groups/GroupDetail.tsx +287 -0
- package/src/admin/identity/groups/GroupsPage.tsx +125 -0
- package/src/admin/identity/groups/group-permissions.ts +5 -0
- package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
- package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
- package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
- package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
- package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
- package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
- package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
- package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
- package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
- package/src/admin/identity/groups/group-sections/models.ts +110 -0
- package/src/admin/identity/groups/group-sections/shared.ts +21 -0
- package/src/admin/identity/groups/index.ts +6 -0
- package/src/admin/identity/groups/models.ts +70 -0
- package/src/admin/identity/index.ts +1 -0
- package/src/admin/identity/members/MemberDetail.tsx +32 -3
- package/src/admin/identity/members/models.ts +3 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
- package/src/admin/identity/users/UserDetail.tsx +2 -2
- package/src/admin/identity/users/index.ts +2 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
- 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 +6 -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
- package/src/ui/SidebarNav.tsx +2 -2
|
@@ -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, GROUPS_ADMIN_SECTION, PERSONAL_API_KEYS_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,
|
|
@@ -17,12 +17,16 @@ import { ASSISTANT_ADMIN_SECTION } from './domains/assistant';
|
|
|
17
17
|
/** Stable historical order; menus, denied fallback and first-visible routing depend on it. */
|
|
18
18
|
export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
|
19
19
|
DASHBOARD_ADMIN_SECTION,
|
|
20
|
+
GROUPS_ADMIN_SECTION,
|
|
21
|
+
PERSONAL_API_KEYS_ADMIN_SECTION,
|
|
20
22
|
USERS_ADMIN_SECTION,
|
|
21
23
|
MEMBERS_ADMIN_SECTION,
|
|
22
24
|
CREDENTIALS_ADMIN_SECTION,
|
|
25
|
+
SIGNIN_ADMIN_SECTION,
|
|
23
26
|
MONITORING_ADMIN_SECTION,
|
|
24
27
|
CLOUDWATCH_ADMIN_SECTION,
|
|
25
28
|
SETTINGS_ADMIN_SECTION,
|
|
29
|
+
FLEET_CONFIGURATION_ADMIN_SECTION,
|
|
26
30
|
SECURITY_OPERATIONS_ADMIN_SECTION,
|
|
27
31
|
BOUNCER_ADMIN_SECTION,
|
|
28
32
|
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
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SettingModel, settingGroupLabel, type SettingRow,
|
|
7
7
|
} from './model';
|
|
8
8
|
import { SettingDetail } from './SettingDetail';
|
|
9
|
+
import { SettingValuePreview } from './SettingValue';
|
|
9
10
|
import { showSettingEditor } from './SettingEditor';
|
|
10
11
|
|
|
11
12
|
const COLUMNS: Column<SettingRow>[] = [
|
|
@@ -19,7 +20,7 @@ const COLUMNS: Column<SettingRow>[] = [
|
|
|
19
20
|
},
|
|
20
21
|
{
|
|
21
22
|
key: 'display_value', label: 'Value', render: (row) => (
|
|
22
|
-
<
|
|
23
|
+
<SettingValuePreview row={row} />
|
|
23
24
|
),
|
|
24
25
|
},
|
|
25
26
|
{ key: 'group', label: 'Scope', render: (row) => settingGroupLabel(row.group) },
|