emergency: 911 dialplan, notifications, test button, settings, warnings

Dialplan: sp-emergency-dial (911, 9911) first in sp-from-ext;
sp-emergency has no class, channel-limit or DND check, dials every
enabled trunk in name order with the E911 caller ID, and raises
UserEvent SpEmergency. Static sp-emergency-alert (_X! so "0" matches)
for the ring-out.

api: SpEmergency -> emergency_events row, live emergency.call (admins
and notify users only), email via nodemailer (SMTP from settings) and
an Originate to every notify extension, each outcome recorded. Test
button runs the same with TEST in front, records and dials nothing.
GET/PATCH /api/settings (smtp password write-only, references
checked, read-only system keys), SMTP test, /api/system/warnings.
Fix: clearing a nullable setting stored SQL NULL (now JSON null).

UI: Settings page (Email, Emergency with the 06 §6 notice), user
Emergency tab, dashboard warnings banner, red 911 banner.

Verified: generator/emergency.test.ts; trunk-test.ts dials 911 from a
caller with no class to the fake provider (E911 caller ID in RPID,
SpEmergency on AMI, alert plays on baresip 101); scripts/e2e/m3-t4.mjs
(SMTP sink receives the test email, 101 rings, banner shows).
nodemailer pinned to 10.0.11 (newest release over two weeks old).

Task: M3-T4
This commit is contained in:
iamdoubz committed 2026-10-17 20:52:50 -05:00
1 parent 4496f6b89a
commit 409724bc55
45 files changed
+1781 -139

No files matched your search

