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

+1 -1
View File
@@ -1 +1 @@
8612
9468
+23 -1
View File
@@ -6,7 +6,8 @@ import { join } from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import type { AmiClient } from '../ami/client.ts';
import type { Db } from '../db/client.ts';
import { generatorRuns } from '../db/schema.ts';
import { eq } from 'drizzle-orm';
import { generatorRuns, settings } from '../db/schema.ts';
import { getSetting, setSetting } from '../services/settings.ts';
import { loadSnapshot, type Snapshot } from './load.ts';
import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts';
@@ -240,3 +241,24 @@ export function createGenerator(deps: GeneratorDeps) {
}
export type Generator = ReturnType<typeof createGenerator>;
/**
* On an AMI (re)connect: if Asterisk started after the change that needed a restart, it has
* loaded the new transports, so the restart is no longer required (01 §4.3).
*/
export async function clearRestartIfRestarted(
db: Db,
ami: Pick<AmiClient, 'command'>,
now = Date.now(),
): Promise<boolean> {
if (!(await getSetting(db, 'system.restartRequired'))) return false;
const uptime = /System uptime:\s*(\d+)/.exec(await ami.command('core show uptime seconds'))?.[1];
const [row] = await db
.select({ at: settings.updatedAt })
.from(settings)
.where(eq(settings.key, 'system.restartRequired'));
if (uptime === undefined || !row) return false;
if (now - Number(uptime) * 1000 <= row.at.getTime()) return false;
await setSetting(db, 'system.restartRequired', false);
return true;
}
+3 -1
View File
@@ -6,7 +6,7 @@ import { buildApp } from './app.ts';
import { ConfigError, loadConfig } from './config.ts';
import { setSecretKey } from './crypto/secrets.ts';
import { createDb, runMigrations } from './db/client.ts';
import { createGenerator } from './generator/index.ts';
import { clearRestartIfRestarted, createGenerator } from './generator/index.ts';
import { ensureInitialAdmin } from './services/bootstrap.ts';
import { LiveState } from './live/state.ts';
import { smtpMailer } from './services/mail.ts';
@@ -68,6 +68,8 @@ await live.loadFromDb();
// state (after the apply, so hints for new extensions exist). TODO(M6): sync AstDB first (04 §5.5).
ami.on('connected', () => {
void (async () => {
if (await clearRestartIfRestarted(db, ami))
app.log.info('asterisk restarted: restart no longer required');
await generator.apply({ trigger: 'startup', force: true });
await live.loadFromAsterisk();
})().catch((err) => app.log.error({ err }, 'startup sync failed'));
+4 -1
View File
@@ -17,7 +17,7 @@ import { badRequest, conflict, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { assertDestinationExists, describeDestination } from '../services/destinations.ts';
import { getSetting } from '../services/settings.ts';
import { getSetting, setSetting } from '../services/settings.ts';
type DidRow = typeof dids.$inferSelect;
@@ -77,6 +77,9 @@ export async function didRoutes(app: FastifyInstance) {
if (isUniqueViolation(e)) throw conflict(`${formatNumber(body.number)} is already added`);
throw e;
}
// The first number becomes the company's outbound caller ID (otherwise the provider picks).
if ((await getSetting(db, 'routing.defaultCallerIdDidId')) === null)
await setSetting(db, 'routing.defaultCallerIdDidId', created.id);
const dto = await toDto(db, created);
await audit(db, {
actorUserId: req.user!.id,
+27 -1
View File
@@ -13,7 +13,7 @@ import {
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { badRequest } from '../errors.ts';
import { HttpError, badRequest } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
@@ -171,6 +171,32 @@ export async function settingsRoutes(app: FastifyInstance) {
return { notified };
});
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', async () => {
for (const url of ['https://api.ipify.org', 'https://ifconfig.me/ip']) {
const ip = await fetch(url, { signal: AbortSignal.timeout(5000) })
.then((r) => (r.ok ? r.text() : ''))
.catch(() => '');
if (z.ipv4().safeParse(ip.trim()).success) return { ip: ip.trim() };
}
throw new HttpError(502, 'unavailable', "Couldn't detect the public IP. Enter it by hand.");
});
// First-run wizard (05 §2.1).
app.get('/api/setup/status', async () => ({
completed: await getSetting(db, 'setup.completed'),
}));
app.post('/api/setup/complete', async (req) => {
await setSetting(db, 'setup.completed', true);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'setup_complete',
summary: 'Finished the setup wizard',
});
return { ok: true };
});
/** Things an admin should fix, for the dashboard banner (05 §2). */
app.get('/api/system/warnings', async (): Promise<{ items: Warning[] }> => {
const items: Warning[] = [];
+39
View File
@@ -1,10 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/system/* (04 §7.3). Admin only.
import { readFile } from 'node:fs/promises';
import { networkInterfaces } from 'node:os';
import { join } from 'node:path';
import { z } from 'zod';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { HttpError } from '../errors.ts';
import { AmiDisconnectedError } from '../ami/client.ts';
import { GENERATED_FILES } from '../generator/index.ts';
/** Generated files hold plaintext SIP passwords and PINs: mask them in the debug view. */
@@ -39,6 +44,40 @@ export async function systemRoutes(app: FastifyInstance) {
return r;
});
// ponytail: the M3 subset of 04 §7.3's status (the wizard needs the LAN IP); M11 adds the rest.
app.get('/api/system/status', async () => ({
ami: { connected: app.ami.isConnected() },
restartRequired: await getSetting(app.db, 'system.restartRequired'),
// With host networking (production) these are the server's own addresses.
lanIps: Object.values(networkInterfaces())
.flat()
.filter((i) => i && i.family === 'IPv4' && !i.internal)
.map((i) => i!.address),
}));
/** 01 §4.3: transports only change on a restart. `now=true` drops calls; otherwise waits. */
app.post('/api/system/asterisk/restart', async (req) => {
const { now } = z.object({ now: z.enum(['true', 'false']).default('false') }).parse(req.query);
if (!app.ami.isConnected())
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
await app.ami
.action({
Action: 'Command',
Command: now === 'true' ? 'core restart now' : 'core restart gracefully',
})
// A restart can close AMI before the reply arrives: that's the restart working.
.catch((e: unknown) => {
if (!(e instanceof AmiDisconnectedError)) throw e;
});
await audit(app.db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'asterisk_restart',
summary: `Restarted telephony${now === 'true' ? ' now' : ' (after active calls)'}`,
});
return { ok: true };
});
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
GENERATED_FILES.map(async (name) => ({
+4 -2
View File
@@ -5,7 +5,6 @@ import { join } from 'node:path';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { patternRegex } from '@selfphoneng/shared';
import { settings } from '../src/db/schema.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -78,7 +77,10 @@ describe.skipIf(!hasTestDb)('outbound routes API', () => {
destination: { type: 'hangup' },
})
).json().id;
await t.db.insert(settings).values({ key: 'routing.defaultCallerIdDidId', value: mainDid });
// The first number added became the default outbound caller ID on its own.
expect((await req('GET', '/api/settings')).json()['routing.defaultCallerIdDidId']).toBe(
mainDid,
);
routes = (await req('GET', '/api/outbound-routes')).json().items;
});
afterAll(() => t?.close());
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M3-T5: what the setup wizard needs from the api: status/complete, public IP detection,
// system status, and the telephony restart that clears "restart required".
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { settings } from '../src/db/schema.ts';
import { AmiDisconnectedError } from '../src/ami/client.ts';
import { clearRestartIfRestarted } from '../src/generator/index.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('setup wizard api', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
});
afterAll(() => t?.close());
it('setup starts incomplete and can be completed (and is read-only in PATCH)', async () => {
expect((await req('GET', '/api/setup/status')).json()).toEqual({ completed: false });
expect((await req('PATCH', '/api/settings', { 'setup.completed': true })).statusCode).toBe(400);
expect((await req('POST', '/api/setup/complete')).statusCode).toBe(200);
expect((await req('GET', '/api/setup/status')).json()).toEqual({ completed: true });
});
it('detects the public IP, falling back to the second service', async () => {
const spy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('<html>blocked</html>'))
.mockResolvedValueOnce(new Response('203.0.113.7\n'));
const r = await req('POST', '/api/settings/network/detect-public-ip');
expect(r.json()).toEqual({ ip: '203.0.113.7' });
spy.mockRejectedValue(new Error('offline'));
expect((await req('POST', '/api/settings/network/detect-public-ip')).statusCode).toBe(502);
spy.mockRestore();
});
it('system status lists LAN addresses; restart goes through AMI', async () => {
const s = (await req('GET', '/api/system/status')).json();
expect(s).toMatchObject({ ami: { connected: true }, restartRequired: false });
expect(Array.isArray(s.lanIps)).toBe(true);
expect((await req('POST', '/api/system/asterisk/restart?now=true')).statusCode).toBe(200);
expect(t.ami.actions.at(-1)).toEqual({ Action: 'Command', Command: 'core restart now' });
await req('POST', '/api/system/asterisk/restart');
expect(t.ami.actions.at(-1)).toEqual({ Action: 'Command', Command: 'core restart gracefully' });
// A real restart closes AMI before it replies: still a success.
const action = t.ami.action;
t.ami.action = async () => {
throw new AmiDisconnectedError();
};
expect((await req('POST', '/api/system/asterisk/restart?now=true')).statusCode).toBe(200);
t.ami.action = action;
});
it('"restart required" clears only when Asterisk started after the change', async () => {
await setSetting(t.db, 'system.restartRequired', true);
const [row] = await t.db
.select({ at: settings.updatedAt })
.from(settings)
.where(eq(settings.key, 'system.restartRequired'));
const changedAt = row!.at.getTime();
const ami = (uptime: number) => ({
command: async () => `System uptime: ${uptime}\nLast reload: ${uptime}\n`,
});
// Up for an hour: started before the change, still running the old transports.
expect(await clearRestartIfRestarted(t.db, ami(3600), changedAt + 5000)).toBe(false);
expect(await getSetting(t.db, 'system.restartRequired')).toBe(true);
// Up for 2 s, 5 s after the change: it restarted since.
expect(await clearRestartIfRestarted(t.db, ami(2), changedAt + 5000)).toBe(true);
expect(await getSetting(t.db, 'system.restartRequired')).toBe(false);
});
});
+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>
);
}
+11
View File
@@ -66,6 +66,17 @@ to the next one. Steps can be skipped where noted.
9. Emergency: default E911 caller ID DID (required if a trunk exists), notification emails. Shows the 06 §6 text about registering the address at voip.ms.
10. Done: shows "Dial *43 from any phone for an echo test" and links to the softphone.
Implementation notes (M3):
- Admins are sent to `/setup` until `setup.completed` is true; "Skip setup" completes it.
- Step 1 is already done by the forced password change at first sign-in, so it only confirms.
- Step 3 changes the SIP transports; on a fresh install the wizard restarts telephony right away
(`POST /api/system/asterisk/restart?now=true`). `restartRequired` clears on the next AMI
connect once Asterisk's uptime shows it started after the change.
- M3 also ships steps 7 and 8 because "calls work using only the wizard" needs a user and a
routed number. Step 8 offers a user or an outside number; "everyone" and "an IVR" arrive in M6.
Step 10 does not mention *43 or the softphone until M6/M5 build them.
- The first phone number added becomes `routing.defaultCallerIdDidId` when none is set.
## 3. Shared components (`src/components/`)
- **DestinationPicker**: a type select (User, Voicemail of user, Ring group, Queue,
+1 -1
View File
@@ -125,7 +125,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
🔨 Emergency contexts, `SpEmergency` handler (email, ring-out, banner, `emergency_events`), test-notification button, dashboard warnings.
✅ Unit/golden tests show 911 bypasses class/limits. The test-notification button sends the email and rings the notify extension. **Do not dial 911.**
**M3-T5 Setup wizard (steps 1–6, 9, 10)**
**M3-T5 Setup wizard (steps 1–10; steps 7–8 with the M3 destination choices, see 05 §2.1)**
📖 05 §2.1
✅ A fresh install can be taken to "inbound and outbound calls work" using only the wizard.
+4
View File
@@ -41,3 +41,7 @@ over its IP would be identified as the trunk.
`pnpm test:asterisk` (golden-file load test, `call-test.ts` with 30 s two-way audio between two
baresip phones, and `trunk-test.ts` with registration against a fake provider) needs no api at all.
`m3-t5.mjs` is the M3-T5 acceptance and needs its own fresh database: it signs in with the
initial password, walks all ten wizard steps against a fake voip.ms, then proves an inbound call
to the DID rings the new user's softphone and an outbound call leaves with the DID as caller ID.
+4 -1
View File
@@ -32,7 +32,10 @@ try {
await p.fill('New password', NEW_PW);
await p.fill('Repeat new password', NEW_PW);
await p.click('Change password');
await p.waitFor(`location.pathname === '/admin'`, 'admin dashboard after change');
// A fresh install opens the setup wizard (05 §2.1); this flow skips it (m3-t5.mjs walks it).
await p.waitFor(`location.pathname === '/setup'`, 'setup wizard after change');
await p.click('Skip setup');
await p.waitFor(`location.pathname === '/admin'`, 'admin dashboard after skipping setup');
assert.match(await p.text(), /Extensions online/);
await p.click('Users & Extensions');
+325
View File
@@ -0,0 +1,325 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M3-T5 acceptance: a fresh install taken to "inbound and outbound calls work" using only the
// setup wizard, against a fake voip.ms (a second Asterisk that registers the sub-account, calls
// our DID like voip.ms does, and records what our outbound calls look like).
// Needs `m2-live.mjs up` and the api on a FRESH database (initial admin password).
// Usage: node scripts/e2e/m3-t5.mjs <baseUrl> <adminEmail> <initialPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch } from './cdp.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const PROVIDER = 'sp-live-provider';
const PHONE = 'sp-live-phone101';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' }; // the fake provider's test account
const NEW_PW = 'correct-horse-battery-staple';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const docker = (...a) => {
const r = spawnSync('docker', a, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
};
const cli = (name, cmd) => docker('exec', name, 'asterisk', '-rx', cmd).out;
async function until(what, fn, seconds = 60) {
for (let i = 0; i < seconds * 2; i++) {
if (await fn()) return;
await sleep(500);
}
throw new Error(`timed out: ${what}`);
}
const [base, email, initialPw, shotsDir] = process.argv.slice(2);
// ── the fake voip.ms ──
const conf = mkdtempSync(join(tmpdir(), 'sp-provider-'));
for (const f of [
'rtp_selfphoneng.conf',
'extensions_globals_selfphoneng.conf',
'voicemail_selfphoneng.conf',
'queues_selfphoneng.conf',
'musiconhold_selfphoneng.conf',
'confbridge_selfphoneng.conf',
])
writeFileSync(join(conf, f), '');
writeFileSync(
join(conf, 'pjsip_selfphoneng.conf'),
`[transport-udp]\ntype = transport\nprotocol = udp\nbind = 0.0.0.0:5060\n\n` +
`[${SUB.user}]\ntype = endpoint\ncontext = provider-in\ndisallow = all\nallow = ulaw\nauth = ${SUB.user}-auth\naors = ${SUB.user}\n\n` +
`[${SUB.user}-auth]\ntype = auth\nauth_type = userpass\nusername = ${SUB.user}\npassword = ${SUB.pass}\n\n` +
`[${SUB.user}]\ntype = aor\nmax_contacts = 1\nremove_existing = yes\n`,
);
writeFileSync(
join(conf, 'extensions_selfphoneng.conf'),
// Calls to our DID use a 10-digit Request-URI, like voip.ms (06 §3).
`[provider-out]\nexten => _X.,1,Set(CALLERID(all)="Mobile" <3125559999>)\n same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30)\n\n` +
`[provider-echo]\nexten => s,1,Answer()\n same => n,Echo()\n\n` +
`[provider-in]\nexten => _X.,1,Set(GLOBAL(LAST_IN)=to=\${EXTEN} rpid=\${PJSIP_HEADER(read,Remote-Party-ID)})\n same => n,Answer()\n same => n,Echo()\n`,
);
docker('rm', '-f', PROVIDER, PHONE);
assert.ok(
docker(
'run',
'-d',
'--rm',
'--name',
PROVIDER,
'--network',
NET,
'-e',
'AMI_SECRET=provider',
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
IMAGE,
).ok,
);
await until(
'provider boot',
async () => docker('exec', PROVIDER, 'asterisk', '-rx', 'core waitfullybooted').ok,
);
const providerIp = docker(
'inspect',
'-f',
`{{(index .NetworkSettings.Networks "${NET}").IPAddress}}`,
PROVIDER,
).out;
const p = await launch({ shotsDir });
const text = () => p.evaluate('document.body.innerText');
const step = async (n, title) => {
await p.waitFor(
`document.querySelector('#step-title')?.textContent === '${n}. ${title}'`,
`step ${n}`,
20000,
);
};
/** Waits for the field (steps load their data first), then fills it. */
const fill = async (label, value) => {
await p.waitFor(
`[...document.querySelectorAll('label')].some(l => l.textContent.trim().startsWith(${JSON.stringify(label)}))`,
label,
20000,
);
await p.fill(label, value);
};
const clickNext = () => p.click('Next');
const choose = (ariaLabel, value) =>
p.evaluate(`(() => {
const el = document.querySelector('select[aria-label="${ariaLabel}"]');
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(el, ${JSON.stringify(value)});
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
try {
// 1. Password (forced change on first sign-in)
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', initialPw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/change-password'`);
await p.fill('Current password', initialPw);
await p.fill('New password', NEW_PW);
await p.fill('Repeat new password', NEW_PW);
await p.click('Change password');
await p.waitFor(`location.pathname === '/setup'`, 'wizard opens');
await step(1, 'Password');
await clickNext();
// 2. Company
await step(2, 'Company');
await fill('Company name', 'Acme Dental');
await p.click('Save and continue');
// 3. Network (documentation address; Docker's 172.16/12 stays a local net). Saving changes the
// SIP transports, so the wizard restarts telephony.
await step(3, 'Network');
await fill('Public IP address', '203.0.113.10');
await p.evaluate(
`[...document.querySelectorAll('label')].find(l => l.textContent.includes("I've done this")).querySelector('input').click(), true`,
);
await p.click('Save and continue');
await step(4, 'Email');
const restarted = (await text()).includes('Telephony restarted');
console.log(`network saved${restarted ? '; telephony restarted' : ''}`);
assert.match(
cli(AST, 'pjsip show transport transport-udp'),
/external_media_address\s*:\s*203\.0\.113\.10/,
);
// 4. Email: skipped
await p.click('Skip for now');
// 5. Trunk
await step(5, 'Trunk');
await fill('Server', providerIp);
await fill('Sub-account username', SUB.user);
await fill('Password', SUB.pass);
await p.click('Create trunk');
await p.waitFor(`document.body.innerText.includes('Saved.')`, 'trunk saved');
await p.waitFor(
`![...document.querySelectorAll('button')].find(b => b.textContent === 'Next').disabled`,
'trunk Registered unlocks Next',
90000,
);
await p.shot('m3-t5-05-trunk');
console.log('trunk Registered');
await clickNext();
// 6. Numbers
await step(6, 'Phone numbers');
await fill('Phone number', '(312) 555-1234');
await p.click('Add number');
await p.waitFor(`document.body.innerText.includes('✓ (312) 555-1234')`, 'number added');
await clickNext();
// 7. Users
await step(7, 'Users');
for (const [aria, value] of [
['Name 1', 'Dan Doe'],
['Email 1', 'dan@example.com'],
])
await p.evaluate(`(() => {
const el = document.querySelector('input[aria-label="${aria}"]');
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, ${JSON.stringify(value)});
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
})()`);
await p.click('Add users');
await p.waitFor(`document.body.innerText.includes('shown only now')`, 'temporary password');
await p.shot('m3-t5-07-users');
const ext = /(\d{3,6})\s+Dan Doe/.exec(await text())?.[1];
assert.ok(ext, 'Dan got an extension');
await p.waitFor(`document.body.innerText.includes('✓ ${ext} Dan Doe')`);
await clickNext();
// 8. Main number → Dan
await step(8, 'Main number');
const label = 'When someone calls (312) 555-1234 (Main line), ring:';
await choose(`${label}: type`, 'user');
await p.waitFor(
`[...document.querySelectorAll('select[aria-label="${label}: user"] option')].some(o => o.textContent.includes('Dan Doe'))`,
);
const danId = await p.evaluate(
`[...document.querySelectorAll('select[aria-label="${label}: user"] option')].find(o => o.textContent.includes('Dan Doe')).value`,
);
await choose(`${label}: user`, danId);
await p.click('Save and continue');
// 9. Emergency: Next stays locked until a default E911 caller ID is saved.
await step(9, 'Emergency');
assert.ok(
await p.evaluate(
`[...document.querySelectorAll('button')].find(b => b.textContent === 'Next').disabled`,
),
);
const didId = await p.evaluate(
`[...document.querySelectorAll('option')].find(o => o.textContent.startsWith('(312) 555-1234')).value`,
);
await fill('Default emergency caller ID', didId);
await p.click('Save');
await p.waitFor(
`![...document.querySelectorAll('button')].find(b => b.textContent === 'Next').disabled`,
'Next unlocked',
);
await clickNext();
// 10. Done
await step(10, 'Done');
await p.click('Finish');
await p.waitFor(`location.pathname === '/admin'`, 'dashboard after finishing');
await p.waitFor(
`/Trunks up\\s*1 \\/ 1/.test(document.body.innerText)`,
'trunk up on the dashboard',
);
await p.shot('m3-t5-10-dashboard');
console.log('wizard finished');
// ── Calls work ──
const cookie = await (async () => {
const r = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: NEW_PW }),
});
return r.headers.get('set-cookie').split(';')[0];
})();
const exts = await (await fetch(`${base}/api/extensions`, { headers: { cookie } })).json();
const dan = exts.items.find((e) => e.number === ext);
const creds = await (
await fetch(`${base}/api/extensions/${dan.id}/sip-credentials`, { headers: { cookie } })
).json();
const dir = mkdtempSync(join(tmpdir(), 'sp-phone-'));
mkdirSync(join(dir, 'p'));
writeFileSync(
join(dir, 'p', 'config'),
'sip_listen 0.0.0.0:5070\nmodule_path /usr/lib/baresip/modules\nmodule g711.so\nmodule ausine.so\nmodule aufile.so\nmodule menu.so\nmodule account.so\naudio_source ausine,440\nausrc_srate 48000\nauplay_srate 48000\naudio_player aufile,/phone/p/heard.wav\naudio_alert aufile,/dev/null\n',
);
writeFileSync(
join(dir, 'p', 'accounts'),
`<sip:${ext}@${AST};transport=udp>;auth_pass=${creds.password};answermode=auto;audio_codecs=PCMU\n`,
);
assert.ok(
docker(
'run',
'-d',
'--rm',
'--name',
PHONE,
'--network',
NET,
'-v',
`${dir}:/phone`,
'debian:trixie-slim',
'sh',
'-c',
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && baresip -f /phone/p > /phone/p/log 2>&1',
).ok,
);
await until(
`phone ${ext} registers`,
async () => new RegExp(`${ext}/sip:.*Avail`).test(cli(AST, 'pjsip show contacts')),
180,
);
const bothUp = () => {
const up = cli(AST, 'core show channels concise')
.split('\n')
.filter((l) => l.includes('!Up!'));
return (
up.some((l) => l.startsWith(`PJSIP/${ext}-`)) && up.some((l) => l.startsWith('PJSIP/trunk-'))
);
};
// Inbound: a "mobile" calls the DID.
cli(PROVIDER, 'channel originate Local/3125551234@provider-out extension s@provider-echo');
await until('inbound call answered by Dan', async () => bothUp(), 30);
await p.waitFor(
`/On a call\\s*1/.test(document.body.innerText)`,
'dashboard shows the call',
10000,
);
await p.shot('m3-t5-11-inbound-call');
console.log(`inbound: (312) 555-1234 rang ${ext}, answered`);
cli(AST, 'channel request hangup all');
await sleep(2000);
// Outbound: Dan dials a 10-digit number.
cli(AST, `channel originate PJSIP/${ext} extension 3125550000@sp-from-ext`);
await until('outbound call connected', async () => bothUp(), 30);
const lastIn = /LAST_IN=(.*)/.exec(cli(PROVIDER, 'dialplan show globals'))?.[1] ?? '';
assert.match(lastIn, /^to=13125550000 /, 'provider got 11 digits');
assert.match(lastIn, /sip:13125551234@/, 'caller ID is the DID');
console.log(`outbound: provider received ${lastIn}`);
cli(AST, 'channel request hangup all');
assert.deepEqual(p.consoleErrors, [], 'no console errors');
console.log('M3-T5 (wizard → calls work): PASS');
} catch (e) {
await p.shot('m3-t5-FAILED').catch(() => {});
console.error('FAIL:', e.message, '\nconsole:', p.consoleErrors);
process.exitCode = 1;
} finally {
await p.close();
docker('rm', '-f', PHONE);
}