portal-mojo 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/docs/DetailView.md +19 -15
  2. package/docs/README.md +1 -0
  3. package/docs/admin-assistant.md +3 -1
  4. package/docs/admin-credentials.md +7 -0
  5. package/docs/admin-dashboard.md +1 -1
  6. package/docs/admin-dns.md +8 -2
  7. package/docs/admin-incidents.md +26 -3
  8. package/docs/admin-jobs.md +7 -4
  9. package/docs/admin-messaging.md +6 -5
  10. package/docs/admin-modals.md +122 -0
  11. package/docs/admin-network-security.md +4 -4
  12. package/docs/admin-phonehub.md +13 -5
  13. package/docs/admin-push.md +7 -0
  14. package/docs/admin-rules.md +8 -0
  15. package/docs/admin-shortlinks.md +2 -1
  16. package/docs/admin-storage.md +2 -1
  17. package/docs/auth.md +28 -0
  18. package/docs/client.md +12 -0
  19. package/docs/feedback.md +10 -0
  20. package/docs/forms.md +10 -0
  21. package/docs/popover.md +15 -0
  22. package/package.json +1 -1
  23. package/src/admin/assistant/api.ts +1 -1
  24. package/src/admin/assistant/pages.tsx +71 -17
  25. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  26. package/src/admin/core/index.ts +7 -2
  27. package/src/admin/credentials/group-api-keys.tsx +47 -43
  28. package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
  29. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  30. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  31. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  32. package/src/admin/dns/setup.ts +1 -1
  33. package/src/admin/domains/observability.ts +1 -1
  34. package/src/admin/identity/users/UserDetail.tsx +36 -20
  35. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  36. package/src/admin/identity/users/sections/actions.tsx +20 -9
  37. package/src/admin/incidents/EventDetail.tsx +147 -5
  38. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  39. package/src/admin/incidents/models.ts +1 -1
  40. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  41. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  42. package/src/admin/jobs/index.ts +1 -1
  43. package/src/admin/jobs/purge-preview.ts +25 -0
  44. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  45. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  46. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  47. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  48. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  49. package/src/admin/messaging/push/PushPage.tsx +20 -8
  50. package/src/admin/monitoring/LogInspector.tsx +3 -19
  51. package/src/admin/network/IPSetDetail.tsx +32 -63
  52. package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
  53. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  54. package/src/admin/phonehub/api.ts +6 -0
  55. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  56. package/src/admin/rules/condition-removal.ts +7 -0
  57. package/src/admin/security/geoip/models.ts +5 -0
  58. package/src/admin/security/tickets.tsx +40 -17
  59. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  60. package/src/admin/storage/BucketDetail.tsx +28 -9
  61. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  62. package/src/admin/storage/FileView.tsx +28 -16
  63. package/src/admin/storage/FilesPage.tsx +4 -5
  64. package/src/client/auth.ts +10 -1
  65. package/src/client/client.ts +14 -6
  66. package/src/client/mock.ts +89 -5
  67. package/src/client/upload.ts +1 -0
  68. package/src/ui/DetailView.tsx +22 -5
  69. package/src/ui/Popover.tsx +13 -12
  70. package/src/ui/modal.tsx +8 -4
@@ -1,14 +1,23 @@
1
- import { useRef, useState } from 'react';
2
1
  import { useQuery, useQueryClient } from '@tanstack/react-query';
3
- import { ArmedButton, Badge, DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column, type FilterDef } from '../../ui';
2
+ import { useRef, useState } from 'react';
4
3
  import { mojoList, useCan } from '../../client/runtime';