+35 -1
View File
@@ -29,7 +29,7 @@ export const ADMIN_PAGES = [
{ path: 'reports', label: 'Reports', milestone: 'M11' },
{ path: 'sms', label: 'SMS', milestone: 'M9' },
{ path: 'blocked', label: 'Blocked numbers', milestone: 'M6' },
{ path: 'settings', label: 'Settings', milestone: 'M3' },
{ path: 'settings', label: 'Settings', milestone: null },
{ path: 'system', label: 'System', milestone: 'M11' },
] as const;
@@ -129,6 +129,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
Reconnecting…
</p>
)}
<EmergencyBanner />
<main className="flex-1 p-4 md:p-6">
<Outlet />
</main>
@@ -145,3 +146,36 @@ export function ComingSoon({ label, milestone }: { label: string; milestone: str
</section>
);
}
/** 911 was dialed: impossible to miss, stays until dismissed (04 §5.3). */
function EmergencyBanner() {
const calls = useLive((s) => s.emergencies);
if (!calls.length) return null;
const dismiss = (at: string) =>
useLive.setState((s) => ({ emergencies: s.emergencies.filter((c) => c.at !== at) }));
return (
<div role="alert" className="flex flex-col gap-1 bg-red-600 px-4 py-2 text-sm text-white">
{calls.map((c) => (
<div key={c.at + c.ext} className="flex flex-wrap items-center gap-x-3">
<strong>
{c.test
? `TEST of the 911 alerts (${c.name})`
: `911 dialed from extension ${c.ext || '?'} (${c.name})`}
</strong>
<span>
{new Date(c.at).toLocaleTimeString()}
{c.location ? ` · ${c.location}` : ''}
</span>
<span className="flex-1" />
<button
type="button"
onClick={() => dismiss(c.at)}
className="rounded border border-white/60 px-2 py-0.5 text-xs hover:bg-white/10"
>
Dismiss
</button>
</div>
))}
</div>
);
}
+46 -2
View File
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Telephony link, trunks and extensions online. Calls, queues and the
// warnings banner arrive with their features (M3-T4, M7, M8).
// Dashboard (05 §2). Warnings, telephony link, trunks and extensions online. Calls and queues
// arrive with their features (M7, M8).
import { useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { settingsApi } from '../../api/client.ts';
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
@@ -27,6 +30,11 @@ export function DashboardPage() {
return (
<section className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">Dashboard</h1>
<Warnings
trunkDown={trunkList.some(
(t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'),
)}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Card title="Extensions online">
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
@@ -77,3 +85,39 @@ export function DashboardPage() {
</section>
);
}
function Warnings({ trunkDown }: { trunkDown: boolean }) {
const warnings = useQuery({
queryKey: ['warnings'],
queryFn: settingsApi.warnings,
refetchInterval: 30_000,
});
const items = [
...(trunkDown
? [
{
code: 'trunk_down',
message: 'A trunk is not connected to the provider.',
link: '/admin/trunks',
},
]
: []),
...(warnings.data ?? []),
];
if (!items.length) return null;
return (
<ul
aria-label="Warnings"
className="flex flex-col gap-1 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/50 dark:text-red-100"
>
{items.map((w) => (
<li key={w.code}>
{w.message}{' '}
<Link to={w.link} className="font-medium underline">
Fix
</Link>
</li>
))}
</ul>
);
}
+312
View File
@@ -0,0 +1,312 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Settings (05 §2). M3: Email and Emergency. The other tabs arrive with their features.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { NavLink, useParams } from 'react-router';
import { formatNumber, type SettingsDto } from '@selfphoneng/shared';
import { didsApi, extensionsApi, settingsApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
const TABS = [
{ id: 'email', label: 'Email' },
{ id: 'emergency', label: 'Emergency' },
] as const;
export function SettingsPage() {
const { tab = 'email' } = useParams();
const settings = useQuery({ queryKey: ['settings'], queryFn: settingsApi.get });
return (
<section className="flex max-w-2xl flex-col gap-4">
<h1 className="text-xl font-semibold">Settings</h1>
<nav
aria-label="Settings"
className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800"
>
{TABS.map((t) => (
<NavLink
key={t.id}
to={`/admin/settings/${t.id}`}
className={({ isActive }) =>
`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium ${isActive || (t.id === 'email' && tab === 'email') ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`
}
>
{t.label}
</NavLink>
))}
</nav>
{settings.isError && <Alert>{settings.error.message}</Alert>}
{settings.data && tab === 'email' && <EmailTab s={settings.data} />}
{settings.data && tab === 'emergency' && <EmergencyTab s={settings.data} />}
</section>
);
}
function useSave() {
const qc = useQueryClient();
return useMutation({
mutationFn: settingsApi.patch,
onSuccess: (s) => {
qc.setQueryData(['settings'], s);
void qc.invalidateQueries({ queryKey: ['warnings'] });
},
});
}
function EmailTab({ s }: { s: SettingsDto }) {
const save = useSave();
const [v, setV] = useState({
host: s.smtp?.host ?? '',
port: String(s.smtp?.port ?? 587),
secure: s.smtp?.secure ?? false,
username: s.smtp?.username ?? '',
password: '',
from: s.smtp?.from ?? '',
});
const [to, setTo] = useState('');
const test = useMutation({ mutationFn: () => settingsApi.testEmail(to) });
const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
setV({ ...v, [k]: e.target.value });
return (
<form
className="flex flex-col gap-4"
noValidate
onSubmit={(e) => {
e.preventDefault();
save.mutate({
smtp: v.host ? { ...v, port: Number(v.port), password: v.password || undefined } : null,
});
}}
>
<p className="text-sm text-zinc-500">
The mail server used for emergency alerts and voicemail emails. Leave the server empty to
turn email off.
</p>
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<div className="grid grid-cols-[1fr_6rem] gap-3">
<Field label="SMTP server">
{(a) => (
<input
{...a}
value={v.host}
onChange={set('host')}
placeholder="smtp.example.com"
className={inputClass}
/>
)}
</Field>
<Field label="Port">
{(a) => (
<input
{...a}
value={v.port}
onChange={set('port')}
inputMode="numeric"
className={inputClass}
/>
)}
</Field>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={v.secure}
onChange={(e) =>
setV({ ...v, secure: e.target.checked, port: e.target.checked ? '465' : '587' })
}
className="size-4"
/>
TLS from the start (port 465). Off: STARTTLS (port 587).
</label>
<Field label="Username">
{(a) => (
<input
{...a}
value={v.username}
onChange={set('username')}
autoComplete="off"
className={inputClass}
/>
)}
</Field>
<Field
label="Password"
hint={s.smtp?.hasPassword ? 'Leave empty to keep the current password.' : undefined}
>
{(a) => (
<input
{...a}
type="password"
value={v.password}
onChange={set('password')}
autoComplete="new-password"
className={inputClass}
/>
)}
</Field>
<Field label="From" hint="For example: Phone system <pbx@example.com>">
{(a) => <input {...a} value={v.from} onChange={set('from')} className={inputClass} />}
</Field>
<div>
<Button variant="primary" type="submit" disabled={save.isPending}>
Save
</Button>
</div>
{s.smtp && (
<div className="flex flex-col gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="flex items-end gap-2">
<Field label="Send a test email to">
{(a) => (
<input
{...a}
type="email"
value={to}
onChange={(e) => setTo(e.target.value)}
className={inputClass}
/>
)}
</Field>
<Button onClick={() => test.mutate()} disabled={!to || test.isPending}>
{test.isPending ? 'Sending…' : 'Send test'}
</Button>
</div>
{test.isError && <Alert>{test.error.message}</Alert>}
{test.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Sent. Check the inbox (and the spam folder).
</p>
)}
</div>
)}
</form>
);
}
function EmergencyTab({ s }: { s: SettingsDto }) {
const save = useSave();
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
const [cid, setCid] = useState(s['emergency.defaultCallerIdDidId'] ?? '');
const [emails, setEmails] = useState(s['emergency.notifyEmails'].join(', '));
const [notify, setNotify] = useState<string[]>(s['emergency.notifyExtensionIds']);
const test = useMutation({ mutationFn: settingsApi.testEmergency });
return (
<form
className="flex flex-col gap-4"
noValidate
onSubmit={(e) => {
e.preventDefault();
save.mutate({
'emergency.defaultCallerIdDidId': cid || null,
'emergency.notifyEmails': emails
.split(/[,\s]+/)
.map((x) => x.trim())
.filter(Boolean),
'emergency.notifyExtensionIds': notify,
});
}}
>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
<p>
Every phone can always dial <strong>911</strong> (and 9911), whatever its calling
permission. Emergency services get the street address registered for the caller ID number{' '}
<strong>at voip.ms</strong> (DID → e911), not anything typed here. Register an address for
every number used as an emergency caller ID. People working elsewhere need their own
number with their own address, or should call 911 from a mobile phone.
</p>
<p className="mt-2">
US law (Kari&apos;s Law, RAY BAUM&apos;S Act) also asks that someone is notified when 911
is dialed. This is a summary, not legal advice: check with counsel.
</p>
</div>
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<Field
label="Default emergency caller ID"
hint="Used when an extension has no emergency caller ID of its own."
>
{(a) => (
<select
{...a}
value={cid}
onChange={(e) => setCid(e.target.value)}
className={inputClass}
>
<option value="">Not set</option>
{dids.data?.map((d) => (
<option key={d.id} value={d.id}>
{formatNumber(d.number)}
{d.label ? ` (${d.label})` : ''}
{d.e911Address ? '' : ' — E911 address not confirmed'}
</option>
))}
</select>
)}
</Field>
<Field label="Email these people" hint="Separate addresses with commas.">
{(a) => (
<input
{...a}
value={emails}
onChange={(e) => setEmails(e.target.value)}
placeholder="frontdesk@example.com"
className={inputClass}
/>
)}
</Field>
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-sm font-medium">Ring these phones</legend>
{exts.data?.length === 0 && <p className="text-sm text-zinc-500">No extensions yet.</p>}
{exts.data?.map((x) => (
<label key={x.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4"
checked={notify.includes(x.id)}
onChange={(e) =>
setNotify(e.target.checked ? [...notify, x.id] : notify.filter((n) => n !== x.id))
}
/>
{x.number} {x.name}
</label>
))}
</fieldset>
<div className="flex flex-wrap gap-2">
<Button variant="primary" type="submit" disabled={save.isPending}>
Save
</Button>
<Button onClick={() => test.mutate()} disabled={test.isPending}>
{test.isPending ? 'Testing…' : 'Test notifications'}
</Button>
</div>
<p className="text-xs text-zinc-500">
The test sends the email and rings the phones above with &quot;TEST&quot; in front. It never
dials 911. Never test by calling 911.
</p>
{test.isError && <Alert>{test.error.message}</Alert>}
{test.data && (
<ul role="status" className="text-sm">
{test.data.notified.length === 0 && (
<li>Nobody to notify yet: add an email or a phone.</li>
)}
{test.data.notified.map((n) => (
<li key={n.kind + n.to} className={n.ok ? '' : 'text-red-600 dark:text-red-400'}>
{n.kind === 'email' ? 'Email to' : 'Rang'} {n.to}: {n.ok ? 'ok' : n.error}
</li>
))}
</ul>
)}
</form>
);
}
+57 -3
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// User + extension editor (05 §2). Tabs so far: General, Phone, Calling, Credentials.
// Voicemail (M4), Emergency (M3-T4) and Forwarding (M6) arrive with their features.
// User + extension editor (05 §2). Tabs so far: General, Phone, Calling, Emergency, Credentials.
// Emergency too. Voicemail (M4) and Forwarding (M6) arrive with their features.
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
@@ -21,7 +21,7 @@ import { CLASS_LABEL } from './OutboundPage.tsx';
import { Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
type Tab = 'general' | 'phone' | 'calling' | 'credentials';
type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials';
const GeneralSchema = z.object({
displayName: z.string().trim().min(1, 'Required').max(100),
@@ -55,6 +55,7 @@ export function UserDrawer({
{ id: 'general', label: 'General', enabled: true },
{ id: 'phone', label: 'Phone', enabled: Boolean(ext.data) },
{ id: 'calling', label: 'Calling', enabled: Boolean(ext.data) },
{ id: 'emergency', label: 'Emergency', enabled: Boolean(ext.data) },
{ id: 'credentials', label: 'Credentials', enabled: Boolean(ext.data) },
];
@@ -106,6 +107,7 @@ export function UserDrawer({
)}
{tab === 'phone' && ext.data && <PhoneTab ext={ext.data} onSaved={() => onSaved()} />}
{tab === 'calling' && ext.data && <CallingTab ext={ext.data} onSaved={() => onSaved()} />}
{tab === 'emergency' && ext.data && <EmergencyTab ext={ext.data} onSaved={() => onSaved()} />}
{tab === 'credentials' && ext.data && <CredentialsTab ext={ext.data} />}
</Dialog>
);
@@ -496,3 +498,55 @@ function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }
</form>
);
}
function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) {
const qc = useQueryClient();
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
const [error, setError] = useState<string>();
const form = useForm({
defaultValues: {
emergencyCallerIdDidId: ext.emergencyCallerIdDidId ?? '',
emergencyLocation: ext.emergencyLocation ?? '',
},
});
const onSubmit = form.handleSubmit(async (v) => {
setError(undefined);
try {
await extensionsApi.update(ext.id, {
emergencyCallerIdDidId: v.emergencyCallerIdDidId || null,
emergencyLocation: v.emergencyLocation.trim() || null,
});
await qc.invalidateQueries({ queryKey: ['extension', ext.id] });
onSaved();
} catch (e) {
setError((e as Error).message);
}
});
return (
<form id="user-emergency-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field
label="Emergency caller ID"
hint="A number whose E911 address at voip.ms is where this person works. Default: the company emergency caller ID."
>
{(a) => (
<select {...a} {...form.register('emergencyCallerIdDidId')} className={inputClass}>
<option value="">Company default</option>
{dids.data?.map((d) => (
<option key={d.id} value={d.id}>
{formatNumber(d.number)}
{d.label ? ` (${d.label})` : ''}
</option>
))}
</select>
)}
</Field>
<Field
label="Location"
hint="For example: 2nd floor, room 210. Goes into the alert email; 911 can't receive it."
>
{(a) => <input {...a} {...form.register('emergencyLocation')} className={inputClass} />}
</Field>
</form>
);
}