portal-mojo 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/docs/DetailView.md +19 -15
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +2 -1
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +24 -6
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +70 -1
- package/docs/forms.md +51 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +18 -3
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +67 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +11 -5
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +90 -6
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/detail/MetadataSection.tsx +46 -10
- package/src/ui/form-autosave.ts +134 -23
- package/src/ui/guardrail.tsx +125 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +8 -4
- package/src/ui/toast.tsx +48 -5
|
@@ -2,19 +2,30 @@
|
|
|
2
2
|
// Port of web-mojo `admin/security/IPSetView.js` (TabView: Configuration +
|
|
3
3
|
// CIDR Data, with a fleet-operations kebab), rebuilt on the house detail
|
|
4
4
|
// modal with every fleet-affecting action behind an armed confirmation.
|
|
5
|
-
import { useState } from 'react';
|
|
6
5
|
import { useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
|
+
import { useCan } from '../../client/runtime';
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
Badge,
|
|
10
|
+
DetailView,
|
|
11
|
+
Eyebrow,
|
|
12
|
+
FlatRow,
|
|
13
|
+
StatusPanel,
|
|
14
|
+
fmt,
|
|
15
|
+
modal,
|
|
16
|
+
toast,
|
|
10
17
|
} from '../../ui';
|
|
11
|
-
import {
|
|
18
|
+
import { promptEditIPSet } from './IPSetEditor';
|
|
12
19
|
import {
|
|
13
|
-
IPSET_CACHE_ONLY_HELP,
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
IPSET_CACHE_ONLY_HELP,
|
|
21
|
+
IPSET_KIND_BADGE_OPTIONS,
|
|
22
|
+
IPSET_MANAGE_PERMS,
|
|
23
|
+
IPSET_SOURCE_OPTIONS,
|
|
24
|
+
IPSetModel,
|
|
25
|
+
isCacheOnlyIPSet,
|
|
26
|
+
useIPSetCidrData,
|
|
27
|
+
type IPSetRow,
|
|
16
28
|
} from './models';
|
|
17
|
-
import { promptEditIPSet } from './IPSetEditor';
|
|
18
29
|
|
|
19
30
|
const DASH = <span className="dim-italic">—</span>;
|
|
20
31
|
|
|
@@ -100,12 +111,14 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
100
111
|
const qc = useQueryClient();
|
|
101
112
|
const { data: row, isPending, error } = IPSetModel.useOne(id);
|
|
102
113
|
const canManage = useCan(IPSET_MANAGE_PERMS).can;
|
|
103
|
-
const
|
|
114
|
+
const allowed = useRef(canManage); allowed.current = canManage;
|
|
115
|
+
useEffect(() => { allowed.current = canManage; return () => { allowed.current = false; }; }, [canManage]);
|
|
116
|
+
|
|
104
117
|
const sync = IPSetModel.useAction('sync');
|
|
105
118
|
const enable = IPSetModel.useAction('enable');
|
|
106
119
|
const disable = IPSetModel.useAction('disable');
|
|
107
120
|
const refresh = IPSetModel.useAction('refresh_source');
|
|
108
|
-
|
|
121
|
+
|
|
109
122
|
const save = IPSetModel.useSave();
|
|
110
123
|
|
|
111
124
|
if (isPending) return <div className="modal-pad dim">Loading IP set…</div>;
|
|
@@ -114,6 +127,7 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
114
127
|
const cacheOnly = isCacheOnlyIPSet(row);
|
|
115
128
|
|
|
116
129
|
const run = async (label: string, fn: () => Promise<unknown>) => {
|
|
130
|
+
if (!allowed.current) return;
|
|
117
131
|
try {
|
|
118
132
|
await fn();
|
|
119
133
|
toast.success(label);
|
|
@@ -126,7 +140,7 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
126
140
|
|
|
127
141
|
const onEdit = async () => {
|
|
128
142
|
const changes = await promptEditIPSet(row);
|
|
129
|
-
if (!changes) return;
|
|
143
|
+
if (!changes || !allowed.current) return;
|
|
130
144
|
try {
|
|
131
145
|
await save.mutateAsync({ id, changes });
|
|
132
146
|
// A `data` save re-runs set_data() server-side, so the detailed
|
|
@@ -149,6 +163,12 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
149
163
|
...(cacheOnly ? [{ icon: 'bi-database-lock', text: 'CACHE-ONLY', tone: 'warning' as const }] : []),
|
|
150
164
|
...(row.sync_error ? [{ icon: 'bi-exclamation-triangle', text: 'Sync error', tone: 'danger' as const }] : []),
|
|
151
165
|
]}
|
|
166
|
+
contextMenu={[
|
|
167
|
+
{ label: 'Edit IP set', permissions: IPSET_MANAGE_PERMS, disabled: save.isPending, onSelect: () => void onEdit() },
|
|
168
|
+
{ label: row.is_enabled ? 'Disable & remove from fleet' : 'Enable & sync', permissions: IPSET_MANAGE_PERMS, disabled: cacheOnly || enable.isPending || disable.isPending, onSelect: async () => { if (await modal.confirm({ title: row.is_enabled ? 'Disable fleet enforcement?' : 'Enable fleet enforcement?', message: `${row.name}: changes this set on every fleet instance.`, confirmText: row.is_enabled ? 'Disable' : 'Enable' })) await run('IP set state updated', () => (row.is_enabled ? disable : enable).mutateAsync({ id })); } },
|
|
169
|
+
{ label: 'Sync to fleet', permissions: IPSET_MANAGE_PERMS, disabled: cacheOnly || sync.isPending, onSelect: async () => { if (await modal.confirm({ title: 'Sync to fleet?', message: `Push ${row.name} to every fleet instance now.`, confirmText: 'Sync' })) await run('Sync requested', () => sync.mutateAsync({ id })); } },
|
|
170
|
+
{ label: 'Refresh source', permissions: IPSET_MANAGE_PERMS, disabled: refresh.isPending, onSelect: async () => { if (await modal.confirm({ title: 'Refresh source?', message: `Replace stored CIDRs for ${row.name} from its provider list.`, confirmText: 'Refresh' })) await run('Source refresh requested', () => refresh.mutateAsync({ id })); } },
|
|
171
|
+
]}
|
|
152
172
|
sections={[
|
|
153
173
|
{
|
|
154
174
|
key: 'config', label: 'Configuration', icon: 'bi-sliders', render: () => (
|
|
@@ -164,58 +184,7 @@ export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
164
184
|
meta={row.last_synced == null
|
|
165
185
|
? 'Never synced to the fleet.'
|
|
166
186
|
: `Last synced ${fmt.datetime(row.last_synced)} · ${fmt.relative(row.last_synced)}`}
|
|
167
|
-
|
|
168
|
-
<div className="netsec-action-row">
|
|
169
|
-
{row.is_enabled ? (
|
|
170
|
-
<ArmedButton
|
|
171
|
-
className="btn-compact"
|
|
172
|
-
icon="bi-toggle-off"
|
|
173
|
-
label="Disable & remove from fleet"
|
|
174
|
-
armedLabel="Click again — every fleet instance drops this set from iptables"
|
|
175
|
-
onConfirm={() => run('IP set disabled and removed from the fleet', () => disable.mutateAsync({ id }))}
|
|
176
|
-
/>
|
|
177
|
-
) : (
|
|
178
|
-
<ArmedButton
|
|
179
|
-
className="btn-compact"
|
|
180
|
-
icon="bi-toggle-on"
|
|
181
|
-
label="Enable & sync"
|
|
182
|
-
armedLabel={cacheOnly
|
|
183
|
-
? 'Click again — the server will refuse this (cache-only list)'
|
|
184
|
-
: `Click again — ${row.cidr_count.toLocaleString()} ranges are kernel-blocked fleet-wide`}
|
|
185
|
-
onConfirm={() => run('IP set enabled and synced', () => enable.mutateAsync({ id }))}
|
|
186
|
-
/>
|
|
187
|
-
)}
|
|
188
|
-
<ArmedButton
|
|
189
|
-
className="btn-compact"
|
|
190
|
-
icon="bi-broadcast"
|
|
191
|
-
label="Sync to fleet"
|
|
192
|
-
armedLabel="Click again — pushes this set to every fleet instance now"
|
|
193
|
-
onConfirm={() => run('Sync broadcast to the fleet', () => sync.mutateAsync({ id }))}
|
|
194
|
-
/>
|
|
195
|
-
<ArmedButton
|
|
196
|
-
className="btn-compact"
|
|
197
|
-
icon="bi-arrow-clockwise"
|
|
198
|
-
label="Refresh source"
|
|
199
|
-
armedLabel="Click again — refetches the provider list and replaces the stored CIDRs"
|
|
200
|
-
onConfirm={() => run('Source refresh requested', () => refresh.mutateAsync({ id }))}
|
|
201
|
-
/>
|
|
202
|
-
<button className="btn btn-compact" onClick={() => void onEdit()}>
|
|
203
|
-
<i className="bi bi-pencil" /> Edit
|
|
204
|
-
</button>
|
|
205
|
-
{canDelete && (
|
|
206
|
-
<ArmedButton
|
|
207
|
-
className="btn-compact btn-danger"
|
|
208
|
-
icon="bi-trash"
|
|
209
|
-
label="Delete"
|
|
210
|
-
armedLabel="Click again — deletes the set and removes it from every fleet instance. This cannot be undone."
|
|
211
|
-
onConfirm={() => run('IP set deleted', async () => {
|
|
212
|
-
await remove.mutateAsync({ id });
|
|
213
|
-
onClose();
|
|
214
|
-
})}
|
|
215
|
-
/>
|
|
216
|
-
)}
|
|
217
|
-
</div>
|
|
218
|
-
) : null}
|
|
187
|
+
|
|
219
188
|
/>
|
|
220
189
|
|
|
221
190
|
{cacheOnly && (
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import { useRef, useState } from 'react';
|
|
2
1
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
4
3
|
import { mojoList, useCan } from '../../client/runtime';
|
|
5
|
-
import {
|
|
4
|
+
import { Badge, DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column, type FilterDef } from '../../ui';
|
|
5
|
+
import { DEFAULT_MOJO_REMOTE_URL, buildPhoneConfigPayload, fetchPhoneGroupChoices, lookupPhoneNumber, normalizePhoneNumber, resolveMojoRemoteUrl, savePhoneConfigImperative, testPhoneConfigImperative, type PhoneSecretField, type SecretEdit } from './api';
|
|
6
6
|
import { exportPhoneConfigs, exportPhoneNumbers, exportSmsAudits } from './data';
|
|
7
|
+
import { openSmsComposer, SMS_SEND_PERMISSIONS } from './SmsComposer';
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
PHONE_CONFIG_MANAGE_PERMISSIONS,
|
|
10
|
+
PHONE_CONFIG_VIEW_PERMISSIONS,
|
|
11
|
+
PHONE_GROUP_DIRECTORY_PERMISSIONS,
|
|
12
|
+
PHONE_LOOKUP_UI_PERMISSIONS,
|
|
13
|
+
PHONE_NUMBER_VIEW_PERMISSIONS,
|
|
14
|
+
PhoneConfigModel,
|
|
15
|
+
PhoneNumberModel,
|
|
16
|
+
SMS_VIEW_PERMISSIONS,
|
|
17
|
+
SmsModel,
|
|
18
|
+
type PhoneConfigRow,
|
|
19
|
+
type PhoneNumberRow,
|
|
20
|
+
type SmsRow,
|
|
12
21
|
} from './models';
|
|
13
22
|
|
|
14
23
|
const relationLabel=(value:{id:number;name?:string;display_name?:string}|number|null,empty='System default')=>value==null?empty:typeof value==='number'?`#${value}`:value.name??value.display_name??`#${value.id}`;
|
|
@@ -39,19 +48,282 @@ const configColumns:Column<PhoneConfigRow>[]=[
|
|
|
39
48
|
{key:'modified',label:'Modified',sortable:true,render:r=>fmt.datetime(r.modified)},
|
|
40
49
|
];
|
|
41
50
|
|
|
42
|
-
function PhoneDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PhoneNumberModel.useOne(id);
|
|
51
|
+
function PhoneDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PhoneNumberModel.useOne(id);if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading phone record…':'Phone record unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={row.phone_number} subtitle={`${row.carrier||'Unknown carrier'} · ${row.line_type||'Unknown line type'}`} icon="bi-telephone" onClose={onClose} sections={[{key:'lookup',label:'Lookup',icon:'bi-search',render:()=><div className="detail-section"><FlatRow label="Provider">{row.lookup_provider||'—'}</FlatRow><FlatRow label="Lookup count">{row.lookup_count}</FlatRow><FlatRow label="Last lookup">{row.last_lookup_at?fmt.datetime(row.last_lookup_at):'Never'}</FlatRow><FlatRow label="Expires">{row.lookup_expires_at?fmt.datetime(row.lookup_expires_at):'Unknown'}</FlatRow><p className="dim">Lookup payloads are deliberately excluded from the client cache.</p></div>},{key:'identity',label:'Carrier & owner',icon:'bi-person-lines-fill',render:()=><div className="detail-section"><FlatRow label="Valid">{row.is_valid?'Yes':'No'}</FlatRow><FlatRow label="Mobile">{row.is_mobile?'Yes':'No'}</FlatRow><FlatRow label="VoIP">{row.is_voip?'Yes':'No'}</FlatRow><FlatRow label="Registered owner">{row.registered_owner||'—'}</FlatRow><FlatRow label="Region">{[row.region,row.state].filter(Boolean).join(', ')||'—'}</FlatRow></div>}]}/>;}
|
|
43
52
|
|
|
44
53
|
function LookupDialog({close}:{close:(row:PhoneNumberRow|null)=>void}){const qc=useQueryClient();const [value,setValue]=useState('');const [busy,setBusy]=useState(false);const [error,setError]=useState('');const submit=async()=>{setBusy(true);setError('');try{const normalized=await normalizePhoneNumber(value);let previous:PhoneNumberRow|undefined;try{const existing=await mojoList<PhoneNumberRow>(PhoneNumberModel.endpoint,{phone_number:normalized,graph:'default',start:0,size:1});previous=existing.rows[0];}catch{/* lookup remains available for a new row */}const row=await lookupPhoneNumber(normalized,previous,()=>window.confirm('This cache row is still fresh. Force a paid global Twilio Lookup anyway?'));qc.setQueryData(PhoneNumberModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PhoneNumberModel.keys.root});toast.success('Lookup completed with refresh evidence');close(row);}catch(reason){setError(reason instanceof Error?reason.message:'Lookup failed');}finally{setBusy(false);}};return <div className="modal-pad"><h2 className="modal-title">Lookup phone number</h2><p className="dim">The number is normalized first. New or expired rows refresh automatically; a known-fresh row requires confirmation before a paid global Twilio Lookup.</p>{error&&<div className="form-alert" role="alert">{error}</div>}<label className="field"><span className="field-label">Phone number</span><input className="input" value={value} onChange={e=>setValue(e.target.value)} placeholder="+1 415 555 0100"/></label><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!value.trim()} onClick={()=>void submit()}>{busy?'Looking up…':'Normalize and look up'}</button></div></div>;}
|
|
45
54
|
|
|
46
55
|
export function PhoneNumbersPage(){const canLookup=useCan(PHONE_LOOKUP_UI_PERMISSIONS).can;return <ModelTable model={PhoneNumberModel} eyebrow="Communications · Phone Hub" title="Phone Numbers" columns={phoneColumns} defaultSort="phone_number" searchable searchPlaceholder="Search number, carrier, or owner" columnChooser persistState persistKey="admin:phonehub:numbers" exportFormats={['csv','json']} exporter={exportPhoneNumbers} addLabel="Lookup number" onAdd={canLookup?()=>void modal.open(close=><LookupDialog close={close}/>):undefined} onRowClick={row=>void modal.detail(close=><PhoneDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
47
56
|
|
|
48
|
-
function SmsDetail({id,onClose}:{id:number;onClose:()=>void}){const query=SmsModel.useOne(id);
|
|
49
|
-
export function SmsPage(){return <ModelTable model={SmsModel} eyebrow="Communications · Phone Hub" title="SMS Audit" columns={smsColumns} filters={smsFilters} defaultSort="-created" searchable searchPlaceholder="Search number or message body" autoRefresh={20} columnChooser persistState persistKey="admin:phonehub:sms" exportFormats={['csv','json']} exporter={exportSmsAudits} onRowClick={row=>void modal.detail(close=><SmsDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
57
|
+
function SmsDetail({id,onClose}:{id:number;onClose:()=>void}){const query=SmsModel.useOne(id);if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading SMS audit…':'SMS audit unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={`${row.from_number} → ${row.to_number}`} subtitle={`${row.direction} · ${row.status}`} icon="bi-chat-square-text" onClose={onClose} sections={[{key:'message',label:'Message',icon:'bi-chat-text',render:()=><div className="detail-section"><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Provider">{row.provider||'—'}</FlatRow><FlatRow label="Group">{relationLabel(row.group,'None')}</FlatRow><div className="known-card"><h3>Message body</h3><p className="pre-wrap">{row.body||'—'}</p></div>{row.error_message&&<div className="form-alert">{row.error_message}</div>}</div>}]}/>;}
|
|
58
|
+
export function SmsPage(){const canSend=useCan(SMS_SEND_PERMISSIONS).can;return <ModelTable model={SmsModel} eyebrow="Communications · Phone Hub" title="SMS Audit" columns={smsColumns} filters={smsFilters} defaultSort="-created" searchable searchPlaceholder="Search number or message body" autoRefresh={20} columnChooser persistState persistKey="admin:phonehub:sms" exportFormats={['csv','json']} exporter={exportSmsAudits} addLabel="Send SMS" onAdd={canSend?()=>void openSmsComposer():undefined} onRowClick={row=>void modal.detail(close=><SmsDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
59
|
+
|
|
60
|
+
type PhoneProvider = PhoneConfigRow['provider'];
|
|
61
|
+
|
|
62
|
+
const credentialLabels: Record<PhoneSecretField, string> = {
|
|
63
|
+
twilio_account_sid: 'Twilio account SID',
|
|
64
|
+
twilio_auth_token: 'Twilio auth token',
|
|
65
|
+
aws_access_key_id: 'AWS access key ID',
|
|
66
|
+
aws_secret_access_key: 'AWS secret access key',
|
|
67
|
+
mojo_api_key: 'Mojo service API key',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const providerMeta: Record<PhoneProvider, { label: string; icon: string; description: string }> = {
|
|
71
|
+
twilio: { label: 'Twilio', icon: 'bi-chat-square-text', description: 'Programmable SMS credentials' },
|
|
72
|
+
aws: { label: 'AWS SNS', icon: 'bi-cloud', description: 'Regional SMS sender credentials' },
|
|
73
|
+
mojo: { label: 'Mojo Remote', icon: 'bi-phone', description: 'Group-aware remote Mojo bridge' },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const providerCredentialFields: Record<PhoneProvider, PhoneSecretField[]> = {
|
|
77
|
+
twilio: ['twilio_account_sid', 'twilio_auth_token'],
|
|
78
|
+
aws: ['aws_access_key_id', 'aws_secret_access_key'],
|
|
79
|
+
mojo: ['mojo_api_key'],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function providerOperationalNote(provider: PhoneProvider): string {
|
|
83
|
+
if (provider === 'mojo') return 'Mojo Remote is used by the group-aware SMS delivery path.';
|
|
84
|
+
if (provider === 'aws') return 'Stored AWS credentials can be tested here. AWS outbound delivery is not wired yet.';
|
|
85
|
+
return 'SMS delivery uses the saved Twilio credential pair, or global settings when neither credential is stored.';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function providerVisibleRows(row: PhoneConfigRow): Array<[string, string]> {
|
|
89
|
+
if (row.provider === 'twilio') return [['From number', row.twilio_from_number || 'Not set']];
|
|
90
|
+
if (row.provider === 'aws') return [['Region', row.aws_region || 'Not set'], ['Sender ID', row.aws_sender_id || 'Not set']];
|
|
91
|
+
return [['Remote URL', row.mojo_remote_url || 'Not set']];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function CredentialField({ field, existing, clearing, wide, inputRef, onToggle }: {
|
|
95
|
+
field: PhoneSecretField;
|
|
96
|
+
existing: boolean;
|
|
97
|
+
clearing: boolean;
|
|
98
|
+
wide?: boolean;
|
|
99
|
+
inputRef: (node: HTMLInputElement | null) => void;
|
|
100
|
+
onToggle: () => void;
|
|
101
|
+
}) {
|
|
102
|
+
const inputId = `phonehub-${field}`;
|
|
103
|
+
return <div className={`field phonehub-secret-field${wide ? ' phonehub-field-span' : ''}`}>
|
|
104
|
+
<label className="field-label" htmlFor={inputId}>{credentialLabels[field]}</label>
|
|
105
|
+
<div className="phonehub-secret-row">
|
|
106
|
+
<input
|
|
107
|
+
id={inputId}
|
|
108
|
+
ref={inputRef}
|
|
109
|
+
className="input"
|
|
110
|
+
type="password"
|
|
111
|
+
autoComplete="new-password"
|
|
112
|
+
placeholder={existing ? 'Leave blank to keep the stored value' : 'Enter credential'}
|
|
113
|
+
disabled={clearing}
|
|
114
|
+
/>
|
|
115
|
+
{existing && <button type="button" className={`btn${clearing ? ' btn-danger' : ''}`} onClick={onToggle}>
|
|
116
|
+
{clearing ? 'Keep stored value' : 'Clear stored value…'}
|
|
117
|
+
</button>}
|
|
118
|
+
</div>
|
|
119
|
+
<span className={`field-help${clearing ? ' text-bad' : ''}`}>
|
|
120
|
+
{clearing ? 'This credential will be cleared when you save.' : 'Write-only; the stored value is never displayed.'}
|
|
121
|
+
</span>
|
|
122
|
+
</div>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ConfigEditor({ row, close }: { row?: PhoneConfigRow; close: (value: PhoneConfigRow | null) => void }) {
|
|
126
|
+
const qc = useQueryClient();
|
|
127
|
+
const canGroups = useCan(PHONE_GROUP_DIRECTORY_PERMISSIONS).can;
|
|
128
|
+
const groups = useQuery({ queryKey: ['phonehub', 'group-choices'], queryFn: fetchPhoneGroupChoices, enabled: canGroups, staleTime: 60_000 });
|
|
129
|
+
const [name, setName] = useState(row?.name ?? '');
|
|
130
|
+
const [provider, setProvider] = useState<PhoneProvider>(row?.provider ?? 'twilio');
|
|
131
|
+
const [group, setGroup] = useState(row?.group == null ? '' : String(typeof row.group === 'number' ? row.group : row.group.id));
|
|
132
|
+
const [active, setActive] = useState(row?.is_active ?? true);
|
|
133
|
+
const [testMode, setTestMode] = useState(row?.test_mode ?? false);
|
|
134
|
+
const [lookup, setLookup] = useState(row?.lookup_enabled ?? true);
|
|
135
|
+
const [cacheDays, setCacheDays] = useState(row?.lookup_cache_days ?? 90);
|
|
136
|
+
const [twilioFrom, setTwilioFrom] = useState(row?.twilio_from_number ?? '');
|
|
137
|
+
const [awsRegion, setAwsRegion] = useState(row?.aws_region ?? 'us-east-1');
|
|
138
|
+
const [awsSender, setAwsSender] = useState(row?.aws_sender_id ?? '');
|
|
139
|
+
const [mojoUrl, setMojoUrl] = useState(resolveMojoRemoteUrl(row?.mojo_remote_url));
|
|
140
|
+
const refs = useRef<Partial<Record<PhoneSecretField, HTMLInputElement | null>>>({});
|
|
141
|
+
const [clearFields, setClearFields] = useState<PhoneSecretField[]>([]);
|
|
142
|
+
const [busy, setBusy] = useState(false);
|
|
143
|
+
const [error, setError] = useState('');
|
|
144
|
+
const meta = providerMeta[provider];
|
|
145
|
+
const invalidCache = lookup && (!Number.isFinite(cacheDays) || cacheDays < 1);
|
|
146
|
+
|
|
147
|
+
const chooseProvider = (next: PhoneProvider) => {
|
|
148
|
+
setProvider(next);
|
|
149
|
+
if (next === 'mojo') setMojoUrl((value) => resolveMojoRemoteUrl(value));
|
|
150
|
+
setClearFields([]);
|
|
151
|
+
};
|
|
152
|
+
const toggleClear = (field: PhoneSecretField) => {
|
|
153
|
+
if (clearFields.includes(field)) {
|
|
154
|
+
setClearFields(clearFields.filter((value) => value !== field));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (window.confirm(`Clear the stored ${credentialLabels[field]} on save?`)) setClearFields([...clearFields, field]);
|
|
158
|
+
};
|
|
159
|
+
const submit = async () => {
|
|
160
|
+
setBusy(true);
|
|
161
|
+
setError('');
|
|
162
|
+
const credentials: Partial<Record<PhoneSecretField, SecretEdit>> = {};
|
|
163
|
+
const visibleCredentials = providerCredentialFields[provider];
|
|
164
|
+
for (const field of Object.keys(credentialLabels) as PhoneSecretField[]) {
|
|
165
|
+
const value = refs.current[field]?.value ?? '';
|
|
166
|
+
credentials[field] = !visibleCredentials.includes(field)
|
|
167
|
+
? { mode: 'untouched' }
|
|
168
|
+
: clearFields.includes(field)
|
|
169
|
+
? { mode: 'clear', confirmed: true }
|
|
170
|
+
: value ? { mode: 'replace', value } : { mode: 'untouched' };
|
|
171
|
+
}
|
|
172
|
+
const providerChanges = provider === 'twilio'
|
|
173
|
+
? { twilio_from_number: twilioFrom || null }
|
|
174
|
+
: provider === 'aws'
|
|
175
|
+
? { aws_region: awsRegion || null, aws_sender_id: awsSender || null }
|
|
176
|
+
: { mojo_remote_url: resolveMojoRemoteUrl(mojoUrl) };
|
|
177
|
+
try {
|
|
178
|
+
buildPhoneConfigPayload({}, credentials);
|
|
179
|
+
const saved = await savePhoneConfigImperative(qc, row?.id ?? null, {
|
|
180
|
+
name: name.trim(), provider, group: group ? Number(group) : null,
|
|
181
|
+
is_active: active, test_mode: testMode, lookup_enabled: lookup,
|
|
182
|
+
lookup_cache_days: cacheDays, ...providerChanges,
|
|
183
|
+
}, credentials);
|
|
184
|
+
for (const field of Object.keys(credentialLabels) as PhoneSecretField[]) if (refs.current[field]) refs.current[field]!.value = '';
|
|
185
|
+
toast.success(row ? 'Provider configuration updated' : 'Provider configuration created');
|
|
186
|
+
close(saved);
|
|
187
|
+
} catch (reason) {
|
|
188
|
+
setError(reason instanceof Error ? reason.message : 'Configuration could not be saved');
|
|
189
|
+
} finally {
|
|
190
|
+
setBusy(false);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return <div className="modal-pad phonehub-config-editor">
|
|
195
|
+
<header className="phonehub-config-header">
|
|
196
|
+
<div className="eyebrow">Phone Hub</div>
|
|
197
|
+
<h2 className="modal-title">{row ? 'Edit' : 'Create'} provider configuration</h2>
|
|
198
|
+
<p className="dim">Choose a provider, enter only its connection details, then set how this configuration participates in delivery and lookup.</p>
|
|
199
|
+
</header>
|
|
200
|
+
<div className="phonehub-config-sections">
|
|
201
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
202
|
+
<section className="phonehub-config-section" aria-labelledby="phonehub-basics-heading">
|
|
203
|
+
<div className="phonehub-config-section-heading">
|
|
204
|
+
<span className="phonehub-config-section-icon"><i className="bi bi-card-heading" /></span>
|
|
205
|
+
<div><h3 id="phonehub-basics-heading">Basics</h3><p>Name the configuration and decide where it applies.</p></div>
|
|
206
|
+
</div>
|
|
207
|
+
<div className="phonehub-field-grid">
|
|
208
|
+
<label className="field phonehub-field-span"><span className="field-label">Name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)} placeholder="e.g. Primary Twilio or Mojo Remote" /></label>
|
|
209
|
+
{canGroups ? <label className="field phonehub-field-span"><span className="field-label">Group scope</span><select className="input" value={group} onChange={(event) => setGroup(event.target.value)}><option value="">System default</option>{groups.data?.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select><span className="field-help">System default is used when an active group-specific configuration is unavailable.</span></label> : <div className="field phonehub-field-span"><span className="field-label">Group scope</span><div className="input" aria-readonly="true">{relationLabel(row?.group ?? null)}</div><span className="field-help">Group choices stay hidden without global group-directory permission.</span></div>}
|
|
210
|
+
<fieldset className="phonehub-provider-fieldset phonehub-field-span">
|
|
211
|
+
<legend className="field-label">Provider</legend>
|
|
212
|
+
<div className="phonehub-provider-options">
|
|
213
|
+
{(Object.entries(providerMeta) as Array<[PhoneProvider, typeof providerMeta[PhoneProvider]]>).map(([value, option]) => <label className="phonehub-provider-option" key={value}>
|
|
214
|
+
<input type="radio" name="phonehub-provider" value={value} checked={provider === value} onChange={() => chooseProvider(value)} />
|
|
215
|
+
<span><i className={`bi ${option.icon}`} /><strong>{option.label}</strong><small>{option.description}</small></span>
|
|
216
|
+
</label>)}
|
|
217
|
+
</div>
|
|
218
|
+
</fieldset>
|
|
219
|
+
</div>
|
|
220
|
+
</section>
|
|
221
|
+
|
|
222
|
+
<section className="phonehub-config-section" aria-labelledby="phonehub-connection-heading">
|
|
223
|
+
<div className="phonehub-config-section-heading">
|
|
224
|
+
<span className="phonehub-config-section-icon"><i className={`bi ${meta.icon}`} /></span>
|
|
225
|
+
<div><h3 id="phonehub-connection-heading">{meta.label} connection</h3><p>Only fields for the selected provider are shown and submitted.</p></div>
|
|
226
|
+
</div>
|
|
227
|
+
<div className="phonehub-field-grid">
|
|
228
|
+
{provider === 'twilio' && <>
|
|
229
|
+
<label className="field phonehub-field-span"><span className="field-label">From number</span><input className="input" value={twilioFrom} onChange={(event) => setTwilioFrom(event.target.value)} placeholder="+15551234567" /></label>
|
|
230
|
+
</>}
|
|
231
|
+
{provider === 'aws' && <>
|
|
232
|
+
<label className="field"><span className="field-label">AWS region</span><input className="input" value={awsRegion} onChange={(event) => setAwsRegion(event.target.value)} placeholder="us-east-1" /></label>
|
|
233
|
+
<label className="field"><span className="field-label">Sender ID</span><input className="input" value={awsSender} onChange={(event) => setAwsSender(event.target.value)} placeholder="Optional, region-dependent" /></label>
|
|
234
|
+
</>}
|
|
235
|
+
{provider === 'mojo' && <>
|
|
236
|
+
<label className="field phonehub-field-span"><span className="field-label">Remote Mojo URL</span><input className="input" type="url" value={mojoUrl} onChange={(event) => setMojoUrl(event.target.value)} onBlur={() => setMojoUrl((value) => resolveMojoRemoteUrl(value))} placeholder={DEFAULT_MOJO_REMOTE_URL} /><span className="field-help">Defaults to the hosted MojoVerify API. Change this only for a custom Mojo endpoint.</span></label>
|
|
237
|
+
</>}
|
|
238
|
+
{providerCredentialFields[provider].map((field) => <CredentialField
|
|
239
|
+
key={field}
|
|
240
|
+
field={field}
|
|
241
|
+
existing={Boolean(row)}
|
|
242
|
+
clearing={clearFields.includes(field)}
|
|
243
|
+
wide={providerCredentialFields[provider].length === 1}
|
|
244
|
+
inputRef={(node) => { refs.current[field] = node; }}
|
|
245
|
+
onToggle={() => toggleClear(field)}
|
|
246
|
+
/>)}
|
|
247
|
+
</div>
|
|
248
|
+
<div className={`phonehub-provider-note${provider === 'mojo' ? '' : ' is-warning'}`}><i className={`bi ${provider === 'mojo' ? 'bi-info-circle' : 'bi-exclamation-triangle'}`} /><span>{providerOperationalNote(provider)}</span></div>
|
|
249
|
+
</section>
|
|
250
|
+
|
|
251
|
+
<section className="phonehub-config-section" aria-labelledby="phonehub-behavior-heading">
|
|
252
|
+
<div className="phonehub-config-section-heading">
|
|
253
|
+
<span className="phonehub-config-section-icon"><i className="bi bi-toggles" /></span>
|
|
254
|
+
<div><h3 id="phonehub-behavior-heading">Behavior</h3><p>Control selection, connection testing, and phone-number lookup.</p></div>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="phonehub-behavior-grid">
|
|
257
|
+
<label className="phonehub-behavior-setting"><span><strong>Active</strong><small>Eligible for provider selection.</small></span><input type="checkbox" role="switch" className="switch" checked={active} onChange={(event) => setActive(event.target.checked)} /></label>
|
|
258
|
+
<label className="phonehub-behavior-setting"><span><strong>Test mode</strong><small>Short-circuit connection tests; delivery is unchanged.</small></span><input type="checkbox" role="switch" className="switch" checked={testMode} onChange={(event) => setTestMode(event.target.checked)} /></label>
|
|
259
|
+
<label className="phonehub-behavior-setting"><span><strong>Automatic lookup</strong><small>Look up carrier data for new numbers.</small></span><input type="checkbox" role="switch" className="switch" checked={lookup} onChange={(event) => setLookup(event.target.checked)} /></label>
|
|
260
|
+
</div>
|
|
261
|
+
<label className="field phonehub-cache-field"><span className="field-label">Lookup cache lifetime</span><div className="phonehub-number-suffix"><input className="input" type="number" min="1" value={cacheDays} disabled={!lookup} onChange={(event) => setCacheDays(Number(event.target.value))} /><span>days</span></div>{invalidCache && <span className="field-error">Enter at least 1 day.</span>}</label>
|
|
262
|
+
</section>
|
|
263
|
+
</div>
|
|
264
|
+
<div className="modal-actions phonehub-config-actions">
|
|
265
|
+
<button className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
|
|
266
|
+
<button className="btn btn-primary" disabled={busy || !name.trim() || invalidCache} onClick={() => void submit()}>{busy ? 'Saving…' : 'Save configuration'}</button>
|
|
267
|
+
</div>
|
|
268
|
+
</div>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ConfigDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
272
|
+
const qc = useQueryClient();
|
|
273
|
+
const [saving, setSaving] = useState(false);
|
|
274
|
+
const query = PhoneConfigModel.useOne(id);
|
|
275
|
+
const canManage = useCan(PHONE_CONFIG_MANAGE_PERMISSIONS).can;
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
const [testing, setTesting] = useState(false);
|
|
279
|
+
const [testResult, setTestResult] = useState<Awaited<ReturnType<typeof testPhoneConfigImperative>> | null>(null);
|
|
280
|
+
if (!query.data) return <div className="modal-pad">{query.isLoading ? 'Loading provider configuration…' : 'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
|
|
281
|
+
const row = query.data;
|
|
282
|
+
const toggle = async (next: boolean) => {
|
|
283
|
+
if (!canManage || saving) return;
|
|
284
|
+
setSaving(true);
|
|
285
|
+
try { await savePhoneConfigImperative(qc, id, { is_active: next }, {}); await query.refetch(); }
|
|
286
|
+
catch (error) { toast.error(error instanceof Error ? error.message : 'State change failed'); }
|
|
287
|
+
finally { setSaving(false); }
|
|
288
|
+
};
|
|
289
|
+
const meta = providerMeta[row.provider];
|
|
290
|
+
const edit = () => void modal.open((close) => <ConfigEditor row={row} close={async (saved) => { close(saved); if (saved) { setTestResult(null); await query.refetch(); } }} />, { size: 'lg' });
|
|
291
|
+
const test = async () => {
|
|
292
|
+
setTesting(true);
|
|
293
|
+
setTestResult(null);
|
|
294
|
+
try {
|
|
295
|
+
const result = await testPhoneConfigImperative(id);
|
|
296
|
+
setTestResult(result);
|
|
297
|
+
toast[result.testMode ? 'warning' : result.status ? 'success' : 'error'](result.message);
|
|
298
|
+
} catch (reason) {
|
|
299
|
+
const message = reason instanceof Error ? reason.message : 'Connection test failed';
|
|
300
|
+
setTestResult({ status: false, message, testMode: false, errorCode: null });
|
|
301
|
+
toast.error(message);
|
|
302
|
+
} finally {
|
|
303
|
+
setTesting(false);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
50
306
|
|
|
51
|
-
const
|
|
52
|
-
|
|
307
|
+
const resultNotice = testResult && <div className={testResult.status && !testResult.testMode ? 'known-card' : 'form-alert'} role={testResult.status ? 'status' : 'alert'}>
|
|
308
|
+
<h3>Connection test: {testResult.testMode ? 'Not tested' : testResult.status ? 'Passed' : 'Failed'}</h3>
|
|
309
|
+
<p>{testResult.message}</p>
|
|
310
|
+
{testResult.errorCode === 'missing_credentials' && <p>Use Edit configuration to save the provider credentials, then test again. Blank credential fields keep any saved value unchanged.</p>}
|
|
311
|
+
</div>;
|
|
53
312
|
|
|
54
|
-
|
|
313
|
+
return <DetailView
|
|
314
|
+
title={row.name}
|
|
315
|
+
subtitle={`Scope: ${relationLabel(row.group)}`}
|
|
316
|
+
icon={meta.icon}
|
|
317
|
+
chips={[{ text: meta.label, tone: 'info' }, { text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }, ...(row.test_mode ? [{ text: 'Test mode', tone: 'warning' as const }] : [])]}
|
|
318
|
+
onClose={onClose}
|
|
319
|
+
active={canManage ? { value: row.is_active, disabled: saving, onChange: next => void toggle(next) } : undefined}
|
|
320
|
+
contextMenu={canManage ? [{ label: 'Edit configuration', disabled: testing, onSelect: edit }, { label: 'Test stored connection', disabled: testing, onSelect: () => void test() }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: saving, onSelect: () => void toggle(!row.is_active) }] : []}
|
|
321
|
+
sections={[
|
|
322
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-sliders', render: () => <div className="detail-section">{resultNotice}<div className="phonehub-provider-summary"><i className={`bi ${meta.icon}`} /><div><span className="eyebrow">Provider</span><h3>{meta.label}</h3><p>{meta.description}</p></div></div><div className="phonehub-summary-grid"><div className="known-card"><span>Scope</span><strong>{relationLabel(row.group)}</strong></div><div className="known-card"><span>Automatic lookup</span><strong>{row.lookup_enabled ? `Enabled · ${row.lookup_cache_days} days` : 'Disabled'}</strong></div><div className="known-card"><span>Last modified</span><strong>{fmt.datetime(row.modified)}</strong></div></div><p className="dim">An active group configuration wins; otherwise Phone Hub falls back to the first active system default.</p></div> },
|
|
323
|
+
{ key: 'connection', label: 'Connection', icon: 'bi-plug', render: () => <div className="detail-section">{resultNotice}<FlatRow label="Scope">{relationLabel(row.group)}</FlatRow><div className="section-eyebrow">{meta.label} settings</div>{providerVisibleRows(row).map(([label, value]) => <FlatRow key={label} label={label}>{value}</FlatRow>)}<FlatRow label="Credentials"><span><i className="bi bi-shield-lock" /> Write-only; edit to replace or clear</span></FlatRow><div className={`phonehub-provider-note${row.provider === 'mojo' ? '' : ' is-warning'}`}><i className={`bi ${row.provider === 'mojo' ? 'bi-info-circle' : 'bi-exclamation-triangle'}`} /><span>{providerOperationalNote(row.provider)}</span></div>{row.test_mode && <div className="phonehub-provider-note is-warning"><i className="bi bi-beaker" /><span>Test mode only short-circuits the connection test. It does not prevent SMS delivery.</span></div>}<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> }
|
|
324
|
+
]}
|
|
325
|
+
/>;
|
|
326
|
+
}
|
|
55
327
|
export function ProviderConfigsPage(){const canManage=useCan(PHONE_CONFIG_MANAGE_PERMISSIONS).can;return <ModelTable model={PhoneConfigModel} eyebrow="Communications · Phone Hub" title="Provider Configurations" columns={configColumns} defaultSort="name" searchable searchPlaceholder="Search configuration names" columnChooser persistState persistKey="admin:phonehub:configs" exportFormats={['csv','json']} exporter={exportPhoneConfigs} addLabel="Create configuration" onAdd={canManage?()=>void modal.open(close=><ConfigEditor close={close}/>,{size:'lg'}):undefined} onRowClick={row=>void modal.detail(close=><ConfigDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
56
328
|
|
|
57
329
|
export function PhoneHubPage(){const canNumbers=useCan(PHONE_NUMBER_VIEW_PERMISSIONS).can;const canSms=useCan(SMS_VIEW_PERMISSIONS).can;const canConfig=useCan(PHONE_CONFIG_VIEW_PERMISSIONS).can;const items=[...(canNumbers?[{key:'numbers',label:'Phone Numbers',panel:<PhoneNumbersPage/>}]:[]),...(canSms?[{key:'sms',label:'SMS',panel:<SmsPage/>}]:[]),...(canConfig?[{key:'config',label:'Provider Config',panel:<ProviderConfigsPage/>}]:[])];return <div className="phonehub-page"><div className="phonehub-heading"><p className="eyebrow">Communications</p><h1>Phone Hub</h1><p className="dim">Global phone intelligence, SMS audit history, and provider configuration. This surface never adopts the active product group.</p></div><Tabs ariaLabel="Phone Hub admin areas" variant="underline-all" items={items}/></div>;}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { mojoCall, useCan } from '../../client/runtime';
|
|
4
|
+
import { Badge, FlatRow, modal } from '../../ui';
|
|
5
|
+
import { normalizePhoneNumber } from './api';
|
|
6
|
+
import { SMS_VIEW_PERMISSIONS, SmsModel, sanitizeSmsRow, type SmsRow } from './models';
|
|
7
|
+
|
|
8
|
+
// Sending and creating/editing an audit row are separate backend operations.
|
|
9
|
+
export const SMS_SEND_PERMISSIONS = ['sys.send_sms', 'sys.comms'];
|
|
10
|
+
|
|
11
|
+
export function openSmsComposer() {
|
|
12
|
+
let sending = false;
|
|
13
|
+
return modal.open(close => <SmsComposer close={() => close(null)} onSending={value => { sending = value; }} />, {
|
|
14
|
+
canDismiss: () => !sending,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SmsComposer({ close, onSending }: { close: () => void; onSending: (value: boolean) => void }) {
|
|
19
|
+
const qc = useQueryClient();
|
|
20
|
+
const { can: canSend, me } = useCan(SMS_SEND_PERMISSIONS);
|
|
21
|
+
const canView = useCan(SMS_VIEW_PERMISSIONS).can;
|
|
22
|
+
const actor = useRef(me?.id);
|
|
23
|
+
const allowed = useRef(false);
|
|
24
|
+
allowed.current = canSend && canView && me?.id === actor.current;
|
|
25
|
+
const mounted = useRef(true);
|
|
26
|
+
const inFlight = useRef(false);
|
|
27
|
+
const [recipient, setRecipient] = useState('');
|
|
28
|
+
const [message, setMessage] = useState('');
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
const [error, setError] = useState('');
|
|
31
|
+
const [receipt, setReceipt] = useState<SmsRow | null>(null);
|
|
32
|
+
const [refreshError, setRefreshError] = useState(false);
|
|
33
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
34
|
+
|
|
35
|
+
const assertAllowed = () => {
|
|
36
|
+
if (!mounted.current || !allowed.current) throw new Error('Your permission to send SMS is no longer available.');
|
|
37
|
+
};
|
|
38
|
+
const submit = async (event: FormEvent) => {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
if (inFlight.current || receipt || !recipient.trim() || !message.trim()) return;
|
|
41
|
+
inFlight.current = true;
|
|
42
|
+
onSending(true);
|
|
43
|
+
setBusy(true);
|
|
44
|
+
setError('');
|
|
45
|
+
let attempted = false;
|
|
46
|
+
try {
|
|
47
|
+
assertAllowed();
|
|
48
|
+
const to_number = await normalizePhoneNumber(recipient);
|
|
49
|
+
assertAllowed();
|
|
50
|
+
// Explicit global scope: never copy outer params/group, audit fields,
|
|
51
|
+
// credentials or a provider selector into the sending request.
|
|
52
|
+
const response = await mojoCall('/api/phonehub/sms/send', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
body: { to_number, body: message },
|
|
55
|
+
beforeSend: () => { assertAllowed(); attempted = true; },
|
|
56
|
+
});
|
|
57
|
+
const data = response.data as SmsRow | undefined;
|
|
58
|
+
if (!data || !Number.isSafeInteger(data.id) || data.id <= 0 || typeof data.status !== 'string' || !data.status) {
|
|
59
|
+
throw new Error('The server returned no message receipt.');
|
|
60
|
+
}
|
|
61
|
+
if (mounted.current) setReceipt(sanitizeSmsRow(data));
|
|
62
|
+
} catch (reason) {
|
|
63
|
+
if (mounted.current) setError(`${reason instanceof Error ? reason.message : 'SMS request failed.'}${attempted ? ' Check SMS Audit before trying again; sending could not be confirmed.' : ''}`);
|
|
64
|
+
} finally {
|
|
65
|
+
if (attempted) {
|
|
66
|
+
try { await qc.invalidateQueries({ queryKey: SmsModel.keys.root }); }
|
|
67
|
+
catch { if (mounted.current) setRefreshError(true); }
|
|
68
|
+
}
|
|
69
|
+
inFlight.current = false;
|
|
70
|
+
onSending(false);
|
|
71
|
+
if (mounted.current) setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (receipt) {
|
|
76
|
+
const failed = receipt.status === 'failed' || receipt.status === 'undelivered';
|
|
77
|
+
return <div className="modal-pad">
|
|
78
|
+
<h2 className="modal-title">{failed ? 'SMS failed' : 'SMS request recorded'}</h2>
|
|
79
|
+
<p className="modal-message">{failed ? 'The message was not delivered. Review the error before sending another message.' : 'The status below is reported by Phone Hub. Only a delivered status confirms delivery.'}</p>
|
|
80
|
+
<FlatRow label="To"><code>{receipt.to_number || recipient}</code></FlatRow>
|
|
81
|
+
<FlatRow label="Status"><Badge tone={failed ? 'danger' : receipt.status === 'delivered' ? 'success' : 'info'}>{receipt.status}</Badge></FlatRow>
|
|
82
|
+
<FlatRow label="Audit record">#{receipt.id}</FlatRow>
|
|
83
|
+
{receipt.error_message && <div className="form-alert" role="alert">{receipt.error_message}</div>}
|
|
84
|
+
{refreshError && <p role="alert">Refresh SMS Audit to see the latest message record.</p>}
|
|
85
|
+
<div className="modal-actions"><button className="btn btn-primary" disabled={busy} onClick={close}>Close</button></div>
|
|
86
|
+
</div>;
|
|
87
|
+
}
|
|
88
|
+
return <form className="modal-pad" onSubmit={event => void submit(event)}>
|
|
89
|
+
<h2 className="modal-title">Send SMS</h2>
|
|
90
|
+
<p className="modal-message">Send one message using the system SMS configuration and its default sender. This sends a real message; provider test mode does not prevent delivery.</p>
|
|
91
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
92
|
+
{!allowed.current && <div className="form-alert" role="alert">You no longer have permission to send SMS.</div>}
|
|
93
|
+
<label className="field"><span className="field-label">Recipient</span><input className="input" type="tel" autoComplete="tel" value={recipient} disabled={busy} onChange={event => setRecipient(event.target.value)} placeholder="+1 415 555 0123" required /><span className="field-help">Include the country code for international numbers.</span></label>
|
|
94
|
+
<label className="field"><span className="field-label">Message</span><textarea className="input" rows={6} value={message} disabled={busy} onChange={event => setMessage(event.target.value)} required /><span className="field-help">{message.length} characters. Long messages may use multiple SMS segments.</span></label>
|
|
95
|
+
<div className="modal-actions"><button type="button" className="btn" disabled={busy} onClick={close}>Cancel</button><button type="submit" className="btn btn-primary" disabled={busy || !allowed.current || !recipient.trim() || !message.trim()}>{busy ? 'Sending…' : 'Send message'}</button></div>
|
|
96
|
+
</form>;
|
|
97
|
+
}
|