5
- import { buildPhoneConfigPayload, fetchPhoneGroupChoices, lookupPhoneNumber, normalizePhoneNumber, savePhoneConfigImperative, testPhoneConfigImperative, type PhoneSecretField, type SecretEdit } from './api';
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
- PHONE_CONFIG_DELETE_PERMISSIONS, PHONE_CONFIG_MANAGE_PERMISSIONS, PHONE_CONFIG_VIEW_PERMISSIONS,
9
- PHONE_GROUP_DIRECTORY_PERMISSIONS, PHONE_LOOKUP_UI_PERMISSIONS, PHONE_NUMBER_DELETE_PERMISSIONS,
10
- PHONE_NUMBER_VIEW_PERMISSIONS, SMS_DELETE_PERMISSIONS, SMS_VIEW_PERMISSIONS,
11
- PhoneConfigModel, PhoneNumberModel, SmsModel, type PhoneConfigRow, type PhoneNumberRow, type SmsRow,
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,271 @@ 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);const canDelete=useCan(PHONE_NUMBER_DELETE_PERMISSIONS).can;const destroy=PhoneNumberModel.useDelete();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;const remove=async()=>{await destroy.mutateAsync({id});toast.success('Local phone cache row deleted');onClose();};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>},...(canDelete?[{key:'delete',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><p>Deletes this global cached phone record only. It does not affect provider data.</p><ArmedButton label="Delete cached record" armedLabel="Click again to delete" onConfirm={remove}/></div>}]:[])]}/>;}
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);const canDelete=useCan(SMS_DELETE_PERMISSIONS).can;const destroy=SmsModel.useDelete();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;const remove=async()=>{await destroy.mutateAsync({id});toast.success('Local SMS audit row deleted');onClose();};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>},...(canDelete?[{key:'delete',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><p>Deletes the local audit row only. It cannot recall or alter a provider message. SMS rows are deleted when their owning user or group is deleted; deleting a provider config does not delete them.</p><ArmedButton label="Delete SMS audit row" armedLabel="Click again to delete" onConfirm={remove}/></div>}]:[])]}/>;}
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 'Stored Twilio credentials can be tested here. Outbound delivery currently uses the global Twilio settings.';
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
+
50
277
 
