+ 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.

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))"