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:
1 parent
4496f6b89a
commit
409724bc55
45 files changed
+1781
-139
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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's Law, RAY BAUM'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 "TEST" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user