51
- const credentialLabels:Record<PhoneSecretField,string>={twilio_account_sid:'Twilio account SID',twilio_auth_token:'Twilio auth token',aws_access_key_id:'AWS access key id',aws_secret_access_key:'AWS secret access key',mojo_api_key:'Mojo service API key'};
52
- function ConfigEditor({row,close}:{row?:PhoneConfigRow;close:(value:PhoneConfigRow|null)=>void}){const qc=useQueryClient();const canGroups=useCan(PHONE_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['phonehub','group-choices'],queryFn:fetchPhoneGroupChoices,enabled:canGroups,staleTime:60_000});const [name,setName]=useState(row?.name??'');const [provider,setProvider]=useState<PhoneConfigRow['provider']>(row?.provider??'twilio');const [group,setGroup]=useState(row?.group==null?'':String(typeof row.group==='number'?row.group:row.group.id));const [active,setActive]=useState(row?.is_active??true);const [testMode,setTestMode]=useState(row?.test_mode??false);const [lookup,setLookup]=useState(row?.lookup_enabled??true);const [cacheDays,setCacheDays]=useState(row?.lookup_cache_days??90);const [twilioFrom,setTwilioFrom]=useState(row?.twilio_from_number??'');const [awsRegion,setAwsRegion]=useState(row?.aws_region??'us-east-1');const [awsSender,setAwsSender]=useState(row?.aws_sender_id??'');const [mojoUrl,setMojoUrl]=useState(row?.mojo_remote_url??'');const refs=useRef<Partial<Record<PhoneSecretField,HTMLInputElement|null>>>({});const [clearFields,setClearFields]=useState<PhoneSecretField[]>([]);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const toggleClear=(field:PhoneSecretField)=>{if(clearFields.includes(field)){setClearFields(clearFields.filter(v=>v!==field));return;}if(window.confirm(`Clear the stored ${credentialLabels[field]} on save?`))setClearFields([...clearFields,field]);};const submit=async()=>{setBusy(true);setError('');const credentials:Partial<Record<PhoneSecretField,SecretEdit>>={};for(const field of Object.keys(credentialLabels) as PhoneSecretField[]){const value=refs.current[field]?.value??'';credentials[field]=clearFields.includes(field)?{mode:'clear',confirmed:true}:value?{mode:'replace',value}:{mode:'untouched'};}try{buildPhoneConfigPayload({},credentials);const saved=await savePhoneConfigImperative(qc,row?.id??null,{name:name.trim(),provider,group:group?Number(group):null,is_active:active,test_mode:testMode,lookup_enabled:lookup,lookup_cache_days:cacheDays,twilio_from_number:twilioFrom||null,aws_region:awsRegion||null,aws_sender_id:awsSender||null,mojo_remote_url:mojoUrl||null},credentials);for(const field of Object.keys(credentialLabels) as PhoneSecretField[])if(refs.current[field])refs.current[field]!.value='';toast.success(row?'Provider configuration updated':'Provider configuration created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Configuration could not be saved');}finally{setBusy(false);}};return <div className="modal-pad phonehub-config-editor"><h2 className="modal-title">{row?'Edit':'Create'} provider configuration</h2><p className="dim">Credentials are write-only. Leave a field blank to keep it, type a replacement, or explicitly confirm Clear. Empty strings are never submitted.</p>{error&&<div className="form-alert" role="alert">{error}</div>}<div className="form-grid"><label className="field col-8"><span className="field-label">Name</span><input className="input" value={name} onChange={e=>setName(e.target.value)}/></label><label className="field col-4"><span className="field-label">Provider</span><select className="input" value={provider} onChange={e=>setProvider(e.target.value as PhoneConfigRow['provider'])}><option value="twilio">Twilio</option><option value="aws">AWS</option><option value="mojo">Mojo</option></select></label>{canGroups?<label className="field col-12"><span className="field-label">Group scope</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><span className="field-help">Choices are a bounded global directory projection (maximum 100).</span></label>:<div className="field col-12"><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>}<label className="field col-6"><span className="field-label">Twilio from number</span><input className="input" value={twilioFrom} onChange={e=>setTwilioFrom(e.target.value)}/></label><label className="field col-6"><span className="field-label">AWS region</span><input className="input" value={awsRegion} onChange={e=>setAwsRegion(e.target.value)}/></label><label className="field col-6"><span className="field-label">AWS sender id</span><input className="input" value={awsSender} onChange={e=>setAwsSender(e.target.value)}/></label><label className="field col-6"><span className="field-label">Mojo remote URL</span><input className="input" value={mojoUrl} onChange={e=>setMojoUrl(e.target.value)}/></label>{(Object.keys(credentialLabels) as PhoneSecretField[]).map(field=><label className="field col-6" key={field}><span className="field-label">{credentialLabels[field]}</span><div className="phonehub-secret-row"><input ref={node=>{refs.current[field]=node;}} className="input" type="password" autoComplete="new-password" disabled={clearFields.includes(field)}/><button type="button" className={`btn${clearFields.includes(field)?' btn-danger':''}`} onClick={()=>toggleClear(field)}>{clearFields.includes(field)?'Keep instead':'Clear…'}</button></div></label>)}<label className="check col-4"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/><span>Active</span></label><label className="check col-4"><input type="checkbox" checked={testMode} onChange={e=>setTestMode(e.target.checked)}/><span>Test mode</span></label><label className="check col-4"><input type="checkbox" checked={lookup} onChange={e=>setLookup(e.target.checked)}/><span>Lookup enabled</span></label><label className="field col-4"><span className="field-label">Lookup cache days</span><input className="input" type="number" min="1" value={cacheDays} onChange={e=>setCacheDays(Number(e.target.value))}/></label></div><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save configuration'}</button></div></div>;}
278
+ const [testing, setTesting] = useState(false);
279
+ if (!query.data) return <div className="modal-pad">{query.isLoading ? 'Loading provider configuration…' : 'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
280
+ const row = query.data;
281
+ const toggle = async (next: boolean) => {
282
+ if (!canManage || saving) return;
283
+ setSaving(true);
284
+ try { await savePhoneConfigImperative(qc, id, { is_active: next }, {}); await query.refetch(); }
285
+ catch (error) { toast.error(error instanceof Error ? error.message : 'State change failed'); }
286
+ finally { setSaving(false); }
287
+ };
288
+ const meta = providerMeta[row.provider];
289
+ const edit = () => void modal.open((close) => <ConfigEditor row={row} close={async (saved) => { close(saved); if (saved) await query.refetch(); }} />, { size: 'lg' });
290
+ const test = async () => {
291
+ setTesting(true);
292
+ try {
293
+ const result = await testPhoneConfigImperative(id);
294
+ toast[result.status ? 'success' : 'error'](result.message);
295
+ } catch (reason) {
296
+ toast.error(reason instanceof Error ? reason.message : 'Connection test failed');
297
+ } finally {
298
+ setTesting(false);
299
+ }
300
+ };
53
301
 
54
- function ConfigDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PhoneConfigModel.useOne(id);const canManage=useCan(PHONE_CONFIG_MANAGE_PERMISSIONS).can;const canDelete=useCan(PHONE_CONFIG_DELETE_PERMISSIONS).can;const destroy=PhoneConfigModel.useDelete();if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading provider configuration…':'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><ConfigEditor row={row} close={async saved=>{close(saved);if(saved)await query.refetch();}}/>,{size:'lg'});const test=async()=>{try{const result=await testPhoneConfigImperative(id);toast[result.status?'success':'error'](result.message);}catch(reason){toast.error(reason instanceof Error?reason.message:'Connection test failed');}};const remove=async()=>{await destroy.mutateAsync({id});toast.success('Provider configuration deleted');onClose();};return <DetailView title={row.name} subtitle={`${relationLabel(row.group)} · ${row.provider}`} icon="bi-phone" onClose={onClose} sections={[{key:'configuration',label:'Configuration',icon:'bi-sliders',render:()=><div className="detail-section"><div className="storage-action-row">{canManage&&<><button className="btn" onClick={edit}>Edit</button><button className="btn" onClick={()=>void test()}>Test stored connection</button></>}</div><FlatRow label="Provider">{row.provider}</FlatRow><FlatRow label="Active">{row.is_active?'Yes':'No'}</FlatRow><FlatRow label="Test mode">{row.test_mode?'Yes':'No'}</FlatRow><FlatRow label="Lookup cache">{row.lookup_cache_days} days</FlatRow><p className="form-alert">Test mode only short-circuits the connection test; it does not prevent SMS sends. Current sending uses a per-group configuration only for the Mojo provider. Stored Twilio and AWS credentials are testable, but sending still follows global Twilio settings; AWS sending is not wired.</p><p className="dim">An active group config wins, otherwise the first active system default is used. Deleting or deactivating it falls back to the system default. Deleting a group cascades its config; deleting this config does not delete SMS audit rows.</p></div>},...(canDelete?[{key:'delete',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><ArmedButton label="Delete provider configuration" armedLabel="Click again to delete" onConfirm={remove}/></div>}]:[])]}/>;}
302
+ return <DetailView
303
+ title={row.name}
304
+ subtitle={relationLabel(row.group)}
305
+ icon={meta.icon}
306
+ chips={[{ text: meta.label, tone: 'info' }, { text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }, ...(row.test_mode ? [{ text: 'Test mode', tone: 'warning' as const }] : [])]}
307
+ onClose={onClose}
308
+ active={canManage ? { value: row.is_active, disabled: saving, onChange: next => void toggle(next) } : undefined}
309
+ contextMenu={canManage ? [{ label: 'Edit configuration', onSelect: edit }, { label: 'Test stored connection', disabled: testing, onSelect: () => void test() }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: saving, onSelect: () => void toggle(!row.is_active) }] : []}
310
+ sections={[
311
+ { key: 'overview', label: 'Overview', icon: 'bi-sliders', render: () => <div className="detail-section"><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> },
312
+ { key: 'connection', label: 'Connection', icon: 'bi-plug', render: () => <div className="detail-section"><div className="section-eyebrow">{meta.label} settings</div>{providerVisibleRows(row).map(([label, value]) => <FlatRow key={label} label={label}>{value}</FlatRow>)}<FlatRow label="Credentials"><span><i className="bi bi-shield-lock" /> Write-only; edit to replace or clear</span></FlatRow><div className={`phonehub-provider-note${row.provider === 'mojo' ? '' : ' is-warning'}`}><i className={`bi ${row.provider === 'mojo' ? 'bi-info-circle' : 'bi-exclamation-triangle'}`} /><span>{providerOperationalNote(row.provider)}</span></div>{row.test_mode && <div className="phonehub-provider-note is-warning"><i className="bi bi-beaker" /><span>Test mode only short-circuits the connection test. It does not prevent SMS delivery.</span></div>}<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> }
313
+ ]}
314
+ />;
315
+ }
55
316
  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
