diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts index 6f41155..b0abc4f 100644 --- a/apps/api/src/jobs.ts +++ b/apps/api/src/jobs.ts @@ -154,7 +154,7 @@ export function createJobs(d: JobDeps) { d, `Public IP changed to ${ip}`, `The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` + - 'Restart telephony from the dashboard so calls use the new address (it drops calls in progress).\n' + + 'Restart telephony so calls use the new address: Admin → System says how (it drops calls in progress).\n' + 'Phones and the provider may need the new IP if they were set up with the old one.', ); }; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 3d920e5..94a2a7b 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -27,6 +27,7 @@ import { BlockedPage } from './pages/admin/BlockedPage.tsx'; import { CallsPage } from './pages/admin/CallsPage.tsx'; import { RecordingsPage } from './pages/admin/RecordingsPage.tsx'; import { ReportsPage } from './pages/admin/ReportsPage.tsx'; +import { SystemPage } from './pages/admin/SystemPage.tsx'; import { MePage } from './pages/user/MePage.tsx'; import { SetupPage } from './pages/SetupPage.tsx'; import './index.css'; @@ -78,6 +79,8 @@ const router = createBrowserRouter([ ) : p.path === 'reports' ? ( + ) : p.path === 'system' ? ( + ) : ( ), diff --git a/apps/web/src/pages/admin/AdminLayout.tsx b/apps/web/src/pages/admin/AdminLayout.tsx index b76c770..ffabdde 100644 --- a/apps/web/src/pages/admin/AdminLayout.tsx +++ b/apps/web/src/pages/admin/AdminLayout.tsx @@ -30,7 +30,7 @@ export const ADMIN_PAGES = [ { path: 'sms', label: 'SMS', milestone: 'M9' }, { path: 'blocked', label: 'Blocked numbers', milestone: null }, { path: 'settings', label: 'Settings', milestone: null }, - { path: 'system', label: 'System', milestone: 'M11' }, + { path: 'system', label: 'System', milestone: null }, ] as const; export function AdminLayout({ user }: { user: AuthUser }) { diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index 915258d..305cb09 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -426,10 +426,7 @@ function NetworkTab({ s }: { s: SettingsDto }) { save.mutate({ 'network.publicIp': ip.trim() || null, 'network.publicIpWatch': watch }); }} > - + {(p) => (
+

System

+ {status.error && {status.error.message}} + + {s && ( +
+
+
Telephony (Asterisk)
+
{s.ami.connected ? 'Running' : 'Not reachable'}
+
+
+
Restart
+
{s.restartRequired ? 'Needed' : 'Not needed'}
+
+
+ )} + {s?.restartRequired && ( +

+ The public IP, SIP ports or TLS changed. Telephony keeps using the old ones until it + restarts. +

+ )} + +
+

Restarting telephony

+

+ On the server, in the folder SelfPhoneNG is installed in (where .env is), + run: +

+
+          {RESTART}
+        
+
    +
  • + Calls in progress drop, so do it when nobody is on the phone. +
  • +
  • + Phones and the trunk sign in again by themselves within a minute. This page shows + “Running” and “Not needed” once it's back. +
  • +
  • The web app, voicemail and call history aren't affected.
  • +
+
+ + ); +} diff --git a/apps/web/src/pages/admin/UserDrawer.tsx b/apps/web/src/pages/admin/UserDrawer.tsx index e8e9843..4c6681d 100644 --- a/apps/web/src/pages/admin/UserDrawer.tsx +++ b/apps/web/src/pages/admin/UserDrawer.tsx @@ -458,6 +458,7 @@ function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void } defaultValues: { outboundClass: ext.outboundClass, outboundCallerIdDidId: ext.outboundCallerIdDidId ?? '', + recordMode: ext.recordMode, }, }); const onSubmit = form.handleSubmit(async (v) => { @@ -466,6 +467,7 @@ function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void } await extensionsApi.update(ext.id, { outboundClass: v.outboundClass, outboundCallerIdDidId: v.outboundCallerIdDidId || null, + recordMode: v.recordMode, }); await qc.invalidateQueries({ queryKey: ['extension', ext.id] }); onSaved(); @@ -506,6 +508,19 @@ function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void } )} + + {(a) => ( + + )} + ); } diff --git a/docs/admin-guide/legal.md b/docs/admin-guide/legal.md index 32bf422..82f906d 100644 --- a/docs/admin-guide/legal.md +++ b/docs/admin-guide/legal.md @@ -73,14 +73,18 @@ Recording calls without the right consent can be a crime. In the US: - **Nothing is recorded unless you turn it on.** You can record: - every call to a number (**Phone numbers → Record every call**); - - a queue's calls (the queue's **Record calls** switch). + - a queue's calls (the queue's **Record calls** switch); + - one person's calls with outside numbers (Users & Extensions → the user → **Calling** → + **Record calls**: calls from outside, calls to outside, or both). The setup wizard never turns recording on. - **Settings → Recording → "Notice played before a recorded call"** plays a sound you upload (e.g. "This call may be recorded for quality and training") to everyone who calls a number set to record, before anything else. Upload it on **Sounds & music**. -- **Queue recordings play no notice of their own.** If you record a queue, send its callers - through a number that plays the notice, or through an announcement that says it - (**Sounds & music → Announcements**: play a sound, then go to the queue). +- **Queue and per-person recordings play no notice of their own.** Only numbers set to record + play it. If you record a queue or a person, make sure the callers still hear it: send them through + a number that plays the notice, or through an announcement that says it (**Sounds & music → + Announcements**: play a sound, then go on). For calls a person makes to the outside, tell them to + say the call is recorded. - **Retention.** Recordings and voicemail are deleted after the number of days you set there. 0 keeps them. diff --git a/docs/admin-guide/network.md b/docs/admin-guide/network.md index 83a42be..86a9492 100644 --- a/docs/admin-guide/network.md +++ b/docs/admin-guide/network.md @@ -30,7 +30,7 @@ the wrong place. ("Public IP changed to …"). - **Then restart telephony** so Asterisk uses the new address: on the server, run `docker compose restart asterisk` (calls in progress drop). The dashboard shows "SIP port or - transport changes need a telephony restart" until you do. + transport changes need a telephony restart" until you do, and **System** shows the command. ![Settings → Network](img/settings-network.png) diff --git a/docs/admin-guide/troubleshooting.md b/docs/admin-guide/troubleshooting.md index 2c91cb9..0534a4f 100644 --- a/docs/admin-guide/troubleshooting.md +++ b/docs/admin-guide/troubleshooting.md @@ -128,7 +128,8 @@ nobody is on a call: docker compose restart asterisk ``` -The banner goes away a few seconds after Asterisk is back. +The admin page **System** shows the same command. It says "Restart: Not needed" and the banner +goes away a few seconds after Asterisk is back. ## Locked out diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md index cf94ecf..a1b871e 100644 --- a/docs/spec/05-web-ui.md +++ b/docs/spec/05-web-ui.md @@ -28,7 +28,7 @@ Left sidebar navigation, in this order: | Page | Path | Contents | |---|---|---| | Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down) | -| Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable) | +| Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable). As built (owner follow-up to M11-T7, 2026-10-20): the Calling tab's recording mode was missing until then; it is a **Record calls** select (Never, Calls from outside, Calls to outside, All outside calls = `recordMode` never/inbound/outbound/all). Calls inside the company are never recorded, and no recording notice plays for it (only numbers set to record play one) | | Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" | | Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed | | Outbound rules | `/admin/outbound` | Sortable list (drag to reorder). Form: name, patterns (rows of match/strip/prepend with a pattern help popover), required class, trunk order (multi-select with order), caller ID override. **Test box**: pick an extension + type a number → shows the matching route, final dialed number, trunk, caller ID, or the reason it's denied | @@ -48,7 +48,7 @@ Left sidebar navigation, in this order: | SMS | `/admin/sms` | Same UI as the user one, with all DIDs | | Blocked numbers | `/admin/blocked` | | | Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens | -| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log | +| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-up to M11-T7, 2026-10-20): only telephony status (AMI connected, restart needed) and how to restart (`docker compose restart asterisk` on the server; no restart button). The dashboard's `restart_required` warning links here. The rest of this row isn't built; the admin guide's troubleshooting page covers it from the shell | ### 2.1 First-run setup wizard (`/setup`) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index e103b3f..ffed63b 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -258,3 +258,11 @@ phone signs in to extension 270 with the wrong password; fail2ban's stock `aster IP, and CrowdSec's `crowdsecurity/asterisk` collection parses them (`cscli explain`). It then takes the guide's screenshots into the given folder (run it with `docs/admin-guide/img`) and checks every relative link, image and `#anchor` in the guide. About 4 minutes. + +`m11-system.mjs` (m2-live with sounds and recordings dirs, api with `RECORDINGS_DIR`, after `m1-t4.mjs`) +checks the owner's follow-ups to M11-T7. The user drawer's Calling → "Record calls" set to "Calls +from outside" records a real inbound call to 241 (MixMonitor, a recording in call history); set back +to "Never", the next call isn't recorded. A public IP change on Settings → Network raises the +dashboard's restart warning, whose link opens System ("Restart: Needed", the command); restarting +the Asterisk container brings it to "Running" / "Not needed", the new IP is in the transport and the +warning is gone. About 3 minutes. diff --git a/scripts/e2e/m11-system.mjs b/scripts/e2e/m11-system.mjs new file mode 100644 index 0000000..1741470 --- /dev/null +++ b/scripts/e2e/m11-system.mjs @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The owner's follow-ups to M11-T7 (2026-10-20): +// - Users & Extensions → user → Calling → "Record calls": "Calls from outside" records a real +// inbound call to that person (MixMonitor, a recording in call history); back to "Never", the +// next call isn't recorded. +// - System page: a public IP change in Settings → Network raises the dashboard warning, its link +// opens System ("Restart: Needed" and the command); running what it says (here `docker restart` +// on the m2-live container, which is what `docker compose restart asterisk` does) brings it back +// to "Running" and "Not needed", and the warning goes away. +// Runs on m2-live with a recordings dir (api with RECORDINGS_DIR), after m1-t4.mjs. +// Usage: node scripts/e2e/m11-system.mjs +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { originate } from './ami.mjs'; +import { launch } from './cdp.mjs'; +import { startPhones } from './phones.mjs'; + +const NET = 'sp-live'; +const AST = 'sp-live-ast'; +const DID = '13125558899'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const cli = (cmd) => + spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? ''; +// eslint-disable-next-line no-control-regex -- strips Asterisk's log colors +const ANSI = /\x1b\[[0-9;]*m/g; +const astLog = () => + (spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '').replace(ANSI, ''); +const [base, email, pw, shotsDir] = process.argv.slice(2); +const until = async (what, ms, fn) => { + const end = Date.now() + ms; + for (;;) { + const v = await fn(); + if (v) return v; + assert.ok(Date.now() < end, `timed out: ${what}`); + await sleep(500); + } +}; + +const login = await fetch(`${base}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' }, + body: JSON.stringify({ email, password: pw }), +}); +const cookie = login.headers + .getSetCookie() + .map((c) => c.split(';')[0]) + .join('; '); +const api = async (method, url, body) => { + const r = await fetch(`${base}${url}`, { + method, + headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie }, + body: body && JSON.stringify(body), + }); + const text = await r.text(); + assert.ok(r.ok, `${method} ${url}: ${r.status} ${text}`); + return text ? JSON.parse(text) : null; +}; + +const { user } = await api('POST', '/api/users', { + email: 'rita@example.com', + displayName: 'Rita Recorded', +}); +const ext = await api('POST', '/api/extensions', { + number: '241', + name: 'Rita Recorded', + userId: user.id, +}); +const pass = (await api('GET', `/api/extensions/${ext.id}/sip-credentials`)).password; +const trunk = await api('POST', '/api/trunks', { + name: 'Recording test', + serverHost: '127.0.0.1', + username: 'm11rec', + password: 'unused-password-1', +}); +await api('POST', '/api/dids', { + number: DID, + trunkId: trunk.id, + label: 'Rita direct', + destination: { type: 'user', id: ext.id }, +}); +const phones = startPhones({ + name: 'sp-live-phones-m11sys', + net: NET, + ast: AST, + phones: [{ ext: '241', password: pass, answer: 'auto' }], +}); +/** One call from outside to Rita's number; true when Asterisk started recording it. */ +const callIn = async (n) => { + const at = astLog().length; + await originate({ + Channel: `Local/${DID}@sp-from-trunk-${trunk.id}`, + CallerID: `"Outside" <${n}>`, + Application: 'Wait', + Data: '20', + }); + await until('241 rings', 20_000, () => /Called PJSIP\/241\//.test(astLog().slice(at))); + await sleep(5000); + cli('channel request hangup all'); + await sleep(2000); + return /MixMonitor\(/.test(astLog().slice(at)); +}; + +const p = await launch({ shotsDir }); +try { + await until('phone 241 registered', 180_000, () => + /241\/sip:.*Avail/.test(cli('pjsip show contacts')), + ); + await p.goto(`${base}/login`); + await p.waitFor(`!!document.querySelector('input[type=email]')`); + await p.fill('Email', email); + await p.fill('Password', pw); + await p.click('Sign in'); + await p.waitFor(`location.pathname.startsWith('/admin')`, 'signed in'); + + // Record calls, set in the user drawer. + const setRecord = async (value) => { + await p.goto(`${base}/admin/users`); + await p.waitFor(`document.body.innerText.includes('rita@example.com')`, 'users list'); + await p.click('Edit Rita Recorded'); + await p.waitFor( + `[...document.querySelectorAll('[role=tab]')].some(t => t.textContent === 'Calling' && !t.disabled)`, + 'Calling tab', + ); + await p.click('Calling'); + await p.waitFor(`!!document.querySelector('#user-calling-form')`, 'Calling form'); + await p.fill('Record calls', value); + if (value !== 'never') await p.shot('m11-system-01-record-calls'); + await p.evaluate( + `document.querySelector('dialog button[form=user-calling-form]').click(), true`, + ); + await p.waitFor(`!document.querySelector('dialog[open]')`, 'drawer closed'); + await until( + 'record mode saved', + 10_000, + async () => (await api('GET', `/api/extensions/${ext.id}`)).recordMode === value, + ); + await until( + `dialplan has ${value}`, + 30_000, + () => + cli('dialplan show 241@sp-local-numbers').includes(`(241,`) && + cli('dialplan show 241@sp-local-numbers').includes(`,${value}))`), + ); + }; + await setRecord('inbound'); + assert.ok(await callIn('3125550781'), 'the call was recorded'); + const call = await until('call with its recording in history', 60_000, async () => { + const c = (await api('GET', '/api/calls?q=5558899')).items[0]; + return c?.recordingId ? c : null; + }); + console.log(`record calls "Calls from outside": recorded (${call.talkSeconds} s, in history)`); + await setRecord('never'); + assert.equal(await callIn('3125550782'), false, 'not recorded after "Never"'); + console.log('record calls "Never": the next call was not recorded'); + + // A public IP change → warning → System page → restart → cleared. + await p.goto(`${base}/admin/settings/network`); + await p.waitFor(`!!document.querySelector('input')`, 'network tab'); + await p.fill('Public IP', '203.0.113.77'); + await p.click('Save'); + await p.waitFor(`document.body.innerText.includes('Saved.')`, 'saved'); + await until( + 'restart required', + 30_000, + async () => (await api('GET', '/api/system/status')).restartRequired, + ); + await p.goto(`${base}/admin`); + await p.waitFor( + `document.querySelector('[aria-label=Warnings]')?.innerText.includes('need a telephony restart')`, + 'dashboard warning', + ); + await p.evaluate( + `[...document.querySelectorAll('[aria-label=Warnings] li')].find(li => li.innerText.includes('telephony restart')).querySelector('a').click(), true`, + ); + await p.waitFor(`location.pathname === '/admin/system'`, 'System page from the warning'); + await p.waitFor( + `document.querySelector('[aria-label=Status]')?.innerText.includes('Needed')`, + 'restart needed', + ); + const command = await p.evaluate( + `document.querySelector('[aria-label="Restart command"]').innerText.trim()`, + ); + assert.equal(command, 'docker compose restart asterisk'); + await p.shot('m11-system-02-needed'); + console.log(`system page: "Restart: Needed", tells to run: ${command}`); + + spawnSync('docker', ['restart', AST]); + // Reloaded rather than waiting for the page's own 10 s refresh: headless tabs count as hidden, + // and React Query pauses intervals in hidden tabs. + await until('running and not needed after the restart', 90_000, async () => { + await p.goto(`${base}/admin/system`); + await p.waitFor(`!!document.querySelector('[aria-label=Status]')`, 'status'); + return /Running[\s\S]*Not needed/.test( + await p.evaluate(`document.querySelector('[aria-label=Status]').innerText`), + ); + }); + await p.shot('m11-system-03-restarted'); + assert.match( + cli('pjsip show transport transport-udp'), + /203\.0\.113\.77/, + 'new public IP in use', + ); + const warnings = (await api('GET', '/api/system/warnings')).items.map((w) => w.code); + assert.ok(!warnings.includes('restart_required'), `warnings: ${warnings}`); + console.log('after the restart: "Running", "Not needed", the new IP in use, no warning'); + console.log('M11 system page and record switch: PASS'); +} finally { + await p.close(); + phones.stop(); +} diff --git a/scripts/update.sh b/scripts/update.sh deleted file mode 100755 index 923aac6..0000000 --- a/scripts/update.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# SPDX-License-Identifier: AGPL-3.0-or-later -# Update to the latest code (08 §3.1). Migrations run when the api starts. Calls in progress drop -# when Asterisk's container is recreated, so run it when the phones are quiet. -# Usage: scripts/update.sh (run from anywhere) -set -eu -cd "$(dirname "$0")/.." -git pull --ff-only -profile=$(docker ps -q --filter label=com.docker.compose.project=selfphoneng --filter label=com.docker.compose.service=caddy | grep -q . && echo '--profile caddy' || true) -# shellcheck disable=SC2086 # empty or one flag -docker compose $profile pull --ignore-buildable # postgres, caddy -# shellcheck disable=SC2086 -docker compose $profile build --pull -# shellcheck disable=SC2086 -docker compose $profile up -d -echo "Updated to $(sed -n 's/^ "version": "\(.*\)",$/\1/p' package.json) ($(git rev-parse --short HEAD))"