setup: first-run wizard, telephony restart, system status
ci / check (push) Canceled after 0s
ci / images (push) Canceled after 0s

/setup walks a fresh install to working calls: password (done by the
forced change), company, network (detect public IP, LAN IP from
/api/system/status, router checklist; restarts telephony because the
transports changed), email (skippable), voip.ms trunk (Next unlocks on
Registered), phone numbers, users (extension auto-numbered, temporary
passwords shown once), main number routing, emergency (default E911
caller ID required once a trunk exists), done. Steps 7-8 ship now
because the acceptance needs a user and a routed number; their M6
choices (everyone, IVR) come later. Admins land on /setup until it is
finished or skipped.

api: /api/setup/status and /complete, POST
/api/settings/network/detect-public-ip (ipify, then ifconfig.me),
GET /api/system/status (M3 subset), POST /api/system/asterisk/restart
(an AMI disconnect during a restart counts as success).
restartRequired clears on AMI connect once Asterisk's uptime shows it
started after the change. The first phone number becomes the default
outbound caller ID when none is set (found by the acceptance run:
calls otherwise left with the provider's caller ID).

Verified: scripts/e2e/m3-t5.mjs on a fresh database against the fake
voip.ms: wizard end to end, then an inbound call to the DID rings the
new user's softphone and an outbound call leaves as 13125550000 with
the DID in Remote-Party-ID. m1-t4 now passes through "Skip setup".

Task: M3-T5
This commit is contained in:
iamdoubz committed 2026-10-17 21:11:04 -05:00
1 parent 409724bc55
commit c2b9fff126
19 files changed
+1455 -210

No files matched your search

+14
View File
@@ -127,3 +127,17 @@ export const settingsApi = {
),
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
};
export const systemApi = {
status: () =>
api<{ ami: { connected: boolean }; restartRequired: boolean; lanIps: string[] }>(
'GET',
'/api/system/status',
),
restartAsterisk: (now: boolean) =>
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
detectPublicIp: () =>
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
completeSetup: () => api<{ ok: true }>('POST', '/api/setup/complete'),
};
+2
View File
@@ -15,11 +15,13 @@ import { NumbersPage } from './pages/admin/NumbersPage.tsx';
import { OutboundPage } from './pages/admin/OutboundPage.tsx';
import { SettingsPage } from './pages/admin/SettingsPage.tsx';
import { MePage } from './pages/user/MePage.tsx';
import { SetupPage } from './pages/SetupPage.tsx';
import './index.css';
const router = createBrowserRouter([
{ path: '/login', Component: LoginPage },
{ path: '/change-password', Component: ChangePasswordPage },
{ path: '/setup', element: <RequireAuth role="admin">{() => <SetupPage />}</RequireAuth> },
{
path: '/admin',
element: <RequireAuth role="admin">{(user) => <AdminLayout user={user} />}</RequireAuth>,
+692
View File
@@ -0,0 +1,692 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// First-run setup wizard (05 §2.1). Every step saves before moving on; some can be skipped.
// M3 also brings steps 7 (users) and 8 (main number routing) because "calls work using only the
// wizard" needs them; step 8's ring group and IVR choices arrive with M6.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { formatNumber, type Destination, type TrunkDto } from '@selfphoneng/shared';
import {
didsApi,
extensionsApi,
settingsApi,
systemApi,
trunksApi,
usersApi,
} from '../api/client.ts';
import { DestinationPicker } from '../components/DestinationPicker.tsx';
import { ThemeToggle } from '../components/ThemeToggle.tsx';
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
import { subscribeLive, useLive } from '../live/store.ts';
import { EmailTab, EmergencyTab } from './admin/SettingsPage.tsx';
import { TrunkForm, trunkStatus } from './admin/TrunksPage.tsx';
const STEPS = [
'Password',
'Company',
'Network',
'Email',
'Trunk',
'Phone numbers',
'Users',
'Main number',
'Emergency',
'Done',
] as const;
export function SetupPage() {
const [params, setParams] = useSearchParams();
const step = Math.min(Math.max(Number(params.get('step') ?? 1), 1), STEPS.length);
const go = (n: number) => setParams({ step: String(n) });
const next = () => go(step + 1);
const qc = useQueryClient();
const nav = useNavigate();
useEffect(subscribeLive, []);
const finish = useMutation({
mutationFn: systemApi.completeSetup,
onSuccess: async () => {
qc.setQueryData(['setup'], { completed: true }); // the admin layout reads it on mount
nav('/admin', { replace: true });
},
});
return (
<div className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-6 p-4 md:p-8">
<header className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Set up your phone system</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<Button variant="ghost" onClick={() => finish.mutate()}>
Skip setup
</Button>
</div>
</header>
<ol aria-label="Steps" className="flex flex-wrap gap-1 text-xs">
{STEPS.map((s, i) => (
<li key={s}>
<button
type="button"
onClick={() => go(i + 1)}
aria-current={step === i + 1 ? 'step' : undefined}
className={`rounded-full px-2 py-1 ${step === i + 1 ? 'bg-sky-600 text-white' : 'bg-zinc-100 dark:bg-zinc-800'}`}
>
{i + 1}. {s}
</button>
</li>
))}
</ol>
<section aria-labelledby="step-title" className="flex flex-col gap-4">
<h2 id="step-title" className="text-lg font-medium">
{step}. {STEPS[step - 1]}
</h2>
{step === 1 && <PasswordStep next={next} />}
{step === 2 && <CompanyStep next={next} />}
{step === 3 && <NetworkStep next={next} />}
{step === 4 && <SettingsStep tab="email" next={next} skippable />}
{step === 5 && <TrunkStep next={next} />}
{step === 6 && <NumbersStep next={next} />}
{step === 7 && <UsersStep next={next} />}
{step === 8 && <RoutingStep next={next} />}
{step === 9 && <SettingsStep tab="emergency" next={next} />}
{step === 10 && <DoneStep onFinish={() => finish.mutate()} busy={finish.isPending} />}
</section>
</div>
);
}
function Nav({
next,
canNext = true,
skip,
hint,
}: {
next: () => void;
canNext?: boolean;
skip?: string;
hint?: string;
}) {
return (
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
<Button variant="primary" onClick={next} disabled={!canNext}>
Next
</Button>
{skip && (
<Button variant="ghost" onClick={next}>
{skip}
</Button>
)}
{hint && <span className="text-sm text-zinc-500">{hint}</span>}
</div>
);
}
function PasswordStep({ next }: { next: () => void }) {
// Signing in with the initial password already forced a change (RequireAuth).
return (
<>
<p className="text-sm">✓ The initial admin password has been changed.</p>
<Nav next={next} />
</>
);
}
function useSettings() {
return useQuery({ queryKey: ['settings'], queryFn: settingsApi.get });
}
function CompanyStep({ next }: { next: () => void }) {
const s = useSettings();
if (!s.data) return <Loading q={s} />;
return <CompanyForm initial={s.data} next={next} />;
}
function CompanyForm({
initial,
next,
}: {
initial: { 'general.companyName': string; 'general.timezone': string };
next: () => void;
}) {
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const [name, setName] = useState(initial['general.companyName']);
const [tz, setTz] = useState(
initial['general.companyName'] === 'My Company' ? browserTz : initial['general.timezone'],
);
const save = useMutation({ mutationFn: settingsApi.patch, onSuccess: next });
const zones = Intl.supportedValuesOf('timeZone');
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate({
'general.companyName': name,
'general.timezone': tz,
'general.language': 'en',
});
}}
>
{save.isError && <Alert>{save.error.message}</Alert>}
<Field label="Company name">
{(a) => (
<input
{...a}
value={name}
onChange={(e) => setName(e.target.value)}
className={inputClass}
/>
)}
</Field>
<Field label="Time zone" hint="Office hours and call history use it.">
{(a) => (
<select {...a} value={tz} onChange={(e) => setTz(e.target.value)} className={inputClass}>
{zones.map((z) => (
<option key={z}>{z}</option>
))}
</select>
)}
</Field>
<Field label="Language" hint="More languages later.">
{(a) => (
<select {...a} disabled className={inputClass}>
<option>English</option>
</select>
)}
</Field>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<Button variant="primary" type="submit" disabled={save.isPending || !name.trim()}>
Save and continue
</Button>
</div>
</form>
);
}
function NetworkStep({ next }: { next: () => void }) {
const s = useSettings();
const status = useQuery({
queryKey: ['system-status'],
queryFn: systemApi.status,
});
if (!s.data || !status.data) return <Loading q={s} />;
return (
<NetworkForm
next={next}
publicIp={s.data['network.publicIp'] ?? ''}
lanIp={s.data['network.lanIp'] ?? status.data.lanIps[0] ?? ''}
lanIps={status.data.lanIps}
localNets={s.data['network.localNets'].join(', ')}
/>
);
}
function NetworkForm(p: {
next: () => void;
publicIp: string;
lanIp: string;
lanIps: string[];
localNets: string;
}) {
const [publicIp, setPublicIp] = useState(p.publicIp);
const [lanIp, setLanIp] = useState(p.lanIp);
const [nets, setNets] = useState(p.localNets);
const [done, setDone] = useState(false);
const [note, setNote] = useState<string>();
const detect = useMutation({
mutationFn: systemApi.detectPublicIp,
onSuccess: setPublicIp,
});
const save = useMutation({
mutationFn: async () => {
await settingsApi.patch({
'network.publicIp': publicIp.trim() || null,
'network.lanIp': lanIp.trim() || null,
'network.localNets': nets.split(/[,\s]+/).filter(Boolean),
});
// New addresses change the SIP transports, which only load on a restart (01 §4.3).
// A fresh install has no calls to drop, so restart right away.
await new Promise((r) => setTimeout(r, 2000)); // let the debounced apply run
if ((await systemApi.status()).restartRequired) {
await systemApi.restartAsterisk(true);
setNote('Telephony restarted to use the new addresses.');
}
},
onSuccess: p.next,
});
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
{(save.isError || detect.isError) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
{note && (
<p role="status" className="text-sm">
{note}
</p>
)}
<div className="flex items-end gap-2">
<Field
label="Public IP address"
hint="Your internet address, so outside phones and voip.ms can reach this server."
>
{(a) => (
<input
{...a}
value={publicIp}
onChange={(e) => setPublicIp(e.target.value)}
className={inputClass}
/>
)}
</Field>
<Button onClick={() => detect.mutate()} disabled={detect.isPending}>
{detect.isPending ? 'Detecting…' : 'Detect public IP'}
</Button>
</div>
<Field label="LAN IP address" hint="This server's address on your office network.">
{(a) => (
<>
<input
{...a}
list="lan-ips"
value={lanIp}
onChange={(e) => setLanIp(e.target.value)}
className={inputClass}
/>
<datalist id="lan-ips">
{p.lanIps.map((ip) => (
<option key={ip} value={ip} />
))}
</datalist>
</>
)}
</Field>
<Field
label="Local networks"
hint="Networks that reach this server without NAT, separated by commas."
>
{(a) => (
<input
{...a}
value={nets}
onChange={(e) => setNets(e.target.value)}
className={inputClass}
/>
)}
</Field>
<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 className="font-medium">On your router:</p>
<ol className="mt-1 list-decimal pl-5">
<li>
Turn off <strong>SIP ALG</strong> (sometimes called SIP helper). It rewrites calls and
breaks them.
</li>
<li>
Forward <strong>UDP 5060</strong> and <strong>UDP 10000–20000</strong> to this server (
{lanIp || 'its LAN IP'}). Behind two routers, do it on both.
</li>
</ol>
<label className="mt-2 flex items-center gap-2">
<input
type="checkbox"
checked={done}
onChange={(e) => setDone(e.target.checked)}
className="size-4"
/>
I&apos;ve done this
</label>
</div>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<Button variant="primary" type="submit" disabled={save.isPending || !done}>
{save.isPending ? 'Saving…' : 'Save and continue'}
</Button>
</div>
</form>
);
}
function SettingsStep({
tab,
next,
skippable,
}: {
tab: 'email' | 'emergency';
next: () => void;
skippable?: boolean;
}) {
const s = useSettings();
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
if (!s.data) return <Loading q={s} />;
// 06 §6: once a trunk exists, an emergency caller ID is required.
const needCid =
tab === 'emergency' &&
Boolean(trunks.data?.length) &&
!s.data['emergency.defaultCallerIdDidId'];
return (
<>
{tab === 'email' ? <EmailTab s={s.data} /> : <EmergencyTab s={s.data} />}
<Nav
next={next}
canNext={!needCid}
skip={skippable ? 'Skip for now' : undefined}
hint={needCid ? 'Pick and save a default emergency caller ID first.' : undefined}
/>
</>
);
}
function TrunkStep({ next }: { next: () => void }) {
const qc = useQueryClient();
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
const [trunk, setTrunk] = useState<TrunkDto | null>(null);
const t = trunk ?? trunks.data?.[0] ?? null;
const live = useLive((s) => (t ? s.trunks[t.id] : undefined));
if (!trunks.data) return <Loading q={trunks} />;
const status = t ? trunkStatus(t, live) : null;
const ok = status?.label === 'Registered' || status?.label === 'Reachable';
return (
<>
<p className="text-sm text-zinc-500">
Connect your voip.ms sub-account. Follow the checklist, save, and wait for{' '}
<strong>Registered</strong>.
</p>
<TrunkForm
trunk={t}
onSaved={(saved) => {
setTrunk(saved);
void qc.invalidateQueries({ queryKey: ['trunks'] });
}}
/>
<div>
<Button variant="primary" type="submit" form="trunk-form">
{t ? 'Save trunk' : 'Create trunk'}
</Button>
</div>
<Nav
next={next}
canNext={ok}
skip="I'll add it later"
hint={
t && !ok ? `Status: ${status?.label}. Next unlocks when it is Registered.` : undefined
}
/>
</>
);
}
function NumbersStep({ next }: { next: () => void }) {
const qc = useQueryClient();
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
const [number, setNumber] = useState('');
const [label, setLabel] = useState('Main line');
const add = useMutation({
mutationFn: () =>
didsApi.create({
number,
label: label || null,
trunkId: trunks.data![0]!.id,
destination: { type: 'hangup' },
}),
onSuccess: () => {
setNumber('');
setLabel('');
void qc.invalidateQueries({ queryKey: ['dids'] });
},
});
if (!trunks.data || !dids.data) return <Loading q={dids} />;
if (!trunks.data.length)
return (
<>
<p className="text-sm">Numbers arrive through a trunk: add one first (step 5).</p>
<Nav next={next} skip="Skip" canNext={false} />
</>
);
return (
<>
<p className="text-sm text-zinc-500">
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 8.
</p>
{dids.data.length > 0 && (
<ul className="text-sm">
{dids.data.map((d) => (
<li key={d.id}>
✓ {formatNumber(d.number)}{' '}
{d.label && <span className="text-zinc-500">{d.label}</span>}
</li>
))}
</ul>
)}
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
add.mutate();
}}
>
<Field label="Phone number">
{(a) => (
<input
{...a}
value={number}
onChange={(e) => setNumber(e.target.value)}
inputMode="tel"
placeholder="(312) 555-1234"
className={inputClass}
/>
)}
</Field>
<Field label="Label">
{(a) => (
<input
{...a}
value={label}
onChange={(e) => setLabel(e.target.value)}
className={inputClass}
/>
)}
</Field>
<Button type="submit" disabled={!number || add.isPending}>
Add number
</Button>
</form>
{add.isError && <Alert>{add.error.message}</Alert>}
<Nav next={next} canNext={dids.data.length > 0} skip="Skip for now" />
</>
);
}
type NewUser = { name: string; email: string };
function UsersStep({ next }: { next: () => void }) {
const qc = useQueryClient();
const users = useQuery({ queryKey: ['users'], queryFn: usersApi.list });
const [rows, setRows] = useState<NewUser[]>([{ name: '', email: '' }]);
const [created, setCreated] = useState<
{ name: string; email: string; number: string; password: string }[]
>([]);
const save = useMutation({
mutationFn: async () => {
const out: typeof created = [];
for (const r of rows.filter((x) => x.name.trim() && x.email.trim())) {
const { user, temporaryPassword } = await usersApi.create({
displayName: r.name.trim(),
email: r.email.trim(),
role: 'user',
});
const number = await extensionsApi.nextNumber('user');
if (number) await extensionsApi.create({ number, name: r.name.trim(), userId: user.id });
out.push({
name: r.name,
email: r.email,
number: number ?? '–',
password: temporaryPassword,
});
setCreated([...out]);
}
return out;
},
onSettled: () => {
setRows([{ name: '', email: '' }]);
void qc.invalidateQueries({ queryKey: ['users'] });
void qc.invalidateQueries({ queryKey: ['extensions'] });
},
});
const set = (i: number, k: keyof NewUser, v: string) =>
setRows(rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const withExt = users.data?.filter((u) => u.extension) ?? [];
return (
<>
<p className="text-sm text-zinc-500">
Add the people who get a phone (up to 10 here; more later under Users &amp; Extensions).
Each gets the next free extension and a temporary password, shown once.
</p>
{withExt.length > 0 && (
<ul className="text-sm">
{withExt.map((u) => (
<li key={u.id}>
✓ {u.extension!.number} {u.displayName}
</li>
))}
</ul>
)}
<form
className="flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
{rows.map((r, i) => (
<div key={i} className="grid grid-cols-2 gap-2">
<input
aria-label={`Name ${i + 1}`}
placeholder="Name"
value={r.name}
onChange={(e) => set(i, 'name', e.target.value)}
className={inputClass}
/>
<input
aria-label={`Email ${i + 1}`}
placeholder="Email"
type="email"
value={r.email}
onChange={(e) => set(i, 'email', e.target.value)}
className={inputClass}
/>
</div>
))}
<div className="flex gap-2">
{rows.length < 10 && (
<Button onClick={() => setRows([...rows, { name: '', email: '' }])}>
Another person
</Button>
)}
<Button variant="primary" type="submit" disabled={save.isPending}>
Add users
</Button>
</div>
</form>
{save.isError && <Alert>{save.error.message}</Alert>}
{created.length > 0 && (
<div className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<p className="mb-2 font-medium">
Give each person their temporary password (shown only now):
</p>
<table className="w-full">
<tbody>
{created.map((c) => (
<tr key={c.email}>
<td>{c.number}</td>
<td>{c.name}</td>
<td className="text-zinc-500">{c.email}</td>
<td className="font-mono">{c.password}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Nav next={next} canNext={withExt.length > 0} skip="Skip for now" />
</>
);
}
function RoutingStep({ next }: { next: () => void }) {
const qc = useQueryClient();
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
const [dest, setDest] = useState<Record<string, Destination | null>>({});
const save = useMutation({
mutationFn: async () => {
for (const [id, d] of Object.entries(dest))
if (d) await didsApi.update(id, { destination: d });
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['dids'] });
next();
},
});
if (!dids.data) return <Loading q={dids} />;
if (!dids.data.length)
return (
<>
<p className="text-sm">No phone numbers yet (step 6).</p>
<Nav next={next} />
</>
);
const incomplete = Object.values(dest).some((d) => d === null);
return (
<>
{dids.data.map((d) => (
<DestinationPicker
key={d.id}
label={`When someone calls ${formatNumber(d.number)}${d.label ? ` (${d.label})` : ''}, ring:`}
value={dest[d.id] ?? d.destination}
onChange={(v) => setDest({ ...dest, [d.id]: v })}
/>
))}
{save.isError && <Alert>{save.error.message}</Alert>}
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<Button
variant="primary"
onClick={() => save.mutate()}
disabled={incomplete || save.isPending}
>
Save and continue
</Button>
</div>
</>
);
}
function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
return (
<>
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm">
<li>
Set up each desk phone or softphone app with the SIP details under Users &amp; Extensions
→ Credentials.
</li>
<li>Call your main number from a mobile phone: the phone you chose in step 8 rings.</li>
<li>From a desk phone, call your mobile: it shows your main number as caller ID.</li>
<li>Everything here can be changed later in the admin pages.</li>
</ul>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<Button variant="primary" onClick={onFinish} disabled={busy}>
Finish
</Button>
</div>
</>
);
}
function Loading({ q }: { q: { isError: boolean; error: Error | null } }): ReactNode {
return q.isError ? (
<Alert>{q.error!.message}</Alert>
) : (
<p className="text-sm text-zinc-500">Loading…</p>
);
}
+7 -3
View File
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
import { useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router';
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
import { APP_NAME, type AuthUser } from '@selfphoneng/shared';
import { authApi } from '../../api/client.ts';
import { authApi, systemApi } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
@@ -39,6 +39,8 @@ export function AdminLayout({ user }: { user: AuthUser }) {
useEffect(subscribeLive, []);
const qc = useQueryClient();
const nav = useNavigate();
// The first-run wizard comes first until it's finished or skipped (05 §2.1).
const setup = useQuery({ queryKey: ['setup'], queryFn: systemApi.setupStatus });
async function logout() {
await authApi.logout().catch(() => undefined);
@@ -46,6 +48,8 @@ export function AdminLayout({ user }: { user: AuthUser }) {
nav('/login', { replace: true });
}
if (setup.data && !setup.data.completed) return <Navigate to="/setup" replace />;
const links = (
<nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4">
<ul className="flex flex-col gap-0.5">
+2 -2
View File
@@ -52,7 +52,7 @@ function useSave() {
});
}
function EmailTab({ s }: { s: SettingsDto }) {
export function EmailTab({ s }: { s: SettingsDto }) {
const save = useSave();
const [v, setV] = useState({
host: s.smtp?.host ?? '',
@@ -188,7 +188,7 @@ function EmailTab({ s }: { s: SettingsDto }) {
);
}
function EmergencyTab({ s }: { s: SettingsDto }) {
export function EmergencyTab({ s }: { s: SettingsDto }) {
const save = useSave();
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
+207 -196
View File
@@ -154,6 +154,34 @@ function TrunkDrawer({
trunk: TrunkDto | null;
onClose: () => void;
onSaved: (t: TrunkDto) => void;
}) {
return (
<Dialog
open
drawer
onClose={onClose}
title={trunk ? `Edit ${trunk.name}` : 'New trunk'}
footer={
<>
<Button onClick={onClose}>Close</Button>
<Button variant="primary" type="submit" form="trunk-form">
{trunk ? 'Save' : 'Create trunk'}
</Button>
</>
}
>
<TrunkForm trunk={trunk} onSaved={onSaved} />
</Dialog>
);
}
/** The trunk form (id "trunk-form"); the caller renders the submit button. Also used by /setup. */
export function TrunkForm({
trunk,
onSaved,
}: {
trunk: TrunkDto | null;
onSaved: (t: TrunkDto) => void;
}) {
const [error, setError] = useState<string>();
const [saved, setSaved] = useState(false);
@@ -215,233 +243,216 @@ function TrunkDrawer({
});
return (
<Dialog
open
drawer
onClose={onClose}
title={trunk ? `Edit ${trunk.name}` : 'New trunk'}
footer={
<>
<Button onClick={onClose}>{saved ? 'Close' : 'Cancel'}</Button>
<Button variant="primary" type="submit" form="trunk-form">
{trunk ? 'Save' : 'Create trunk'}
</Button>
</>
}
>
<form id="trunk-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
{saved && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved. The phone system picks it up within a few seconds.
</p>
<form id="trunk-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
{saved && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved. The phone system picks it up within a few seconds.
</p>
)}
{trunk && <TestPanel trunk={trunk} />}
<Field label="Provider">
{(a) => (
<select {...a} {...form.register('provider')} className={inputClass}>
<option value="voipms">voip.ms</option>
<option value="generic">Other SIP provider</option>
</select>
)}
{trunk && <TestPanel trunk={trunk} />}
<Field label="Provider">
{(a) => (
<select {...a} {...form.register('provider')} className={inputClass}>
<option value="voipms">voip.ms</option>
<option value="generic">Other SIP provider</option>
</select>
)}
</Field>
{voipms && <VoipmsChecklist />}
<Field label="Name" error={errors.name?.message}>
{(a) => <input {...a} {...form.register('name')} className={inputClass} />}
</Field>
<Field
label="Server"
error={errors.serverHost?.message}
hint={voipms ? 'The voip.ms POP your sub-account and numbers use.' : undefined}
>
{(a) => (
<>
</Field>
{voipms && <VoipmsChecklist />}
<Field label="Name" error={errors.name?.message}>
{(a) => <input {...a} {...form.register('name')} className={inputClass} />}
</Field>
<Field
label="Server"
error={errors.serverHost?.message}
hint={voipms ? 'The voip.ms POP your sub-account and numbers use.' : undefined}
>
{(a) => (
<>
<input
{...a}
{...form.register('serverHost')}
list={voipms ? 'voipms-servers' : undefined}
placeholder={voipms ? 'chicago1.voip.ms' : 'sip.example.com'}
autoComplete="off"
className={inputClass}
/>
<datalist id="voipms-servers">
{VOIPMS_SERVERS.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</>
)}
</Field>
<Field label="Sign-in">
{(a) => (
<select {...a} {...form.register('authMode')} className={inputClass}>
<option value="registration">Username and password (registration)</option>
<option value="ip">IP address (the provider knows this server's IP)</option>
</select>
)}
</Field>
{authMode === 'registration' && (
<>
<Field
label={voipms ? 'Sub-account username' : 'Username'}
error={errors.username?.message}
hint={voipms ? 'Like 123456_pbx.' : undefined}
>
{(a) => (
<input
{...a}
{...form.register('serverHost')}
list={voipms ? 'voipms-servers' : undefined}
placeholder={voipms ? 'chicago1.voip.ms' : 'sip.example.com'}
{...form.register('username')}
autoComplete="off"
className={inputClass}
/>
<datalist id="voipms-servers">
{VOIPMS_SERVERS.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</>
)}
</Field>
<Field label="Sign-in">
)}
</Field>
<Field
label="Password"
error={errors.password?.message}
hint={trunk?.hasPassword ? 'Leave empty to keep the current password.' : undefined}
>
{(a) => (
<input
{...a}
{...form.register('password')}
type="password"
autoComplete="new-password"
className={inputClass}
/>
)}
</Field>
</>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Transport">
{(a) => (
<select {...a} {...form.register('authMode')} className={inputClass}>
<option value="registration">Username and password (registration)</option>
<option value="ip">IP address (the provider knows this server's IP)</option>
<select
{...a}
value={transport}
onChange={(e) => setTransport(e.target.value as FormValues['transport'])}
className={inputClass}
>
<option value="udp">UDP</option>
<option value="tcp">TCP</option>
<option value="tls">TLS (encrypted)</option>
</select>
)}
</Field>
{authMode === 'registration' && (
<>
<Field
label={voipms ? 'Sub-account username' : 'Username'}
error={errors.username?.message}
hint={voipms ? 'Like 123456_pbx.' : undefined}
>
<Field label="Port" error={errors.serverPort?.message}>
{(a) => (
<input
{...a}
{...form.register('serverPort', { valueAsNumber: true })}
type="number"
className={inputClass}
/>
)}
</Field>
</div>
<fieldset className="flex flex-col gap-1">
<legend className="text-sm font-medium">Codecs</legend>
<div className="flex gap-4">
{CODECS.map((c) => (
<label key={c} className="flex items-center gap-2 text-sm">
<input type="checkbox" value={c} {...form.register('codecs')} className="size-4" />
{c === 'ulaw' ? 'G.711 µ-law' : c === 'alaw' ? 'G.711 A-law' : 'G.722 (HD)'}
</label>
))}
</div>
{errors.codecs && (
<p className="text-xs text-red-600 dark:text-red-400">{errors.codecs.message}</p>
)}
</fieldset>
<Field
label="Max simultaneous calls"
error={errors.maxChannels?.message}
hint="Empty for no limit."
>
{(a) => (
<input
{...a}
{...form.register('maxChannels')}
inputMode="numeric"
className={inputClass}
/>
)}
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register('enabled')} className="size-4" />
Enabled
</label>
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<summary className="cursor-pointer font-medium">Advanced</summary>
<div className="mt-3 flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
{...form.register('srtp')}
disabled={transport !== 'tls'}
className="size-4"
/>
Encrypt audio (SRTP, needs TLS)
</label>
<Field
label="From user"
error={errors.fromUser?.message}
hint="Defaults to the username."
>
{(a) => <input {...a} {...form.register('fromUser')} className={inputClass} />}
</Field>
<Field
label="From domain"
error={errors.fromDomain?.message}
hint="Defaults to the server."
>
{(a) => <input {...a} {...form.register('fromDomain')} className={inputClass} />}
</Field>
</div>
</details>
{voipms && (
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<summary className="cursor-pointer font-medium">voip.ms API (optional)</summary>
<div className="mt-3 flex flex-col gap-4">
<p className="text-zinc-500">
Needed for SMS and importing your numbers. In the voip.ms portal: Main Menu → SOAP and
REST/JSON API. Enable it, set an API password, and allow this server's public IP
address.
</p>
<Field label="API username (your voip.ms email)">
{(a) => (
<input
{...a}
{...form.register('username')}
{...form.register('voipmsApiUsername')}
type="email"
autoComplete="off"
className={inputClass}
/>
)}
</Field>
<Field
label="Password"
error={errors.password?.message}
hint={trunk?.hasPassword ? 'Leave empty to keep the current password.' : undefined}
label="API password"
hint={trunk?.hasApiPassword ? 'Leave empty to keep the current password.' : undefined}
>
{(a) => (
<input
{...a}
{...form.register('password')}
{...form.register('voipmsApiPassword')}
type="password"
autoComplete="new-password"
className={inputClass}
/>
)}
</Field>
</>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Transport">
{(a) => (
<select
{...a}
value={transport}
onChange={(e) => setTransport(e.target.value as FormValues['transport'])}
className={inputClass}
>
<option value="udp">UDP</option>
<option value="tcp">TCP</option>
<option value="tls">TLS (encrypted)</option>
</select>
)}
</Field>
<Field label="Port" error={errors.serverPort?.message}>
{(a) => (
<input
{...a}
{...form.register('serverPort', { valueAsNumber: true })}
type="number"
className={inputClass}
/>
)}
</Field>
</div>
<fieldset className="flex flex-col gap-1">
<legend className="text-sm font-medium">Codecs</legend>
<div className="flex gap-4">
{CODECS.map((c) => (
<label key={c} className="flex items-center gap-2 text-sm">
<input type="checkbox" value={c} {...form.register('codecs')} className="size-4" />
{c === 'ulaw' ? 'G.711 µ-law' : c === 'alaw' ? 'G.711 A-law' : 'G.722 (HD)'}
</label>
))}
</div>
{errors.codecs && (
<p className="text-xs text-red-600 dark:text-red-400">{errors.codecs.message}</p>
)}
</fieldset>
<Field
label="Max simultaneous calls"
error={errors.maxChannels?.message}
hint="Empty for no limit."
>
{(a) => (
<input
{...a}
{...form.register('maxChannels')}
inputMode="numeric"
className={inputClass}
/>
)}
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register('enabled')} className="size-4" />
Enabled
</label>
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<summary className="cursor-pointer font-medium">Advanced</summary>
<div className="mt-3 flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
{...form.register('srtp')}
disabled={transport !== 'tls'}
className="size-4"
/>
Encrypt audio (SRTP, needs TLS)
</label>
<Field
label="From user"
error={errors.fromUser?.message}
hint="Defaults to the username."
>
{(a) => <input {...a} {...form.register('fromUser')} className={inputClass} />}
</Field>
<Field
label="From domain"
error={errors.fromDomain?.message}
hint="Defaults to the server."
>
{(a) => <input {...a} {...form.register('fromDomain')} className={inputClass} />}
</Field>
</div>
</details>
{voipms && (
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<summary className="cursor-pointer font-medium">voip.ms API (optional)</summary>
<div className="mt-3 flex flex-col gap-4">
<p className="text-zinc-500">
Needed for SMS and importing your numbers. In the voip.ms portal: Main Menu → SOAP
and REST/JSON API. Enable it, set an API password, and allow this server's public IP
address.
</p>
<Field label="API username (your voip.ms email)">
{(a) => (
<input
{...a}
{...form.register('voipmsApiUsername')}
type="email"
autoComplete="off"
className={inputClass}
/>
)}
</Field>
<Field
label="API password"
hint={
trunk?.hasApiPassword ? 'Leave empty to keep the current password.' : undefined
}
>
{(a) => (
<input
{...a}
{...form.register('voipmsApiPassword')}
type="password"
autoComplete="new-password"
className={inputClass}
/>
)}
</Field>
</div>
</details>
)}
</form>
</Dialog>
)}
</form>
);
}