317
 
57
318
  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
+ }
@@ -3,10 +3,15 @@ import { mojoCall, mojoList } from '../../client/runtime';
3
3
  import { PhoneConfigModel, sanitizePhoneConfigRow, sanitizePhoneNumberRow, type PhoneConfigRow, type PhoneNumberRow, type PhoneRelation } from './models';
4
4
 
5
5
  export const PHONE_GROUP_CHOICE_LIMIT=100;
6
+ export const DEFAULT_MOJO_REMOTE_URL='https://api.mojoverify.com';
6
7
  export const PHONE_SECRET_FIELDS=['twilio_account_sid','twilio_auth_token','aws_access_key_id','aws_secret_access_key','mojo_api_key'] as const;
7
8
  export type PhoneSecretField=(typeof PHONE_SECRET_FIELDS)[number];
8
9
  export type SecretEdit={mode:'untouched'}|{mode:'replace';value:string}|{mode:'clear';confirmed:true};
9
10
 
11
+ export function resolveMojoRemoteUrl(value:unknown):string{
12
+ return typeof value==='string'&&value.trim()?value.trim():DEFAULT_MOJO_REMOTE_URL;
13
+ }
14
+
10
15
  export async function normalizePhoneNumber(phoneNumber:string,countryCode?:string):Promise<string>{
11
16
  const body=await mojoCall('/api/phonehub/number/normalize',{method:'POST',body:{phone_number:phoneNumber,...(countryCode?{country_code:countryCode}:{})}});
12
17
  const value=(body.data as {phone_number?:unknown}|undefined)?.phone_number;
@@ -25,6 +30,7 @@ export async function lookupPhoneNumber(phoneNumber:string,previous?:PhoneNumber
25
30
  }
26
31
  export function buildPhoneConfigPayload(scalars:Record<string,unknown>,credentials:Partial<Record<PhoneSecretField,SecretEdit>>):Record<string,unknown>{
27
32
  const body={...scalars};
33
+ if(body.provider==='mojo')body.mojo_remote_url=resolveMojoRemoteUrl(body.mojo_remote_url);
28
34
  for(const field of PHONE_SECRET_FIELDS){const edit=credentials[field];if(!edit||edit.mode==='untouched')continue;if(edit.mode==='clear')body[field]=null;else{const value=edit.value.trim();if(!value)throw new Error(`${field} replacements cannot be empty.`);body[field]=value;}}
29
35
  return body;
30
36
  }
@@ -1,14 +1,17 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, useEffect, useRef } from 'react';
2
2
  import { useCan } from '../../client/runtime';
