setup: first-run wizard, telephony restart, system status
/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:
1 parent
409724bc55
commit
c2b9fff126
19 files changed
+1455
-210
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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>,
|
||||
|
||||
@@ -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'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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user