3
3
  import { Badge, DetailView, FlatRow, JsonBlock, ModelTable, fmt, modal, toast, type BatchAction, type Column } from '../../ui';
4
4
  import { HandlerChainBuilder } from './HandlerChainBuilder';
5
- import { parseHandlerChain, runtimeEffectiveHandlerChain, validateHandlerChain } from './handler-dsl';
5
+ import { conditionRemovalEffect } from './condition-removal';
6
6
  import { confirmCatchAllEnable, openHandlerChainEditor, openRuleEditor, openRuleSetEditor } from './editors';
7
+ import { parseHandlerChain, runtimeEffectiveHandlerChain, validateHandlerChain } from './handler-dsl';
7
8
  import { BUNDLE_BY_OPTIONS, RULESET_MANAGE_PERMS, RuleModel, RuleSetModel, type RuleRow } from './models';
8
9
 
9
10
  export function RuleSetDetail({ id, onClose }: { id: number; onClose: () => void }) {
10
- const { can: canManage } = useCan(RULESET_MANAGE_PERMS); const query = RuleSetModel.useOne(Number.isFinite(id) ? id : null);
11
- const rules = RuleModel.useList({ parent: id, size: 250, sort: 'index' }); const save = RuleSetModel.useSave(); const destroy = RuleSetModel.useDelete(); const destroyRule = RuleModel.useDelete();
11
+ const { can: canManage } = useCan(RULESET_MANAGE_PERMS);
12
+ const permission = useRef(canManage); permission.current = canManage;
13
+ useEffect(() => { permission.current = canManage; return () => { permission.current = false; }; }, [canManage]); const query = RuleSetModel.useOne(Number.isFinite(id) ? id : null);
14
+ const rules = RuleModel.useList({ parent: id, size: 250, sort: 'index' }); const save = RuleSetModel.useSave(); const destroyRule = RuleModel.useDelete();
12
15
  const duplicateIndexes = useMemo(() => { const counts = new Map<number, number>(); for (const row of rules.data?.rows ?? []) counts.set(row.index, (counts.get(row.index) ?? 0) + 1); return [...counts].filter(([, count]) => count > 1).map(([index]) => index); }, [rules.data]);
13
16
  if (!Number.isFinite(id)) return <div className="modal-pad text-bad">Invalid rule-set id. <button type="button" className="btn" onClick={onClose}>Close</button></div>;
14
17
  if (query.isPending || !query.data) return <div className="modal-pad dim">Loading rule set… <button type="button" className="btn" onClick={onClose}>Close</button></div>;
@@ -19,12 +22,33 @@ export function RuleSetDetail({ id, onClose }: { id: number; onClose: () => void
19
22
  { key: 'comparator', label: 'Comparator', render: (rule) => <code>{rule.comparator}</code> }, { key: 'value', label: 'Value', render: (rule) => <code>{rule.value_type}:{rule.value}</code> },
20
23
  { key: 'is_required', label: 'Required', render: (rule) => rule.is_required ? 'Yes' : 'No' },
21
24
  ];
22
- const ruleActions: BatchAction<RuleRow>[] = canManage ? [{ key: 'delete', label: 'Delete conditions', icon: 'bi-trash', danger: true, confirm: 'Delete the selected conditions? This cannot be undone.', run: (rule) => destroyRule.mutateAsync({ id: rule.id }) }] : [];
25
+ const ruleActions: BatchAction<RuleRow>[] = canManage ? [{
26
+ key: 'delete', label: 'Remove conditions', icon: 'bi-trash', danger: true, confirm: false,
27
+ prepare: async selected => {
28
+ try {
29
+ if (!permission.current) return null;
30
+ const [parent, children] = await Promise.all([query.refetch(), rules.refetch()]);
31
+ if (!parent.data || !children.data || parent.error || children.error) throw new Error('Authoritative rule state unavailable. No conditions removed.');
32
+ const current = selected.map(item => children.data!.rows.find(child => child.id === item.id));
33
+ if (current.some(item => !item)) throw new Error('Selected conditions changed; refresh and select again.');
34
+ const effect = conditionRemovalEffect(parent.data, children.data.count, selected.length);
35
+ const confirmed = await modal.confirm({ title: `Remove ${selected.length} conditions?`, danger: true, confirmText: 'Remove conditions', message: <div><p>Rule set: {parent.data.name} (#{id}) · {parent.data.match_by === 0 ? 'ALL' : 'ANY'}</p><ul>{current.map(item => <li key={item!.id}>{item!.name} (#{item!.id})</li>)}</ul><p>{effect}</p><p>These conditions cannot be restored by this action.</p></div> });
36
+ return confirmed && permission.current ? selected.map(item => item.id) : null;
37
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Condition state unavailable'); return null; }
38
+ },
39
+ runBatch: async selected => {
40
+ if (!permission.current) throw new Error('Permission to remove conditions was revoked.');
41
+ const results = await Promise.allSettled(selected.map(rule => destroyRule.mutateAsync({ id: rule.id })));
42
+ await Promise.all([query.refetch(), rules.refetch()]);
43
+ const failed = results.filter(result => result.status === 'rejected');
44
+ if (failed.length) throw new Error(`${results.length - failed.length} removed; ${failed.length} refused. Authoritative parent and conditions refreshed. ${String((failed[0] as PromiseRejectedResult).reason)}`);
45
+ },
46
+ }] : [];
23
47
  const setActive = async (next: boolean) => { if (next && !(await confirmCatchAllEnable(row, rules.data?.count ?? 0))) return; try { await save.mutateAsync({ id, changes: { is_active: next } }); toast.success(next ? 'Rule set enabled' : 'Rule set disabled'); } catch (error) { toast.error(error instanceof Error ? error.message : 'State change failed'); } };
24
48
  const bundleLabel = BUNDLE_BY_OPTIONS.find((option) => Number(option.value) === row.bundle_by)?.label ?? `Unknown (${row.bundle_by})`;
25
- return <DetailView icon="bi-diagram-3" title={row.name || `Rule set #${row.id}`} subtitle={`${row.category} · priority ${row.priority} · first active match wins`} chips={[{ text: row.is_active ? 'ACTIVE' : 'INACTIVE', tone: row.is_active ? 'success' : 'muted' }, { text: `${rules.data?.count ?? 0} CONDITIONS`, tone: duplicateIndexes.length ? 'warning' : 'info' }]} active={canManage ? { value: row.is_active, onChange: (next) => void setActive(next) } : undefined} onClose={onClose} menuContext={row} contextMenu={canManage ? [{ label: 'Edit rule set', onSelect: () => void openRuleSetEditor(row) }, { label: 'Edit handler chain', onSelect: () => void openHandlerChainEditor(row) }, { divider: true }, { label: 'Delete rule set and conditions', danger: true, onSelect: async () => { if (!(await modal.confirm({ title: 'Delete rule set?', message: `Delete “${row.name}” and all ${rules.data?.count ?? 0} conditions?`, confirmText: 'Delete rule set', danger: true }))) return; await destroy.mutateAsync({ id }); onClose(); } }] : []} sections={[
49
+ return <DetailView icon="bi-diagram-3" title={row.name || `Rule set #${row.id}`} subtitle={`${row.category} · priority ${row.priority} · first active match wins`} chips={[{ text: row.is_active ? 'ACTIVE' : 'INACTIVE', tone: row.is_active ? 'success' : 'muted' }, { text: `${rules.data?.count ?? 0} CONDITIONS`, tone: duplicateIndexes.length ? 'warning' : 'info' }]} active={canManage ? { value: row.is_active, disabled: save.isPending, onChange: (next) => void setActive(next) } : undefined} onClose={onClose} menuContext={row} contextMenu={canManage ? [{ label: 'Edit rule set', onSelect: () => void openRuleSetEditor(row) }, { label: 'Edit handler chain', onSelect: () => void openHandlerChainEditor(row) }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: save.isPending, onSelect: () => void setActive(!row.is_active) }] : []} sections={[
26
50
  { key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-stack"><FlatRow label="State">{row.is_active ? 'Active' : 'Inactive'}</FlatRow><FlatRow label="Category"><code>{row.category}</code></FlatRow><FlatRow label="Priority">{row.priority} (lower is evaluated first within this category)</FlatRow><FlatRow label="Matching">{row.match_by === 0 ? 'All conditions' : row.match_by === 1 ? 'Any condition' : `Unknown (${row.match_by})`}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> },
27
- { key: 'conditions', label: 'Conditions', icon: 'bi-funnel', render: () => <div>{duplicateIndexes.length > 0 && <div className="rule-priority-warning">Duplicate condition indexes {duplicateIndexes.join(', ')} have undefined tie order.</div>}<ModelTable model={RuleModel} title="Conditions" columns={ruleColumns} searchable={false} defaultParams={{ parent: id, sort: 'index' }} onRowClick={canManage ? (rule) => void openRuleEditor(id, rule, nextIndex) : undefined} {...(canManage ? { addLabel: 'Add condition', onAdd: () => void openRuleEditor(id, undefined, nextIndex), selectable: true, batchActions: ruleActions } : {})} /></div> },
51
+ { key: 'conditions', label: 'Conditions', icon: 'bi-funnel', render: () => <div>{duplicateIndexes.length > 0 && <div className="rule-priority-warning">Duplicate condition indexes {duplicateIndexes.join(', ')} have undefined tie order.</div>}<ModelTable model={RuleModel} title="Conditions" columns={ruleColumns} searchable={false} fixedParams={{ parent: id }} defaultParams={{ sort: 'index' }} onRowClick={canManage ? (rule) => void openRuleEditor(id, rule, nextIndex) : undefined} {...(canManage ? { addLabel: 'Add condition', onAdd: () => void openRuleEditor(id, undefined, nextIndex), selectable: true, batchActions: ruleActions } : {})} /></div> },
28
52
  { key: 'triggering', label: 'Triggering', icon: 'bi-lightning', render: () => <div className="detail-stack"><FlatRow label="Bundle by">{bundleLabel}</FlatRow><FlatRow label="Bundle window">{row.bundle_minutes == null ? 'Unbounded' : row.bundle_minutes === 0 ? 'Disabled (no time bundling)' : `${row.bundle_minutes} minutes`}</FlatRow><FlatRow label="Trigger">{row.trigger_count == null ? 'Immediately on first event' : `${row.trigger_count} events${row.trigger_window == null ? '' : ` within ${row.trigger_window} minutes`}`}</FlatRow><FlatRow label="Re-trigger">{row.retrigger_every == null ? 'Once per incident' : `Every ${row.retrigger_every} additional events`}</FlatRow></div> },
29
53
  { key: 'handler', label: 'Handler', icon: 'bi-diagram-3', render: () => <div><div className="handler-section-actions"><p>{runtimeEffectiveHandlerChain(chain).length} runtime-effective dispatch step(s). Publication follows the order below; completion is asynchronous.</p>{canManage && <button type="button" className="btn btn-primary" onClick={() => void openHandlerChainEditor(row)}>Edit chain</button>}</div><HandlerChainBuilder value={row.handler ?? ''} onChange={() => undefined} disabled />{handlerIssues.length > 0 && <p className="dim">Legacy warnings remain visible and byte-preserved until explicitly changed.</p>}</div> },
30
54
  { key: 'agent', label: 'Agent prompt', icon: 'bi-stars', render: () => <div className="detail-stack"><FlatRow label="Prompt">{typeof row.metadata.agent_prompt === 'string' ? row.metadata.agent_prompt : 'No agent prompt configured.'}</FlatRow><p className="dim">The prompt is stored in RuleSet metadata. General edits merge only targeted metadata keys.</p></div> },
@@ -0,0 +1,7 @@
1
+ /** Empty ALL and ANY condition sets both match every event server-side. */
2
+ export function conditionRemovalEffect(parent: { match_by: number; is_active: boolean }, count: number, selected: number): string {
3
+ if (parent.match_by !== 0 && parent.match_by !== 1) throw new Error('Unknown matching mode; refresh and inspect before removing conditions.');
4
+ if (selected < 1 || selected > count) throw new Error('Condition selection changed; refresh before removing conditions.');
5
+ if (count === selected) return `${parent.is_active ? 'This active' : 'This inactive'} rule set becomes catch-all: either matching mode accepts every event without conditions.`;
6
+ return parent.match_by === 0 ? 'ALL mode: removal broadens matching because fewer conditions must hold.' : 'ANY mode: removal narrows matching because fewer alternatives can match.';
7
+ }
@@ -113,6 +113,11 @@ export interface GeoLocatedIPRow {
113
113
  whitelisted_reason: string | null;
114
114
  whitelisted_until: number | null;
115
115
 
116
+ firewall_generation: number;
117
+ firewall_pending: boolean;
118
+ firewall_sync_error: string;
119
+ firewall_observed_at: number | null;
120
+
116
121
  expires_at: number | null;
117
122
 
118
123
  /** `extra` on every graph — computed Python properties. */