25 Commits
Author SHA1 Message Date
iamdoubz 10b499e263 Merge pull request 'Security/freepbx campaign' (#10) from security/freepbx-campaign into main
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 4s
release / images (release) Successful in 59s
Reviewed-on: #10
2026-10-29 18:48:11 -05:00
iamdoubz 34f443385d release: version 0.0.13
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 22s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 4s
2026-10-29 18:40:30 -05:00
iamdoubz f435b475ed repo: ignore voip.ms's API example bundle
ci / check (push) Successful in 1m19s
ci / images (push) Successful in 1m30s
2026-10-29 18:35:06 -05:00
iamdoubz 1979a02827 voipms: IP restriction on the setup checklist, getCDR checked against voip.ms's examples 2026-10-29 18:35:06 -05:00
iamdoubz 08bc611dec security: fraud alerts (M11-T10), admin access spec (M11-T9) 2026-10-29 18:35:06 -05:00
iamdoubz 63286a1954 asterisk: AMI limited to the classes we use, no shell-running modules in the image 2026-10-29 18:35:06 -05:00
iamdoubz 6bd2aaec56 Merge pull request 'Asterisk/24.0.0 rc3' (#9) from asterisk/24.0.0-rc3 into main
ci / check (push) Successful in 1m11s
ci / images (push) Successful in 48s
release / images (release) Successful in 2m10s
Reviewed-on: #9
2026-10-28 17:23:19 -05:00
iamdoubz 80e97b4118 release: version 0.0.12
ci / check (push) Successful in 1m25s
ci / images (push) Successful in 2m35s
ci / check (pull_request) Successful in 1m13s
ci / images (pull_request) Successful in 5s
2026-10-28 17:13:31 -05:00
iamdoubz 0a18d5354d asterisk: 24.0.0-rc3, TLS trunks qualified again
rc3 checks a qualify's certificate against the host name, so TLS trunks get
qualify_frequency = 60 and Test qualifies them. The upgrade check and
pnpm test:asterisk pass on rc3. Spec 01's copy of the Dockerfile is in sync
again, and the version test now compares all of it.
2026-10-28 17:11:18 -05:00
iamdoubz a0121d4975 test: Asterisk upgrade check
verified.ts records what the code relies on Asterisk doing and the version it
was measured on; pnpm test fails while the Dockerfile pins another one.
pnpm test:asterisk-upgrade boots the image next to a TLS provider and lists
every behaviour that moved and what depends on it.
2026-10-28 17:01:26 -05:00
iamdoubz 678d45e4f9 Merge pull request 'Security/review 2' (#8) from security/review-2 into main
ci / check (push) Successful in 1m14s
ci / images (push) Successful in 53s
Reviewed-on: #8
2026-10-28 16:59:55 -05:00
iamdoubz 60348cf0c5 security: a key for the network test probe (08 §7 #9)
ci / check (push) Successful in 1m13s
ci / images (push) Successful in 4m2s
ci / check (pull_request) Successful in 1m42s
ci / images (pull_request) Successful in 28s
Settings → Network makes a key, shown once and stored encrypted; the api sends
it to the probe as a Bearer token, and a new probe address drops it. A probe
with PROBE_KEY only answers that key. Without one it stays open, and
Diagnostics strongly recommends setting it.
2026-10-28 16:28:23 -05:00
iamdoubz 958405651b docs: security review 2 (08 §7) 2026-10-28 16:01:57 -05:00
iamdoubz db244b116a security: recordings, webhooks, Contrack tokens, probe (08 §7 #3 #4 #6 #7)
A phone that only rang no longer gets a coworker's recording, transcript or
summary. The voicemail webhook leaves the transcript out. The saved Contrack
token only goes to its own address. The probe trusts X-Forwarded-For from a
local proxy only.
2026-10-28 16:01:56 -05:00
iamdoubz 1db5aa22dc security: sign-in by email and connecting accounts (08 §7 #1 #2 #5 #8)
Matching by email skips admins and anyone with a connected account, in both
directions. A connected directory account is only swapped by an admin, and
an OpenID connect finishes only in the browser whose session asked.
2026-10-28 16:01:56 -05:00
iamdoubz 8a1f17099d Merge pull request 'Chore/qol' (#7) from chore/qol into main
ci / check (push) Successful in 1m17s
ci / images (push) Successful in 5s
Reviewed-on: #7
2026-10-28 15:18:49 -05:00
iamdoubz a75113b6ac web: report spam from my calls, reports on Blocked numbers
ci / check (push) Successful in 1m24s
ci / images (push) Successful in 25s
ci / check (pull_request) Successful in 1m15s
ci / images (pull_request) Successful in 7s
Users report an outside call that rang them: the spam check's line when it
said medium or high, else a reason they type. Admins block or dismiss each
report on Blocked numbers, and the Dashboard counts them. Migration 0015 adds
spam_reports.
2026-10-28 15:07:05 -05:00
iamdoubz 2bc7d5cde5 web: my reports, the calls made to me
Users get a Reports tab: the admin summary limited to calls that rang or were
answered by them. Answered and talk time are the calls they picked up.
2026-10-28 13:39:33 -05:00
iamdoubz 19ca4ffc91 docs: v1 checklist ticks, DID in the Request-URI
ci / check (push) Successful in 1m12s
ci / images (push) Successful in 30s
The outside install, restores, the week on the owner's server, Portainer
updates and both registries are done. The mobile app is after v1.
2026-10-28 13:28:42 -05:00
iamdoubz 1bc0fe8811 docs: CrowdSec rule for slow SIP scanners
ci / check (push) Successful in 1m21s
ci / images (push) Successful in 22s
The collection's asterisk_bf and asterisk_user_enum hold 5 failures per
address and drain one every 10 s, so a scanner trying every 20 s (two
failures per try: InvalidAccountID, ChallengeResponseFailed) drains as
fast as it fills and is never banned. Seen on a live install: hours of
INVITEs from one address, no decision.

network.md: a local scenario, selfphoneng/asterisk-slow-bf (10 failures,
one drained every 5 min), a table of how it differs from the stock rule,
a whitelist for remote offices and phones, how to read cscli metrics,
and cscli explain with a log line (pointing --file at the acquisition
YAML parses the YAML itself and fails on every line).
2026-10-28 10:56:40 -05:00
iamdoubz add90204c0 Merge pull request 'Feat/ui refresh' (#6) from feat/ui-refresh into main
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 5s
release / images (release) Successful in 58s
Reviewed-on: #6
2026-10-28 10:22:30 -05:00
iamdoubz 4369dd5ae1 release: version 0.0.11
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 22s
ci / check (pull_request) Successful in 1m10s
ci / images (pull_request) Successful in 9s
2026-10-28 10:04:42 -05:00
iamdoubz 3d0f091bba web: icons, grouped admin sidebar, Home cards, phone-style softphone
ci / check (push) Successful in 2m5s
ci / images (push) Successful in 3m7s
lucide-react (1.56.0) is the one icon set; it hides icons from screen
readers by default and only the icons used are bundled (+6 kB gzipped).
It replaces the ✕ ☀ ☾ ↑ ↓ ▶ ✓ ← characters and the hand-drawn menu and
phone icons.

The admin sidebar's 21 pages are grouped under Call routing, People,
Features, History and System, each with an icon; the current page gets a
left bar. The My Phone tabs have icons and a blue active state.

Home is a greeting and cards (new shared Panel): My status, Missed calls
with a call-back button, New voicemails, with friendly empty states.

Softphone: a status pill (dot and word), call controls as icon-over-label
buttons that are toggles with aria-pressed (Mute, Hold, Keypad, Transfer,
red Hang up), and a round keypad with the letters under the digits.
2026-10-28 10:00:29 -05:00
iamdoubz 5db4914fa7 web: theme colors from the logo, readable in both themes
Pages used zinc and sky classes with every dark: variant paired by hand,
and several pairs failed WCAG AA: white on the sky-600 buttons (4.1:1),
zinc-500 secondary text on dark backgrounds (3.7-4.1:1), the 10px zinc-400
labels (2.6:1) and the dashboard's green "Connected" (3.3:1).

index.css now defines theme tokens once, light and dark side by side
(page, surface, subtle, line, line-strong, fg, muted, primary and its
soft/line/ink shades, link, focus), exposed to Tailwind with @theme
inline. Pages use those names, so a palette change is one file. Blue
#2563eb from the logo for buttons and links, the logo's navy #0b1120 for
the dark page. Every text pair is at least 4.5:1 in both themes, most 7:1.

Cards and lists sit on the surface color with a light shadow on an
off-white page, at rounded-xl; native controls follow the theme
(color-scheme). Zinc and sky stay only as data colors: status dots and
chart swatches.
2026-10-28 10:00:20 -05:00
iamdoubz fd3f06c167 web: answer calls and navigate on a phone
On an iPhone the incoming call dimmed the screen with no buttons: iOS
zooms into fields under 16px (the 14px login inputs) and stays zoomed,
and the My Phone tabs were wider than the screen, so the centered
dialog was drawn outside what the phone showed.

Fields are 16px on touch screens and buttons at least 44px. The My Phone
tabs and header links fold into a menu button below tablet width. A
ringing call gets its own screen: full screen on a phone, a card on wider
screens, with round Decline and Answer buttons. Esc still declines.
2026-10-28 09:59:39 -05:00
128 changed files with 8612 additions and 927 deletions

No files matched your search

+1
View File
@@ -39,3 +39,4 @@
**/*.key
**/*.log
**/*.pid
docs/voip.ms-api
+2
View File
@@ -8,3 +8,5 @@ coverage/
*.pid
# The mobile app is its own repo (13), checked out here beside the PBX.
mobile/
# voip.ms's API example bundle (06 §5.1): their copyright, kept locally for reference.
docs/voip.ms-api/
+74
View File
@@ -5,6 +5,80 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
Docker.
## v0.0.13 — 2026-10-29
**Updating:** no database changes. Portainer: change both tags to `0.0.13` and redeploy. Compose:
`sudo scripts/update.sh v0.0.13`.
Hardening after the attacks on FreePBX servers (spec 08 §8), where stolen trunk passwords were used
for toll fraud:
- **Fraud alerts:** every admin gets an email, and the System page a warning, when the phone system
calls an international or premium number (including Caribbean area codes, which are dialed like
a US call but cost like abroad), when voip.ms bills calls on the trunk that this phone system
didn't make, or when the voip.ms balance drops more than 10 in an hour. The last two need the
voip.ms API login on the trunk. All three can be changed on Settings → Security → **Fraud
alerts**.
- **voip.ms IP restriction** (strongly recommended): the setup checklist on Trunks now says to
turn it on for the sub-account, with only this server's public IP. A stolen trunk password is
then useless anywhere else ([voip.ms](docs/admin-guide/voipms.md#1-in-the-voipms-portal)).
- **Asterisk:** the api's manager login (AMI) can only do what SelfPhoneNG uses, and the image no
longer has the modules that run commands on the server.
## v0.0.12 — 2026-10-28
**Before you update:** this version changes the database (spam reports); the api backs up first,
by itself. Portainer: change both tags to `0.0.12` and redeploy. Compose:
`sudo scripts/update.sh v0.0.12`. If you run the network test probe, update it too (see
**Probe key** below).
- **My reports:** everyone with an extension gets a Reports tab: the calls that rang them, how
many they answered and missed, talk time, calls and missed calls per day, and their busiest
hours.
- **Report spam:** on the Calls page, an incoming outside call has a **Report spam** button. When
the spam check flagged the call, its finding is the reason; otherwise the person types one.
Reports wait at the top of **Blocked numbers** for an admin to Block or Dismiss, and the
Dashboard counts them.
- **Second security review** (spec 08 §7), eight findings fixed, none critical:
- Signing in by email (OpenID or directory) no longer matches admins or anyone who already has
a connected sign-in, and connecting an OpenID account only finishes in the browser that
started it.
- **Recordings, transcripts and AI summaries** only go to whoever made or answered the call.
Ring group and queue members whose phone only rang no longer get a coworker's recording, nor
its summary in their call report email or on their Contrack timeline.
- The `voicemail.new` webhook no longer carries the voicemail's transcript.
- Changing the Contrack address asks for the company token again, and everyone reconnects
their own Contrack token.
- A directory account can only be swapped for another by an admin.
- **Probe key** (strongly recommended): Settings → Network → **Generate key**, then set it as
`PROBE_KEY` on the probe and restart it. The probe then only answers your server. Without a key
it still works, and Diagnostics warns. Behind your own reverse proxy (`TRUST_PROXY=true`), the
proxy must run on the probe's machine.
- **Asterisk 24.0.0-rc3.** TLS trunks get a health check again, and Test checks them too (rc2
couldn't check them over TLS). `pnpm test:asterisk-upgrade` now checks what SelfPhoneNG relies
on Asterisk doing before any version change.
- **Docs:** a CrowdSec rule for slow SIP scanners, and how to check that CrowdSec bans
([network](docs/admin-guide/network.md#crowdsec)).
## v0.0.11 — 2026-10-28
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
`sudo scripts/update.sh v0.0.11`.
- **Phones:** on an iPhone a ringing call dimmed the screen but showed no Answer button (iOS
zoomed into the small sign-in fields and stayed zoomed, so the call dialog was off screen).
Fields are now 16px on touch screens and buttons at least 44px. A ringing call gets its own
screen: full screen on a phone, with big round Decline and Answer buttons. My Phone's tabs fold
into a menu button on narrow screens.
- **New look:** colors from the logo (blue, and its navy for dark mode), cards on an off-white
page, and text that is easy to read in both themes (all text at least WCAG AA). Icons
throughout.
- **Admin sidebar** grouped into Call routing, People, Features, History and System.
- **My Phone Home** is a set of cards: My status, Missed calls (with call back) and New
voicemails.
- **Browser phone:** a status pill (Ready, Connecting…), phone-style call buttons with icons
(Mute, Hold, Keypad, Transfer, Hang up) and a round keypad with the letters under the digits.
## v0.0.10 — 2026-10-27
**Before you update:** this version changes the database (spam check); the api backs up first,
+1 -1
View File
@@ -7,7 +7,7 @@
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
**Status:** pre-1.0 (v0.0.10), for testing. See [what's left for v1](docs/v1-checklist.md).
**Status:** pre-1.0 (v0.0.13), for testing. See [what's left for v1](docs/v1-checklist.md).
## What it does today
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/api",
"version": "0.0.10",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+8 -4
View File
@@ -18,6 +18,7 @@ import {
import { todayIn } from '../generator/load.ts';
import { getSetting } from '../services/settings.ts';
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
/** A recorded call waits this long for its summary before it's logged without one. */
const SUMMARY_WAIT_MS = 30 * 60_000;
@@ -141,11 +142,13 @@ export async function logCallsToContrack(d: {
.limit(500);
// One entry per call and person (a person can be in a call as caller, rang and answered).
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
// `heard`: made or answered it; a phone that only rang gets no recording or summary (08 §7 #3).
const todo = new Map<string, (typeof rows)[number] & { out: boolean; heard: boolean }>();
for (const r of rows) {
const k = `${r.call.id}|${r.userId}`;
const out = r.role === 'caller' || todo.get(k)?.out === true;
todo.set(k, { ...r, out });
const heard = ON_THE_CALL.includes(r.role) || todo.get(k)?.heard === true;
todo.set(k, { ...r, out, heard });
}
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
let logged = 0;
@@ -169,7 +172,8 @@ export async function logCallsToContrack(d: {
await record(null);
continue;
}
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
const recorded = r.heard && r.call.recordingId !== null;
const wantSummary = r.withSummary && summariesOn && recorded;
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
continue; // the transcript job hasn't summarized it yet
const e = timelineEntry(
@@ -180,7 +184,7 @@ export async function logCallsToContrack(d: {
toNumber: r.call.toNumber,
talkSeconds: r.call.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
recorded: r.call.recordingId !== null,
recorded,
},
wantSummary ? r.summary : null,
);
@@ -0,0 +1,11 @@
CREATE TABLE "spam_reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"number" text NOT NULL,
"reason" text NOT NULL,
"reported_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_reports_number_reportedBy_unique" UNIQUE("number","reported_by")
);
--> statement-breakpoint
ALTER TABLE "spam_reports" ADD CONSTRAINT "spam_reports_reported_by_users_id_fk" FOREIGN KEY ("reported_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -106,6 +106,13 @@
"when": 1793127210386,
"tag": "0014_spam_check",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1793212840276,
"tag": "0015_spam_reports",
"breakpoints": true
}
]
}
+13
View File
@@ -670,6 +670,19 @@ export const ftcComplaints = pgTable(
(t) => [index().on(t.number), index().on(t.reportedAt)],
);
/** "Report spam" from a user's call history, waiting for an admin (05 §2 Blocked numbers). */
export const spamReports = pgTable(
'spam_reports',
{
id: id(),
number: text().notNull(),
reason: text().notNull(),
reportedBy: uuid().references(() => users.id, { onDelete: 'set null' }),
...timestamps,
},
(t) => [unique().on(t.number, t.reportedBy)],
);
export const callExtensions = pgTable(
'call_extensions',
{
@@ -140,7 +140,7 @@ endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = aor
contact = sip:chicago1.voip.ms:5061;transport=tls
qualify_frequency = 0
qualify_frequency = 60
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = endpoint
+3 -5
View File
@@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
section(`trunk-${id}`, [
'type = aor',
`contact = sip:${target}`,
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health.
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
// Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name
// (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname).
'qualify_frequency = 60',
]),
section(`trunk-${id}`, [
'type = endpoint',
+55 -2
View File
@@ -2,8 +2,8 @@
// voip.ms REST/JSON API (06 §5.1): GET rest.php?api_username&api_password&method&…, JSON back with
// `status` = "success" or an error code. The password is in the query string, so the URL is never
// logged or put in an error.
// TODO(spec): response shapes and error codes are from the 06 spec, not a real account yet
// (voip.ms's API docs need a login); check them with the owner's account and fix here.
// TODO(spec): most response shapes and error codes are from the 06 spec, not a real account yet;
// voip.ms's example bundle (docs/voip.ms-api) only shows getCDR's. Check with the owner's account.
import { normalizeNumber } from '@selfphoneng/shared';
export type VoipmsCreds = { username: string; password: string };
@@ -106,6 +106,59 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise<Voip
});
}
export type VoipmsCall = {
at: Date;
destination: string;
account: string;
disposition: string;
seconds: number;
total: string;
uniqueid: string;
};
/**
* One sub-account's call records between two UTC dates (getCDR, 06 §5.1). `timezone=0` makes
* `date` UTC. The request, `no_cdr` and most fields are in voip.ms's examples; `account`,
* `disposition` and `uniqueid` aren't (verify), and come back '' if missing.
*/
export async function getCdr(
baseUrl: string,
creds: VoipmsCreds,
q: { from: string; to: string; account: string },
): Promise<VoipmsCall[]> {
let r: Record<string, unknown>;
try {
r = await voipmsApi(baseUrl, creds, 'getCDR', {
date_from: q.from,
date_to: q.to,
answered: '1',
noanswer: '1',
busy: '1',
failed: '1',
timezone: '0',
account: q.account,
});
} catch (e) {
if (e instanceof VoipmsError && e.status === 'no_cdr') return [];
throw e;
}
return list(r.cdr).flatMap((c) => {
const at = new Date(`${str(c.date).replace(' ', 'T')}Z`);
if (Number.isNaN(at.getTime())) return [];
return [
{
at,
destination: str(c.destination),
account: str(c.account),
disposition: str(c.disposition),
seconds: Number(str(c.seconds)) || 0,
total: str(c.total),
uniqueid: str(c.uniqueid),
},
];
});
}
/**
* The account's balance (getBalance → balance.current_balance, in the account's currency).
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
+14 -1
View File
@@ -31,6 +31,7 @@ import { logCallsToContrack } from './contacts/timeline.ts';
import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { createFraudWatch } from './services/fraud.ts';
import { spamQuotaCheck } from './spam/check.ts';
import { ftcSync } from './spam/ftc.ts';
import { deliverDue } from './services/webhooks.ts';
@@ -130,6 +131,13 @@ export async function retentionCleanup(d: JobDeps) {
export function createJobs(d: JobDeps) {
const now = d.now ?? (() => new Date());
const tz = () => getSetting(d.db, 'general.timezone');
const fraud = createFraudWatch({
db: d.db,
voipmsUrl: d.config.VOIPMS_API_URL,
mail: (subject, text) => mailAdmins(d, subject, text),
log: d.log,
now,
});
// Holidays roll over at midnight (03 §6.10): render again when the local date changes.
// Retention runs then too (and at start), not at 02:00.
@@ -195,7 +203,8 @@ export function createJobs(d: JobDeps) {
`Public IP changed to ${ip}`,
`The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\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.',
'Phones and the provider may need the new IP if they were set up with the old one ' +
'(voip.ms: the sub-account’s IP restriction or IP authentication, and the API whitelist).',
);
};
@@ -296,6 +305,8 @@ export function createJobs(d: JobDeps) {
publicIpWatch,
trunkWatch,
diskWatch,
riskyCallWatch: fraud.riskyCallWatch,
providerWatch: fraud.providerWatch,
callReports,
contactsWarm,
contrackLog,
@@ -321,6 +332,8 @@ export function startJobs(d: JobDeps): () => void {
run('trunkWatch', j.trunkWatch);
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('riskyCallWatch', j.riskyCallWatch);
if (minute % 15 === 0) run('providerWatch', j.providerWatch);
run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
+3 -5
View File
@@ -213,12 +213,10 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
async testTrunk(
id: string,
{ waitMs = 1500, qualify = true } = {},
{ waitMs = 1500 } = {},
): Promise<{ live: TrunkLive; rttMs: number | null }> {
if (qualify) {
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
}
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
await this.refreshRegistrations();
const rttMs = await this.refreshReachable(id);
const live = this.trunks.get(id);
+28 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk
// entry checks (03 §6.4). Admin only.
// entry checks (03 §6.4). /api/spam-reports: users' Report spam, to block or dismiss. Admin only.
import { desc, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
@@ -9,9 +9,10 @@ import {
formatNumber,
type BlockedDto,
type ListResponse,
type SpamReportDto,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { blockedNumbers } from '../db/schema.ts';
import { blockedNumbers, spamReports, users } from '../db/schema.ts';
import { conflict, notFound } from '../errors.ts';
import { setBlock } from '../services/astdb.ts';
import { audit } from '../services/audit.ts';
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
throw e;
}
await mirror(b.number, true);
await db.delete(spamReports).where(eq(spamReports.number, b.number));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
});
return { ok: true };
});
app.get('/api/spam-reports', async (): Promise<ListResponse<SpamReportDto>> => ({
items: (
await db
.select({ r: spamReports, name: users.displayName })
.from(spamReports)
.leftJoin(users, eq(users.id, spamReports.reportedBy))
.orderBy(desc(spamReports.createdAt))
).map(({ r, name }) => ({
id: r.id,
number: r.number,
reason: r.reason,
reportedBy: name,
createdAt: r.createdAt.toISOString(),
})),
nextCursor: null,
}));
app.delete('/api/spam-reports/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const [r] = await db.delete(spamReports).where(eq(spamReports.id, id)).returning();
if (!r) throw notFound('Already handled');
return { ok: true };
});
}
+56 -3
View File
@@ -1,11 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging.
import { and, asc, desc, eq, ilike, inArray, or, sql, type SQL } from 'drizzle-orm';
// /api/me/spam-reports: Report spam on a call that rang me (05 §4).
import { and, asc, desc, eq, ilike, inArray, ne, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { alias } from 'drizzle-orm/pg-core';
import { z } from 'zod';
import {
CallFilterSchema,
SpamReportCreateSchema,
formatNumber,
normalizeNumber,
type CallDetailDto,
type CallDto,
type ListResponse,
@@ -14,9 +18,18 @@ import {
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
import {
blockedNumbers,
callExtensions,
calls,
cdr,
dids,
extensions,
spamReports,
} from '../db/schema.ts';
import { contactNames } from '../contacts/book.ts';
import { badRequest, notFound } from '../errors.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
import { getSetting } from '../services/settings.ts';
const answerer = alias(extensions, 'answerer');
@@ -216,6 +229,8 @@ export async function callRoutes(app: FastifyInstance) {
const out = mine.includes('caller');
return {
...c,
// Only rang (a ring group): someone else's conversation (08 §7 #3).
recordingId: mine.some((r) => ON_THE_CALL.includes(r)) ? c.recordingId : null,
myRole: out ? 'out' : 'in',
missedByMe: !out && (MISSED as readonly string[]).includes(c.status),
};
@@ -224,4 +239,42 @@ export async function callRoutes(app: FastifyInstance) {
};
},
);
/** The admins see it on Blocked numbers and block or dismiss it. */
app.post('/api/me/spam-reports', { preHandler: requireAuth }, async (req, reply) => {
const body = SpamReportCreateSchema.parse(req.body);
const [c] = await db
.select({ number: calls.fromNumber })
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(
and(
eq(calls.id, body.callId),
eq(calls.direction, 'inbound'),
eq(extensions.userId, req.user!.id),
ne(callExtensions.role, 'caller'),
),
)
.limit(1);
if (!c) throw notFound('Not a call from outside that rang you');
let number: string;
try {
number = normalizeNumber(c.number ?? '');
} catch {
throw badRequest("This caller's number was hidden, so it can't be blocked");
}
const [blocked] = await db
.select({ id: blockedNumbers.id })
.from(blockedNumbers)
.where(eq(blockedNumbers.number, number));
if (blocked) throw conflict(`${formatNumber(number)} is already blocked`);
const [r] = await db
.insert(spamReports)
.values({ number, reason: body.reason, reportedBy: req.user!.id })
.onConflictDoNothing()
.returning();
if (!r) throw conflict(`You already reported ${formatNumber(number)}`);
return reply.code(201).send({ ok: true });
});
}
+3 -1
View File
@@ -270,8 +270,10 @@ export async function contactRoutes(app: FastifyInstance) {
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
// The saved token only for the saved address: another one gets none (08 §7 #7).
const same = saved?.url === b.url.replace(/\/+$/, '');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
b.companyToken || (same && saved.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
+21 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
import { and, eq, gt, isNull, notExists } from 'drizzle-orm';
import { and, eq, gt, isNull, ne, notExists } from 'drizzle-orm';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { decrypt, encrypt } from '../crypto/secrets.ts';
@@ -15,7 +15,7 @@ import {
} from '../errors.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
import { createSession, setSessionCookie } from '../auth/sessions.ts';
import { COOKIE, createSession, getSessionUser, setSessionCookie } from '../auth/sessions.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, verifiedEmail } from '../sso/claims.ts';
@@ -146,7 +146,13 @@ export async function oidcRoutes(app: FastifyInstance) {
.where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
if (connecting) {
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in.
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in, and
// only in that browser: a planted sp_oidc can't connect someone else's account (08 §7 #8).
const here = req.cookies[COOKIE]
? await getSessionUser(db, req.cookies[COOKIE], false)
: null;
if (here?.sessionId !== connecting)
return fail('expired', 'connect finished in another browser than the one that asked');
const [sess] = await db
.select({ userId: sessions.userId, email: users.email })
.from(sessions)
@@ -186,7 +192,8 @@ export async function oidcRoutes(app: FastifyInstance) {
let userId = owner?.userId;
if (!userId) {
// A user without a password or an OpenID account yet, by verified email (12 §3.3 step 2).
// A user without a password or any connected account yet, by verified email (12 §3.3
// step 2). Never an admin: a provider account with their email isn't them (08 §7 #2).
const v = verifiedEmail(r.claims, s.trustEmail);
const [u] = v
? await db
@@ -196,13 +203,12 @@ export async function oidcRoutes(app: FastifyInstance) {
and(
eq(users.email, v),
isNull(users.passwordHash),
ne(users.role, 'admin'),
notExists(
db
.select({ id: userIdentities.id })
.from(userIdentities)
.where(
and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
),
.where(eq(userIdentities.userId, users.id)),
),
),
)
@@ -322,8 +328,15 @@ export async function oidcRoutes(app: FastifyInstance) {
recordLoginFailure(req.ip, login);
throw badRequest('The directory refused that username or password');
}
if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
const r = await connectDirectory(db, req.log, req.user!.id, entry, req.ip);
if (r === 'taken')
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
if (r === 'connected_other')
throw new HttpError(
409,
'connected_other',
'Another directory account is connected. Ask an admin to disconnect it first',
);
return { ok: true };
});
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
+12 -2
View File
@@ -13,6 +13,7 @@ import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
import { badRequest, forbidden, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { sideFiles } from '../ai/calls.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
/** The recording and, when call transcripts left them, its two side files. */
const removeFiles = async (dir: string, filePath: string) => {
@@ -109,7 +110,10 @@ export async function recordingRoutes(app: FastifyInstance) {
},
);
/** Admins, or a participant: their extension is on the call this recording belongs to. */
/**
* Admins, or who was on the call: their extension made or answered it. Not a phone that only
* rang (a ring group, a queue): that's a coworker's conversation (08 §7 #3).
*/
async function mayHear(user: { id: string; role: string }, recordingId: string) {
if (user.role === 'admin') return;
const [mine] = await db
@@ -117,7 +121,13 @@ export async function recordingRoutes(app: FastifyInstance) {
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
.where(
and(
eq(calls.recordingId, recordingId),
eq(extensions.userId, user.id),
inArray(callExtensions.role, ON_THE_CALL),
),
)
.limit(1);
if (!mine) throw forbidden('Not your call');
}
+39 -13
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
import { and, asc, eq, sql } from 'drizzle-orm';
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
// but /api/me/reports/summary: the same summary of the calls made to me.
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import {
CALL_DIRECTIONS,
@@ -10,12 +11,12 @@ import {
type ProviderBalanceDto,
type ReportSummary,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { decrypt } from '../crypto/secrets.ts';
import { calls, queues, trunks } from '../db/schema.ts';
import { callExtensions, calls, extensions, queues, trunks } from '../db/schema.ts';
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
import { badRequest } from '../errors.ts';
import { badRequest, notFound } from '../errors.ts';
import { todayIn } from '../generator/load.ts';
import { queueStats } from '../services/queuestats.ts';
import { getSetting } from '../services/settings.ts';
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
export async function reportRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
const admin = { preHandler: requireAdmin };
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
const { tz, from, to } = await range(db, req.query);
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
const { tz, from, to } = await range(db, query);
const where = and(
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
toExt
? inArray(
calls.id,
db
.select({ id: callExtensions.callId })
.from(callExtensions)
.where(and(eq(callExtensions.extensionId, toExt), ne(callExtensions.role, 'caller'))),
)
: undefined,
);
// Mine: answered and talk time are the calls I picked up, not a coworker in my ring group.
const answered = toExt
? sql`${calls.answeredByExtensionId} = ${toExt}`
: sql`${calls.status} = 'answered'`;
const local = sql`(${calls.startedAt} at time zone ${tz})`;
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
db
.select({
calls: sql<number>`count(*)::int`,
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
answered: sql<number>`count(*) filter (where ${answered})::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 0)::int`,
lineIn: sql<number>`round(${lineIn})::int`,
lineOut: sql<number>`${lineOut}::int`,
avgTalk: sql<
number | null
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
})
.from(calls)
.where(where),
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
},
hours,
};
}
app.get('/api/reports/summary', admin, (req) => summary(req.query));
app.get('/api/me/reports/summary', { preHandler: requireAuth }, async (req) => {
const [ext] = await db
.select({ id: extensions.id })
.from(extensions)
.where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
return summary(req.query, ext.id);
});
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
const rows = await db
.select()
.from(trunks)
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
return { items, nextCursor: null };
});
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
app.get('/api/reports/queues', admin, async (req): Promise<ListResponse<QueueReportRow>> => {
const { from, to } = await range(db, req.query);
const qs = await db.select().from(queues).orderBy(asc(queues.number));
return {
+42 -3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
import { eq, inArray } from 'drizzle-orm';
import { randomBytes } from 'node:crypto';
import { eq, inArray, isNotNull } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
@@ -26,7 +27,7 @@ import {
type Warning,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { dids, extensions, trunks, users } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
@@ -63,6 +64,7 @@ const READ_ONLY = new Set<string>([
'spam.state',
'sms.webhookToken',
'setup.completed',
'network.probeKeyEnc',
]);
async function toDto(db: Db): Promise<SettingsDto> {
@@ -72,10 +74,12 @@ async function toDto(db: Db): Promise<SettingsDto> {
'auth.oidc': oidc,
'auth.ldap': ldap,
spam,
'network.probeKeyEnc': probeKey,
...rest
} = await getAllSettings(db);
return {
...rest,
'network.hasProbeKey': probeKey !== null,
spam: {
...spam,
ipqs: spam.ipqs && { hasKey: true },
@@ -257,6 +261,7 @@ export async function settingsRoutes(app: FastifyInstance) {
app.patch('/api/settings', async (req) => {
const body = z.record(z.string(), z.unknown()).parse(req.body);
const values: Record<string, unknown> = {};
let contrackMoved = false;
for (const [key, raw] of Object.entries(body)) {
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
throw badRequest(`${key} can't be changed here`);
@@ -287,8 +292,13 @@ export async function settingsRoutes(app: FastifyInstance) {
} catch (err) {
throw badRequest(`Contrack address: ${(err as Error).message}`);
}
// The saved tokens only go back to the address they were typed for (08 §7 #7).
const url = input?.url.replace(/\/+$/, '');
const moved = !!old && !!input && old.url !== url;
if (moved && old.companyTokenEnc && !input.companyToken && !input.clearCompanyToken)
throw badRequest('The Contrack address changed: enter the company token again');
values[key] = input && {
url: input.url.replace(/\/+$/, ''),
url: url!,
publicUrl: input.publicUrl,
companyTokenEnc: input.companyToken
? encrypt(input.companyToken)
@@ -296,6 +306,7 @@ export async function settingsRoutes(app: FastifyInstance) {
? ''
: (old?.companyTokenEnc ?? ''),
};
if (moved) contrackMoved = true;
forgetBook(null);
} else if (key === 'auth.ldap') {
values[key] = await ldapFromInput(raw);
@@ -349,8 +360,17 @@ export async function settingsRoutes(app: FastifyInstance) {
const cert = await readTlsCert(app.config.TLS_DIR);
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
}
// The probe's key was made for its address: another probe doesn't get it (08 §7 #9).
if ('network.probeUrl' in values && values['network.probeUrl'] !== before['network.probeUrl'])
values['network.probeKeyEnc'] = null;
for (const [key, value] of Object.entries(values))
await setSetting(db, key as SettingKey, value as never);
// People's own Contrack tokens were for the old address: they connect again (08 §7 #7).
if (contrackMoved)
await db
.update(users)
.set({ contrackTokenEnc: null })
.where(isNotNull(users.contrackTokenEnc));
// A new IPQualityScore key may have credits: don't wait for spamQuotaCheck (14 §3).
if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key');
const changed = Object.keys(values).filter(
@@ -474,6 +494,25 @@ export async function settingsRoutes(app: FastifyInstance) {
},
);
/**
* A new key for the network test probe (08 §7 #9): shown this once, for its PROBE_KEY. Made for
* the probe address set now; the old key stops working on the next test.
*/
app.post('/api/settings/network/probe-key', async (req) => {
if (!(await getSetting(db, 'network.probeUrl')))
throw badRequest('Set the probe address first');
const key = randomBytes(32).toString('base64url');
await setSetting(db, 'network.probeKeyEnc', encrypt(key));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'settings',
summary: 'Made a new network test probe key',
});
return { key };
});
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
+6 -9
View File
@@ -208,21 +208,18 @@ export async function trunkRoutes(app: FastifyInstance) {
if (!t.enabled) throw badRequest('Turn the trunk on first');
if (!app.live.amiConnected)
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
// No OPTIONS over TLS (see the trunk's aor in generator/pjsip.ts).
const { live, rttMs } = await app.live.testTrunk(id, { qualify: t.transport !== 'tls' });
const { live, rttMs } = await app.live.testTrunk(id);
const reg = t.authMode === 'registration';
const detail =
reg && live.registration === 'registered'
? `Registered with ${t.serverHost}`
: reg && live.registration === 'rejected'
? `${t.serverHost} rejected the username or password`
: !reg && t.transport === 'tls'
? `Can't check a TLS trunk without registration from here: make a test call.`
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
return { registration: reg ? live.registration : 'unknown', rttMs, detail };
});
+6 -3
View File
@@ -138,6 +138,9 @@ export async function sendCallReports(d: {
aiDoneAt: recordings.aiDoneAt,
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
// Made or answered it: only rang means a coworker's conversation (08 §7 #3).
heard: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} in ('caller', 'answered'))`,
})
.from(calls)
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
@@ -177,9 +180,9 @@ export async function sendCallReports(d: {
toNumber: r.c.toNumber,
talkSeconds: r.c.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
recorded: r.c.recordingId !== null,
summary: r.summary,
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
recorded: r.heard && r.c.recordingId !== null,
summary: r.heard ? r.summary : null,
summaryPending: r.heard && r.c.recordingId !== null && r.aiDoneAt === null,
}));
const withSummaries =
e.callReportSummaries &&
+6
View File
@@ -14,6 +14,12 @@ import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/
import { durationMs } from '../media/audio.ts';
type CdrRow = typeof cdr.$inferSelect;
/**
* The call_extensions roles of someone who was on the call, not just rung: they may hear its
* recording and read its transcript and summary (08 §7 #3).
*/
export const ON_THE_CALL = ['caller', 'answered'];
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
/** cdr_manager.conf has usegmtime = yes: "2026-10-18 19:28:37" is UTC. */
+23 -6
View File
@@ -9,6 +9,7 @@ import { BlockList, isIPv4 } from 'node:net';
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
import type { AmiClient } from '../ami/client.ts';
import type { Config } from '../config.ts';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { detectPublicIp } from '../jobs.ts';
import { getAllSettings } from './settings.ts';
@@ -221,15 +222,23 @@ export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
);
out.push(
s['network.probeUrl']
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
: check(
!s['network.probeUrl']
? check(
'probe',
'Network test probe',
'skip',
'None set, so nothing is tested from outside your network.',
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
),
)
: s['network.probeKeyEnc']
? check('probe', 'Network test probe', 'ok', `${s['network.probeUrl']}, with its key`)
: check(
'probe',
'Network test probe',
'warn',
`${s['network.probeUrl']} has no key: anyone who finds it can use it.`,
'Settings → Network → Generate key, then set it as PROBE_KEY on the probe and restart it (strongly recommended).',
),
);
return out;
}
@@ -289,14 +298,20 @@ export async function networkTest(
let ip: string | null = null;
let results: ProbeResult[] = [];
let probeError: string | null = null;
let keyRefused = false;
const key = s['network.probeKeyEnc'];
try {
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: {
'content-type': 'application/json',
...(key && { authorization: `Bearer ${decrypt(key)}` }),
},
body: JSON.stringify({ host, tests }),
signal: AbortSignal.timeout(30_000),
});
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
keyRefused = r.status === 401;
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
// Datagrams sent last arrive just after the answer.
@@ -316,7 +331,9 @@ export async function networkTest(
'Network test probe',
'fail',
`${probe} didn't answer: ${probeError}`,
'Check the probe runs and is reachable from this server, then try again.',
keyRefused
? "The probe's PROBE_KEY isn't this server's key. Settings → Network → Generate key, set it as PROBE_KEY on the probe and restart it."
: 'Check the probe runs and is reachable from this server, then try again.',
),
],
};
+239
View File
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Fraud alerts (08 §8.3): risky calls the PBX placed, voip.ms calls it didn't place, and a
// balance that drops fast. Each is an email to every admin and a warning event.
import { and, eq, gt, gte, isNotNull, like, lte } from 'drizzle-orm';
import { formatNumber } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { calls, cdr, dids, trunks } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import {
getBalance,
getCdr,
VoipmsError,
type VoipmsCall,
type VoipmsCreds,
} from '../integrations/voipms/client.ts';
import { logEvent } from './events.ts';
import { getSetting } from './settings.ts';
/** NANP area codes outside the US and Canada: dialed like a home call, billed like abroad. (verify) */
const ABROAD_NANP = new Set(
'242 246 264 268 284 345 441 473 649 658 664 721 758 767 784 809 829 849 868 869 876'.split(' '),
);
/** Why a dialed number is worth an email, or null. 10 digits count as 1 + them. */
export function riskyDestination(dialed: string): string | null {
const n = /^[2-9]\d{9}$/.test(dialed) ? `1${dialed}` : dialed;
if (n.startsWith('011')) return 'international';
const m = /^1(\d{3})(\d{3})\d{4}$/.exec(n);
if (!m) return null;
if (m[1] === '900' || m[2] === '976') return 'premium';
if (ABROAD_NANP.has(m[1]!)) return 'international rates (outside the US and Canada)';
return null;
}
/** The number a trunk Dial sent (`PJSIP/<number>@trunk-<id>,120`), or null. */
export const trunkDialNumber = (lastdata: string | null): string | null =>
/^PJSIP\/([^@]+)@trunk-/.exec(lastdata ?? '')?.[1] ?? null;
const tail = (n: string) => n.replace(/\D/g, '').slice(-10);
const MIN = 60_000;
const day = (d: Date) => d.toISOString().slice(0, 10);
export type FraudDeps = {
db: Db;
voipmsUrl: string;
mail: (subject: string, text: string) => Promise<void>;
log: { warn(o: object, m: string): void };
now: () => Date;
};
export function createFraudWatch(d: FraudDeps) {
const when = async (at: Date) =>
new Intl.DateTimeFormat('en-US', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: await getSetting(d.db, 'general.timezone'),
}).format(at);
const warn = (code: string, source: string, message: string) =>
logEvent(d.db, { severity: 'warning', code, source, message });
// Calls are read 10 s behind the clock, so a row whose insert is still committing isn't skipped.
// ponytail: the cursor starts at api start; a call made while the api was down has no row anyway.
let since = new Date(d.now().getTime() - 10_000);
const riskyCallWatch = async () => {
const until = new Date(d.now().getTime() - 10_000);
const rows = await d.db
.select()
.from(calls)
.where(
and(
eq(calls.direction, 'outbound'),
gt(calls.createdAt, since),
lte(calls.createdAt, until),
),
)
.orderBy(calls.createdAt);
since = until;
if (!(await getSetting(d.db, 'alerts.riskyCalls'))) return;
const lines: string[] = [];
for (const c of rows) {
const why = riskyDestination(c.toNumber ?? '');
if (why)
lines.push(
`${await when(c.startedAt)} from ${c.fromNumber ?? '?'} to ${formatNumber(c.toNumber!)}: ` +
`${why}, ${c.status}, ${c.talkSeconds} s talking`,
);
}
if (!lines.length) return;
await warn(
'fraud.risky_call',
'Fraud alerts',
`${lines.length} call(s) to international or premium numbers`,
);
await d.mail(
`Calls to risky numbers (${lines.length})`,
'The phone system placed calls to numbers that can cost a lot:\n\n' +
`${lines.join('\n')}\n\n` +
"If you don't recognise them, someone may have a phone's password: change it " +
'(Extensions → the extension → New SIP password) and check Outbound rules.\n' +
'These emails can be turned off on Settings → Security → Fraud alerts.',
);
};
// voip.ms records already reported, for 3 hours (08 §8.3).
const reported = new Map<string, number>();
async function callCheck(t: { name: string; username: string }, creds: VoipmsCreds) {
const now = d.now().getTime();
for (const [id, at] of reported) if (at < now - 180 * MIN) reported.delete(id);
const from = new Date(now - 120 * MIN);
const to = new Date(now - 10 * MIN);
// uniqueid isn't in voip.ms's examples (06 §5.1), so the time and number make the key too.
const key = (r: VoipmsCall) => `${r.uniqueid}|${r.at.getTime()}|${r.destination}`;
const records = (
await getCdr(d.voipmsUrl, creds, { from: day(from), to: day(to), account: t.username })
).filter((r) => r.at >= from && r.at <= to && !reported.has(key(r)));
if (!records.length) return;
const ours = new Set((await d.db.select({ n: dids.number }).from(dids)).map((x) => tail(x.n)));
// Every call the PBX sent to a trunk, forwards and 911 included (03 §6.12, §6.14).
const sent = (
await d.db
.select({ at: cdr.startAt, data: cdr.lastdata })
.from(cdr)
.where(
and(
like(cdr.lastdata, 'PJSIP/%@trunk-%'),
gte(cdr.startAt, new Date(from.getTime() - 5 * MIN)),
lte(cdr.startAt, new Date(to.getTime() + 5 * MIN)),
),
)
).flatMap((r) => {
const n = trunkDialNumber(r.data);
return n && r.at ? [{ n: tail(n), at: r.at.getTime() }] : [];
});
const strange: VoipmsCall[] = [];
for (const r of records) {
reported.set(key(r), r.at.getTime());
if (r.account && r.account !== t.username) continue;
if (ours.has(tail(r.destination))) continue; // incoming, to one of our numbers
const i = sent.findIndex(
(s) => s.n === tail(r.destination) && Math.abs(s.at - r.at.getTime()) <= 5 * MIN,
);
if (i >= 0) sent.splice(i, 1);
else strange.push(r);
}
if (!strange.length) return;
const lines = await Promise.all(
strange.map(
async (r) =>
`${await when(r.at)} to ${r.destination}: ${r.disposition.toLowerCase() || '?'}, ` +
`${r.seconds} s, cost ${r.total}`,
),
);
await warn(
'fraud.provider_call',
`Trunk ${t.name}`,
`voip.ms has ${strange.length} call(s) on ${t.username} that this phone system didn't make`,
);
await d.mail(
`voip.ms calls this phone system didn't make (${strange.length})`,
`voip.ms billed these calls on sub-account ${t.username} (trunk ${t.name}), and this ` +
'phone system has no record of making them:\n\n' +
`${lines.join('\n')}\n\n` +
'Someone else may have the sub-account password. At voip.ms: change the sub-account ' +
"password (then paste it on Trunks), and turn on its IP restriction with this server's " +
'public IP. Check the voip.ms call records too.\n' +
'A call made while SelfPhoneNG was restarting, or by another device using the same ' +
'sub-account, also shows up here.\n' +
'These emails can be turned off on Settings → Security → Fraud alerts.',
);
}
// Balance samples per API login (one account can have several trunks), the last hour's.
const balances = new Map<string, { at: number; v: number }[]>();
const balanceMailed = new Map<string, number>();
async function balanceCheck(creds: VoipmsCreds, limit: number) {
const now = d.now().getTime();
const v = await getBalance(d.voipmsUrl, creds);
const samples = [
...(balances.get(creds.username) ?? []).filter((s) => s.at >= now - 60 * MIN),
{ at: now, v },
];
balances.set(creds.username, samples);
const high = Math.max(...samples.map((s) => s.v));
if (high - v <= limit || (balanceMailed.get(creds.username) ?? 0) > now - 60 * MIN) return;
balanceMailed.set(creds.username, now);
const drop = (high - v).toFixed(2);
await warn(
'fraud.balance_drop',
'Fraud alerts',
`The voip.ms balance dropped ${drop} in an hour`,
);
await d.mail(
`voip.ms balance dropped ${drop} in an hour`,
`The voip.ms balance of ${creds.username} went from ${high.toFixed(2)} to ${v.toFixed(2)} ` +
`within the last hour, more than the ${limit} set on Settings → Security → Fraud alerts.\n\n` +
"If that isn't your own calling, check the call records at voip.ms, change the " +
'sub-account and API passwords, and turn on IP restriction on the sub-account.',
);
}
const providerWatch = async () => {
const checkCalls = await getSetting(d.db, 'alerts.providerCalls');
const limit = await getSetting(d.db, 'alerts.balanceDropPerHour');
if (!checkCalls && !limit) return;
const rows = await d.db
.select({
name: trunks.name,
username: trunks.username,
apiUser: trunks.voipmsApiUsername,
apiPass: trunks.voipmsApiPasswordEnc,
})
.from(trunks)
.where(
and(
eq(trunks.enabled, true),
eq(trunks.provider, 'voipms'),
isNotNull(trunks.voipmsApiUsername),
isNotNull(trunks.voipmsApiPasswordEnc),
),
);
const balanceAsked = new Set<string>();
for (const t of rows) {
const creds = { username: t.apiUser!, password: decrypt(t.apiPass!) };
try {
if (checkCalls && t.username)
await callCheck({ name: t.name, username: t.username }, creds);
if (limit && !balanceAsked.has(creds.username)) {
balanceAsked.add(creds.username);
await balanceCheck(creds, limit);
}
} catch (e) {
if (!(e instanceof VoipmsError)) throw e;
d.log.warn({ trunk: t.name, status: e.status }, 'voip.ms fraud check failed');
}
}
};
return { riskyCallWatch, providerWatch };
}
+6 -5
View File
@@ -121,11 +121,12 @@ async function transcribeMessage(d: VoicemailDeps, row: Row): Promise<string | n
async function announce(d: VoicemailDeps, box: Box, found: Row) {
const transcript = await transcribeMessage(d, found);
const row = { ...found, transcript };
// Every mailbox, with or without a user (the live message is only for the user's own).
void enqueueWebhook(d.db, 'voicemail.new', {
...toVoicemailDto(row),
extension: box.number,
}).catch((err: unknown) => d.log.error({ err }, 'webhook enqueue failed'));
// Every mailbox, with or without a user (the live message is only for the user's own). Not
// the transcript: webhooks go off this server, and transcripts never do (10 §1, 08 §7 #6).
const { transcript: _words, ...hook } = toVoicemailDto(row);
void enqueueWebhook(d.db, 'voicemail.new', { ...hook, extension: box.number }).catch(
(err: unknown) => d.log.error({ err }, 'webhook enqueue failed'),
);
if (box.userId)
d.live.emit('message', {
type: 'voicemail.new',
+27 -8
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
// email. Never a new user (owner, 2026-10-25).
import { and, eq } from 'drizzle-orm';
import { and, eq, notExists } from 'drizzle-orm';
import { deleteUserSessions } from '../auth/sessions.ts';
import type { Db } from '../db/client.ts';
import { userIdentities, users } from '../db/schema.ts';
@@ -22,7 +22,21 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (byId) return byId.u;
if (!e.email) return null;
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
// Nobody with another way in either: an OpenID user's account is theirs to connect (08 §7 #1).
const [byEmail] = await db
.select()
.from(users)
.where(
and(
eq(users.email, e.email),
notExists(
db
.select({ id: userIdentities.id })
.from(userIdentities)
.where(eq(userIdentities.userId, users.id)),
),
),
);
return byEmail && byEmailOk(byEmail) ? byEmail : null;
}
@@ -35,19 +49,27 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
const byEmailOk = (u: UserRow) =>
u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
/** Connect a directory account the person proved (My settings, 12 §4.3). */
/**
* Connect a directory account the person proved (My settings, 12 §4.3). Not another one in place
* of the one they have: the leaver check follows it, so swapping is an admin's job (08 §7 #5).
*/
export async function connectDirectory(
db: Db,
log: { info(o: object, m: string): void },
userId: string,
e: DirectoryEntry,
ip: string,
): Promise<'connected' | 'taken'> {
): Promise<'connected' | 'taken' | 'connected_other'> {
const [owner] = await db
.select({ userId: userIdentities.userId })
.from(userIdentities)
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (owner && owner.userId !== userId) return 'taken';
const [mine] = await db
.select({ subject: userIdentities.subject })
.from(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
if (mine && mine.subject !== e.id) return 'connected_other';
await db.transaction(async (tx) => {
await tx
.delete(userIdentities)
@@ -101,11 +123,8 @@ export async function directorySignIn(
entityId: u.id,
summary: `${u.email} connected directory account ${e.dn}`,
});
} else if (mine.subject !== e.id) {
// Their email matches, but another directory account is already theirs: not this one.
log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
return null;
} else
// Found by this account's id (directoryUserFor never matches by email someone connected).
await db
.update(userIdentities)
.set({ email: e.email, lastUsedAt: new Date() })
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A new Asterisk in asterisk/Dockerfile must go through the upgrade check first (verified.ts).
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { VERIFIED_ASTERISK, pinnedAsterisk } from './asterisk/verified.ts';
describe('the pinned Asterisk', () => {
it('is the version the upgrade check last passed on', () => {
expect(
pinnedAsterisk(),
`asterisk/Dockerfile pins ${pinnedAsterisk()}, the upgrade check last passed on ` +
`${VERIFIED_ASTERISK}. Build the image, run \`pnpm test:asterisk-upgrade\` and ` +
'`pnpm test:asterisk`, act on what moved, then set VERIFIED_ASTERISK in ' +
'apps/api/test/asterisk/verified.ts',
).toBe(VERIFIED_ASTERISK);
});
it('is the one spec 01 §2 names, and its copy of the Dockerfile is the file', () => {
const spec = readFileSync(
new URL('../../../docs/spec/01-infrastructure.md', import.meta.url),
'utf8',
);
expect(spec).toContain(`**Pinned: \`${pinnedAsterisk()}\``);
const copy = /^## 2\. [\s\S]*?^```dockerfile\n([\s\S]*?)^```$/m.exec(spec)?.[1];
const file = readFileSync(new URL('../../../asterisk/Dockerfile', import.meta.url), 'utf8');
expect(copy, 'spec 01 §2 has a copy of asterisk/Dockerfile: keep it the same').toBe(file);
});
});
+3 -5
View File
@@ -13,6 +13,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
import { AmiClient } from '../../src/ami/client.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const CALL_SECONDS = Number(process.env.CALL_SECONDS ?? 30);
@@ -126,11 +127,8 @@ async function main() {
spawnSync('mkdir', ['-p', conf]);
const files = render(snapshot());
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
// Test-only manager.conf: reachable through the published port (the real one is 127.0.0.1-only).
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
// The real manager.conf, reachable through the published port (test/asterisk/manager-conf.ts).
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
phoneConfig(dir, '101', 5070, 440);
phoneConfig(dir, '102', 5072, 880);
+2 -4
View File
@@ -18,6 +18,7 @@ import type { AmiMessage } from '../../src/ami/events.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot, SnapExtension } from '../../src/generator/load.ts';
import { providerDialplan, providerPjsip } from './provider.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const OUT = fileURLToPath(new URL('../fixtures/cdr/', import.meta.url));
@@ -243,10 +244,7 @@ async function main() {
);
}
const runPhones = phoneFiles(dir);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
const ami = new AmiClient({
host: '127.0.0.1',
port: AMI_PORT,
+2 -4
View File
@@ -17,6 +17,7 @@ import { AmiClient } from '../../src/ami/client.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { providerPjsip } from './provider.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const NET = 'sp-cnam';
@@ -223,10 +224,7 @@ async function main() {
);
}
phoneFiles(dir);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
const problems: string[] = [];
const ami = new AmiClient({
host: '127.0.0.1',
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The real manager.conf.template, read/write classes included, so a class the api needs and the
// template lacks fails the tests. Only opened up for the host to reach AMI through the published
// port: the real one is 127.0.0.1-only.
import { readFileSync } from 'node:fs';
export function testManagerConf(): string {
const real = readFileSync(
new URL('../../../../asterisk/conf/manager.conf.template', import.meta.url),
'utf8',
);
const open = real
.replace(/^bindaddr = 127\.0\.0\.1$/m, 'bindaddr = 0.0.0.0')
.replace(/^permit = 127\.0\.0\.1\/255\.255\.255\.255$/m, 'permit = 0.0.0.0/0.0.0.0');
if (open.includes('127.0.0.1'))
throw new Error('manager.conf.template changed: update manager-conf.ts');
return open;
}
+2 -4
View File
@@ -17,6 +17,7 @@ import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { providerDialplan, providerPjsip } from './provider.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const RECORD = process.argv.includes('--record')
@@ -200,10 +201,7 @@ async function main() {
);
}
phoneFiles(dir);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
const problems: string[] = [];
let recorder: Socket | undefined;
+309
View File
@@ -0,0 +1,309 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Asterisk upgrade check (01 §2): boot the pinned image and measure each behaviour in verified.ts,
// then list every one that moved and what depends on it. A second Asterisk plays a provider on
// TLS with a certificate from our own CA for "provider.test", so the qualify check sees a real
// host name. Needs Docker and the image built from asterisk/Dockerfile (ASTERISK_IMAGE, default
// "selfphoneng-asterisk").
// Usage: pnpm test:asterisk-upgrade
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { AmiClient } from '../../src/ami/client.ts';
import { field, type AmiMessage } from '../../src/ami/events.ts';
import { FACTS, VERIFIED_ASTERISK, pinnedAsterisk, type Fact } from './verified.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const GOLDEN = fileURLToPath(
new URL('../../src/generator/__golden__/one-user/expected', import.meta.url),
);
const NET = 'sp-upgrade';
const PBX = 'sp-upgrade-pbx';
const PROVIDER = 'sp-upgrade-provider';
const HOST = 'provider.test';
const AMI_PORT = 15040;
const AMI_SECRET = 'upgrade-test-secret';
const SUB = { user: 'upgrade', pass: 'UpgradeSecret42' };
const RISKY = ['app_system', 'func_shell', 'app_disa', 'res_agi', 'app_externalivr'];
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const docker = (...args: string[]) => {
const r = spawnSync('docker', args, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
};
const log = (...a: unknown[]) => console.log('[upgrade-test]', ...a);
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
for (let i = 0; i < seconds; i++) {
if (await fn()) return;
await sleep(1000);
}
throw new Error(`timed out waiting for ${what}`);
}
/** A CA, and a certificate from it for HOST: what the PBX trusts and the provider shows. */
function certificates(dir: string) {
const r = docker(
'run',
'--rm',
'-v',
`${dir}:/out`,
'--entrypoint',
'sh',
IMAGE,
'-c',
[
'cd /out',
'openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=sp-upgrade-ca -keyout ca.key -out ca.pem',
`openssl req -newkey rsa:2048 -nodes -subj /CN=${HOST} -keyout privkey.pem -out host.csr`,
`printf 'subjectAltName=DNS:${HOST}' > host.ext`,
'openssl x509 -req -in host.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile host.ext -out fullchain.pem',
'chmod 644 *',
].join(' && '),
);
if (!r.ok) throw new Error(`certificates: ${r.out}`);
}
const tlsTransport = (name: string, port: number, client: boolean) => `
[${name}]
type = transport
protocol = tls
bind = 0.0.0.0:${port}
cert_file = /tls/fullchain.pem
priv_key_file = /tls/privkey.pem
method = tlsv1_2
${client ? 'verify_server = yes\nca_list_file = /tls/ca.pem\n' : ''}`;
/** The provider: TLS only, one account that may register. */
const providerPjsip = `${tlsTransport('transport-tls', 5061, false)}
[${SUB.user}]
type = endpoint
context = default
disallow = all
allow = ulaw
auth = ${SUB.user}-auth
aors = ${SUB.user}
[${SUB.user}-auth]
type = auth
auth_type = userpass
username = ${SUB.user}
password = ${SUB.pass}
[${SUB.user}]
type = aor
max_contacts = 1
remove_existing = yes
`;
/**
* Added to the one-user golden config: a trunk to the provider by name over TLS. The registration
* is the control (REGISTER verifies the name on every version we know); the AOR's qualify is the
* fact being measured.
*/
const pbxPjsip = `${tlsTransport('sp-upgrade-tls', 5062, true)}
[sp-upgrade-auth]
type = auth
auth_type = userpass
username = ${SUB.user}
password = ${SUB.pass}
[sp-upgrade-reg]
type = registration
transport = sp-upgrade-tls
outbound_auth = sp-upgrade-auth
server_uri = sip:${HOST}:5061;transport=tls
client_uri = sip:${SUB.user}@${HOST}:5061;transport=tls
contact_user = ${SUB.user}
retry_interval = 5
[sp-upgrade-trunk]
type = aor
contact = sip:${HOST}:5061;transport=tls
qualify_frequency = 5
[sp-upgrade-trunk]
type = endpoint
transport = sp-upgrade-tls
outbound_auth = sp-upgrade-auth
aors = sp-upgrade-trunk
context = sp-upgrade-test
`;
const pbxDialplan = `
[sp-upgrade-test]
exten => s,1,Answer()
same => n,Hangup()
`;
async function main() {
const pinned = pinnedAsterisk();
const tls = mkdtempSync(join(tmpdir(), 'sp-upgrade-tls-'));
const pbxConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-pbx-'));
const provConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-prov-'));
cpSync(GOLDEN, pbxConf, { recursive: true });
appendFileSync(join(pbxConf, 'pjsip_selfphoneng.conf'), pbxPjsip);
appendFileSync(join(pbxConf, 'extensions_selfphoneng.conf'), pbxDialplan);
for (const f of readdirSync(GOLDEN))
writeFileSync(join(provConf, f), f === 'pjsip_selfphoneng.conf' ? providerPjsip : '');
writeFileSync(join(pbxConf, 'manager.conf.template'), testManagerConf());
const ami = new AmiClient({
host: '127.0.0.1',
port: AMI_PORT,
username: 'selfphoneng',
secret: AMI_SECRET,
logger: { info() {}, warn() {}, error() {}, debug() {} },
backoffMs: [500],
});
const asterisk = (name: string, conf: string, extra: string[]) =>
docker(
'run',
'-d',
'--rm',
'--name',
name,
'--network',
NET,
...extra,
'-e',
`AMI_SECRET=${AMI_SECRET}`,
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
'-v',
`${tls}:/tls:ro`,
IMAGE,
);
const booted = (name: string) =>
waitFor(
`${name} to boot`,
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
);
const seen = {} as Record<Fact, boolean>;
const problems: string[] = [];
try {
docker('rm', '-f', PBX, PROVIDER);
docker('network', 'create', NET);
certificates(tls);
const prov = asterisk(PROVIDER, provConf, ['--network-alias', HOST]);
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
await booted(PROVIDER);
const pbx = asterisk(PBX, pbxConf, [
'-p',
`127.0.0.1:${AMI_PORT}:5038`,
'-v',
`${join(pbxConf, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
]);
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
await booted(PBX);
const running = docker('exec', PBX, 'asterisk', '-V').out.trim();
if (running !== `Asterisk ${pinned}`)
throw new Error(
`the image runs "${running}" but asterisk/Dockerfile pins ${pinned}: rebuild it ` +
`(docker build -t ${IMAGE} asterisk)`,
);
log(`${running}, last verified on ${VERIFIED_ASTERISK}`);
ami.start();
await new Promise((r) => ami.once('connected', r));
const cli = (cmd: string) => ami.command(cmd);
const modules = await Promise.all(RISKY.map((m) => cli(`module show like ${m}`)));
seen.riskyModulesGone = modules.every((o) => /\b0 modules loaded/.test(o));
const ep101 = await cli('pjsip show endpoint 101');
seen.mwiSubscribeReplaces = /mwi_subscribe_replaces_unsolicited\s*:\s*yes/.test(ep101);
// set_var lines show as their own rows: " __SP_DIRECTION : internal".
seen.setVarKeepsUnderscores = /^\s*__SP_DIRECTION\s*:\s*internal\s*$/m.test(ep101);
const commands = await cli('manager show commands');
seen.voicemailMoveRemove =
/\bVoicemailMove\b/.test(commands) && /\bVoicemailRemove\b/.test(commands);
// CDR: one Local call through the test context.
const cdr = new Promise<AmiMessage>((r) => ami.once('event:Cdr', r));
await cli('channel originate Local/s@sp-upgrade-test application Wait 1');
const m = await Promise.race([
cdr,
sleep(15_000).then(() => {
throw new Error('no Cdr event within 15 s of a call');
}),
]);
seen.cdrLinkedId = !!field(m, 'LinkedID');
const start = field(m, 'StartTime') ?? '';
seen.cdrTimesUtcNoZone =
/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(start) &&
Math.abs(Date.parse(`${start.replace(' ', 'T')}Z`) - Date.now()) < 120_000;
// TLS: the registration must work first, or the qualify answer says nothing.
await waitFor(
'the TLS registration to the provider (the control)',
async () => /sp-upgrade-reg\S*\s.*Registered/.test(await cli('pjsip show registrations')),
45,
).catch(async (e: Error) => {
throw new Error(`${e.message}:\n${await cli('pjsip show registrations')}`);
});
let contacts = '';
for (let i = 0; i < 20; i++) {
contacts = await cli('pjsip show contacts');
if (/sp-upgrade-trunk\/\S+\s.*\b(Avail|Unavail)/.test(contacts)) break;
await sleep(1000);
}
seen.tlsQualifyByHostname = /sp-upgrade-trunk\/\S+\s.*\bAvail\b/.test(contacts);
if (!seen.tlsQualifyByHostname) {
const why = docker('logs', PBX)
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
.out.replace(/\x1b\[[0-9;]*m/g, '')
.split('\n');
const line = contacts.split('\n').find((l) => l.includes('sp-upgrade-trunk'));
log('qualify over TLS failed:', line);
for (const l of why
.filter((x) => /sp-upgrade-trunk|identity|EUNSUP|qualif/i.test(x))
.slice(-4))
log(l.trim());
}
const listed = await ami.actionList({ Action: 'PJSIPShowContacts' });
seen.staticContactsInShowContacts = listed.some((x) => x.Aor === 'sp-upgrade-trunk');
const detail = await ami.actionList({
Action: 'PJSIPShowEndpoint',
Endpoint: 'sp-upgrade-trunk',
});
seen.endpointShowsContactStatus = detail.some(
(x) => x.Event === 'ContactStatusDetail' && x.AOR === 'sp-upgrade-trunk',
);
} catch (e) {
problems.push((e as Error).message);
} finally {
ami.stop();
docker('rm', '-f', PBX, PROVIDER);
docker('network', 'rm', NET);
for (const d of [tls, pbxConf, provConf]) rmSync(d, { recursive: true, force: true });
}
const moved = (Object.keys(FACTS) as Fact[]).filter(
(k) => k in seen && seen[k] !== FACTS[k].expect,
);
for (const k of Object.keys(FACTS) as Fact[])
console.log(
`${!(k in seen) ? '? ' : moved.includes(k) ? 'MOVED' : 'ok '} ${k}: ${k in seen ? seen[k] : 'not measured'}`,
);
for (const k of moved)
problems.push(
`${k} was ${FACTS[k].expect} on ${VERIFIED_ASTERISK}, is ${seen[k]} on ${pinned}: ` +
`${FACTS[k].what}. Depends on it: ${FACTS[k].where}`,
);
if (problems.length) {
console.log('FAIL asterisk upgrade');
for (const p of problems) console.log(` ${p}`);
process.exit(1);
}
console.log(
pinned === VERIFIED_ASTERISK
? `ok asterisk ${pinned}: every fact as recorded`
: `ok asterisk ${pinned}: nothing moved since ${VERIFIED_ASTERISK}; set VERIFIED_ASTERISK in verified.ts`,
);
}
await main();
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// What the code relies on Asterisk doing, as last measured by upgrade-test.ts, and on which
// version. asterisk-version.test.ts fails when asterisk/Dockerfile pins another version: run
// `pnpm test:asterisk-upgrade` on the new image, act on each change it lists, then update this.
import { readFileSync } from 'node:fs';
export const VERIFIED_ASTERISK = '24.0.0-rc3';
/** The version asterisk/Dockerfile builds. */
export function pinnedAsterisk(): string {
const file = readFileSync(new URL('../../../../asterisk/Dockerfile', import.meta.url), 'utf8');
const v = /^ARG ASTERISK_VERSION=(\S+)$/m.exec(file)?.[1];
if (!v) throw new Error('asterisk/Dockerfile has no ARG ASTERISK_VERSION');
return v;
}
/** `expect`: the answer on VERIFIED_ASTERISK. `where`: what depends on it, to change if it moves. */
export const FACTS = {
riskyModulesGone: {
expect: true,
what: 'app_system, func_shell, app_disa, res_agi and app_externalivr are not loaded',
where: 'asterisk/conf/modules.conf noload lines (08 §6): a renamed module would load again',
},
mwiSubscribeReplaces: {
expect: true,
what: 'endpoints take mwi_subscribe_replaces_unsolicited',
where: 'generator/pjsip.ts: without it a phone that SUBSCRIBEs for MWI gets a 500 (03 §5.2)',
},
setVarKeepsUnderscores: {
expect: true,
what: 'set_var = __SP_DIRECTION=… loads as "__SP_DIRECTION"',
where: 'generator/pjsip.ts set_var lines; dialplan reads SP_DIRECTION on dialed legs (03 §5.2)',
},
staticContactsInShowContacts: {
expect: false,
what: 'PJSIPShowContacts lists static (contact =) AOR contacts',
where: 'live/state.ts reads trunk reachability from PJSIPShowEndpoint instead (04 §5.1)',
},
endpointShowsContactStatus: {
expect: true,
what: 'PJSIPShowEndpoint has a ContactStatusDetail for a static contact',
where: 'live/state.ts trunk reachability (04 §5.1)',
},
voicemailMoveRemove: {
expect: true,
what: 'the AMI actions VoicemailMove and VoicemailRemove exist',
where: 'services/voicemail.ts mark read and delete (04 §5.6)',
},
cdrLinkedId: {
expect: true,
what: 'Cdr AMI events carry LinkedID (cdr_manager.conf mapping)',
where: 'services/cdr.ts groups a call by it (01 §3.15, 04 §5.4)',
},
cdrTimesUtcNoZone: {
expect: true,
what: 'Cdr StartTime is "YYYY-MM-DD HH:MM:SS" in UTC, no zone',
where: 'services/cdr.ts utc() adds the Z itself',
},
tlsQualifyByHostname: {
expect: true,
what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP',
where:
'generator/pjsip.ts qualifies TLS trunks and Test sends PJSIPQualify (08 §2.1). False on ' +
'24.0.0-rc2: a TLS trunk then showed as unreachable, so it had qualify_frequency = 0',
},
} as const;
export type Fact = keyof typeof FACTS;
+2 -4
View File
@@ -11,6 +11,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
import { AmiClient } from '../../src/ami/client.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const NET = 'sp-vm';
@@ -111,10 +112,7 @@ async function main() {
spawnSync('mkdir', ['-p', conf]);
const files = render(snapshot());
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
phoneConfig(dir, '101', 5070);
phoneConfig(dir, '102', 5072);
+69 -4
View File
@@ -1,25 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5).
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5); users' Report spam (05 §4).
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { callExtensions, calls, extensions } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { syncAstDb } from '../src/services/astdb.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let req: (
method: string,
url: string,
payload?: unknown,
as?: string,
) => 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) =>
req = (method, url, payload, as = cookie) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
headers: { ...H, cookie: as },
});
});
afterAll(() => t?.close());
@@ -59,4 +66,62 @@ describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
});
expect((await req('GET', '/api/blocked-numbers')).json().items).toEqual([]);
});
it('Report spam: only on an outside call that rang me; Block clears it, Dismiss drops it', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
await t.addUser('pat@example.com', 'pat-password-123');
const [ext] = await t.db
.insert(extensions)
.values({
number: '103',
name: 'Sam',
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: sam.id,
})
.returning();
const call = async (linkedid: string, from: string | null, role: string) => {
const [c] = await t.db
.insert(calls)
.values({
linkedid,
direction: 'inbound',
status: 'answered',
fromNumber: from,
startedAt: new Date(),
endedAt: new Date(),
})
.returning();
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: ext!.id, role });
return c!.id;
};
const robo = await call('r1', '13125550142', 'rang');
const hidden = await call('r2', null, 'answered');
const samC = await t.login('sam@example.com', 'sam-password-123');
const patC = await t.login('pat@example.com', 'pat-password-123');
const report = (callId: string, as: string, reason = 'Car warranty robocall') =>
req('POST', '/api/me/spam-reports', { callId, reason }, as);
expect((await report(robo, patC)).statusCode).toBe(404); // not a call that rang Pat
expect((await report(robo, samC, ' ')).statusCode).toBe(400); // no reason
expect((await report(hidden, samC)).statusCode).toBe(400); // no number to block
expect((await report(robo, samC)).statusCode).toBe(201);
expect((await report(robo, samC)).statusCode).toBe(409); // once each
expect((await req('GET', '/api/spam-reports', undefined, samC)).statusCode).toBe(403);
const [r] = (await req('GET', '/api/spam-reports')).json().items;
expect(r).toMatchObject({
number: '13125550142',
reason: 'Car warranty robocall',
reportedBy: 'sam@example.com',
});
expect((await req('DELETE', `/api/spam-reports/${r.id}`)).statusCode).toBe(200);
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
expect((await report(robo, samC)).statusCode).toBe(201); // dismissed: may report again
const b = await req('POST', '/api/blocked-numbers', { number: r.number, reason: r.reason });
expect(b.statusCode).toBe(201);
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
expect((await report(robo, samC)).statusCode).toBe(409); // already blocked
});
});
+39
View File
@@ -181,4 +181,43 @@ describe.skipIf(!hasTestDb)('call report email', () => {
await run('2026-10-23T13:06:00Z');
expect(t.mails[1]!.text).not.toContain('quote');
});
it('no recording or summary of a call that only rang my phone (08 §7 #3)', async () => {
await dan('PATCH', '/api/me', { callReportSummaries: true });
const [rec] = await t.db
.insert(recordings)
.values({
linkedid: 'r4',
filePath: '2026/10/24/r4.wav',
startedAt: new Date('2026-10-24T15:00:00Z'),
summary: 'Secret pricing for Acme',
aiDoneAt: new Date(),
})
.returning();
const [c] = await t.db
.insert(calls)
.values({
linkedid: 'c4', // the ring group rang Dan; a coworker answered and talked
direction: 'inbound',
status: 'answered',
startedAt: new Date('2026-10-24T15:00:00Z'),
endedAt: new Date('2026-10-24T15:05:00Z'),
fromNumber: '13125557777',
talkSeconds: 300,
recordingId: rec!.id,
})
.returning();
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: extId, role: 'rang' });
await t.db
.update(extensions)
.set({ callReportSentAt: new Date('2026-10-24T13:00:00Z') })
.where(eq(extensions.id, extId));
t.mails.length = 0;
expect(await run('2026-10-25T13:06:00Z')).toBe(1);
const text = t.mails[0]!.text;
expect(text).toContain('Incoming from (312) 555-7777');
expect(text).not.toContain('Secret pricing');
expect(text).not.toContain('recorded');
expect(text).not.toContain('Summary not ready');
});
});
+44
View File
@@ -460,6 +460,19 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
fake.state.readOnly.clear();
expect(await run()).toBe(0);
expect(jane).toHaveLength(8);
// Only rang Dan (a coworker took it): on his timeline, but not its recording or summary.
const [k12] = await t.db
.insert(calls)
.values(call('k12', 1.5, { recordingId: done!.id }))
.returning();
await t.db
.insert(callExtensions)
.values({ callId: k12!.id, extensionId: ext!.id, role: 'rang' });
forgetBook(danId);
expect(await run()).toBe(1);
expect(jane.at(-1)!.content).not.toMatch(/recorded|Acme/);
expect(jane.at(-1)).not.toHaveProperty('actionItem');
});
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
@@ -521,4 +534,35 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
]);
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
it('the saved tokens only go to the address they were saved for (08 §7 #7)', async () => {
// Another machine on the network that answers like Contrack, and notes what it's sent.
const heard: string[] = [];
const other = createServer((req, res) => {
heard.push(String(req.headers.authorization ?? ''));
res.setHeader('content-type', 'application/json');
res.end('[]');
});
await new Promise<void>((ok) => other.listen(0, '127.0.0.1', ok));
const otherUrl = `http://127.0.0.1:${(other.address() as AddressInfo).port}`;
try {
await admin('POST', '/api/contacts/contrack/test', { url: otherUrl });
expect(heard.join(' ')).not.toContain('ctk_company');
// A new address needs its token typed again…
const kept = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: otherUrl },
});
expect(kept.statusCode).toBe(400);
expect(kept.json().error.message).toMatch(/token again/);
// …and people's own tokens are for the old one: they connect again.
const moved = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: otherUrl, companyToken: 'ctk_new_place' },
});
expect(moved.statusCode, moved.body).toBe(200);
const [d] = await t.db.select().from(users).where(eq(users.id, danId));
expect(d!.contrackTokenEnc).toBeNull();
} finally {
other.close();
}
});
});
+51
View File
@@ -154,4 +154,55 @@ describe.skipIf(!hasTestDb)('diagnostics', () => {
401,
);
});
it('the probe key: made here, shown once, sent to the probe, never in settings (08 §7 #9)', async () => {
const cookie = await t.login(ADMIN.email, ADMIN.password);
const as = (method: string, url: string, payload?: object) =>
t.app.inject({ method: method as 'GET', url, payload, headers: { ...H, cookie } });
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
// No key yet: the inside checks say to make one.
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'warn' });
const g = await as('POST', '/api/settings/network/probe-key');
expect(g.statusCode, g.body).toBe(200);
const { key } = g.json() as { key: string };
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
const s = await as('GET', '/api/settings');
expect(s.json()['network.hasProbeKey']).toBe(true);
expect(s.body).not.toContain(key);
expect(s.json()).not.toHaveProperty('network.probeKeyEnc');
expect((await as('PATCH', '/api/settings', { 'network.probeKeyEnc': 'x' })).statusCode).toBe(
400,
);
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'ok' });
let auth: string | null = null;
await networkTest(
{
...deps,
fetchProbe: (async (_u: URL, init: RequestInit) => {
auth = new Headers(init.headers).get('authorization');
return new Response(JSON.stringify({ ip: '203.0.113.10', results: [] }));
}) as unknown as typeof fetch,
},
'quick',
);
expect(auth).toBe(`Bearer ${key}`);
// The probe refusing it says what to do.
const refused = await networkTest(
{
...deps,
fetchProbe: (async () =>
new Response(JSON.stringify({ error: 'a key is needed' }), {
status: 401,
})) as unknown as typeof fetch,
},
'quick',
);
expect(byId(refused.checks).probe!.fix).toContain('PROBE_KEY');
// Another address: the key was for the old probe, so it's gone.
await as('PATCH', '/api/settings', { 'network.probeUrl': 'https://other-probe.test' });
expect((await as('GET', '/api/settings')).json()['network.hasProbeKey']).toBe(false);
});
});
+35 -2
View File
@@ -1,12 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.3: who a directory entry may connect to by email, who may disconnect it (§4.5), and a
// directory that's down counting as a failed sign-in (§4.2).
// directory that's down counting as a failed sign-in (§4.2). 08 §7: no OpenID user by email, no
// swapping the connected account.
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { directorySignIn } from '../src/sso/directory.ts';
import { connectDirectory, directorySignIn } from '../src/sso/directory.ts';
import type { DirectoryEntry } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -52,6 +53,38 @@ describe.skipIf(!hasTestDb)('directory accounts by email (12 §4.3)', () => {
}
});
it('never connects by email someone who signs in with OpenID (08 §7 #1)', async () => {
// A directory that lets people edit their own mail: Mallory's entry, the victim's email.
const victim = await t.addUser('oidc-only@example.com', 'unused-pw-2');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, victim.id));
await t.db
.insert(userIdentities)
.values({ userId: victim.id, kind: 'oidc', subject: 'https://idp victim-sub' });
expect(await directorySignIn(t.db, log, entry(victim.email), '127.0.0.1')).toBeNull();
expect(await connected(victim.id)).toBe(1); // the OpenID account only
});
it('a connected directory account is only swapped by an admin (08 §7 #5)', async () => {
const u = await t.addUser('swapper@example.com', 'swapper-password-1');
expect(await connectDirectory(t.db, log, u.id, entry(u.email), '127.0.0.1')).toBe('connected');
const [first] = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
// The same account again is fine; another one (a shared mailbox) is not.
expect(
await connectDirectory(
t.db,
log,
u.id,
{ ...entry(u.email), id: first!.subject },
'127.0.0.1',
),
).toBe('connected');
expect(await connectDirectory(t.db, log, u.id, entry('shared@example.com'), '127.0.0.1')).toBe(
'connected_other',
);
const now = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
expect(now.map((i) => i.subject)).toEqual([first!.subject]);
});
it('only an admin can disconnect a directory account (a leaver must stay checked)', async () => {
const u = await t.addUser('leaver@example.com', 'leaver-password-1');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
+231
View File
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T10: fraud alerts (08 §8.3): risky calls, voip.ms calls the PBX didn't make, balance drop.
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, cdr, dids } from '../src/db/schema.ts';
import { createFraudWatch, riskyDestination, trunkDialNumber } from '../src/services/fraud.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe('riskyDestination', () => {
it('flags international, premium and NANP numbers billed like abroad', () => {
expect(riskyDestination('01144207946095')).toBe('international');
expect(riskyDestination('19005551234')).toBe('premium');
expect(riskyDestination('13129765555')).toBe('premium'); // exchange 976
expect(riskyDestination('18765551234')).toMatch(/international rates/); // Jamaica
expect(riskyDestination('8095551234')).toMatch(/international rates/); // 10 digits
expect(riskyDestination('13125551234')).toBeNull();
expect(riskyDestination('18005551234')).toBeNull();
expect(riskyDestination('101')).toBeNull();
});
it('reads the number out of a trunk Dial', () => {
expect(trunkDialNumber('PJSIP/13125550000@trunk-7d3e9a52,120')).toBe('13125550000');
expect(trunkDialNumber('PJSIP/911@trunk-7d3e9a52')).toBe('911');
expect(trunkDialNumber('PJSIP/101,30')).toBeNull();
expect(trunkDialNumber(null)).toBeNull();
});
});
describe.skipIf(!hasTestDb)('fraud watches', () => {
const at = (iso: string) => new Date(iso);
let clock = Date.parse('2026-10-29T12:00:00Z');
let balance = '100.00';
const cdrs: Record<string, unknown>[] = [];
const seen: URLSearchParams[] = [];
const fake = createServer((req, res) => {
const q = new URL(req.url!, 'http://x').searchParams;
seen.push(q);
res.setHeader('content-type', 'application/json');
const m = q.get('method');
res.end(
JSON.stringify(
m === 'getBalance'
? { status: 'success', balance: { current_balance: balance } }
: m === 'getCDR'
? cdrs.length
? { status: 'success', cdr: cdrs }
: { status: 'no_cdr' }
: { status: 'invalid_method' },
),
);
});
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let trunkId: string;
let url: string;
const mails: { subject: string; text: string }[] = [];
const watch = () =>
createFraudWatch({
db: t.db,
voipmsUrl: url,
mail: async (subject, text) => void mails.push({ subject, text }),
log: { warn() {} },
now: () => new Date(clock),
});
beforeAll(async () => {
await new Promise<void>((r) => fake.listen(0, '127.0.0.1', r));
url = `http://127.0.0.1:${(fake.address() as AddressInfo).port}/api/v1/rest.php`;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
const r = await req('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'Sub-account pw!',
});
trunkId = r.json().id;
await req('PATCH', `/api/trunks/${trunkId}`, {
voipmsApiUsername: 'me@example.com',
voipmsApiPassword: 'api-pw',
});
await t.db.insert(dids).values({
number: '13125551000',
trunkId,
destination: { type: 'hangup' },
} as typeof dids.$inferInsert);
});
afterAll(async () => {
fake.close();
await t.close();
});
it('riskyCallWatch emails each risky outside call once, and not when turned off', async () => {
const w = watch();
mails.length = 0;
const call = (linkedid: string, to: string, created: string) => ({
linkedid,
direction: 'outbound' as const,
status: 'answered' as const,
startedAt: at(created),
endedAt: at(created),
fromNumber: '101',
toNumber: to,
talkSeconds: 125,
createdAt: at(created),
});
await t.db
.insert(calls)
.values([
call('r1', '01144207946095', '2026-10-29T12:00:20Z'),
call('r2', '18765551234', '2026-10-29T12:00:30Z'),
call('r3', '13125551234', '2026-10-29T12:00:40Z'),
]);
clock = Date.parse('2026-10-29T12:01:00Z');
await w.riskyCallWatch();
expect(mails.map((m) => m.subject)).toEqual(['Calls to risky numbers (2)']);
expect(mails[0]!.text).toMatch(/from 101 to 01144207946095: international, answered/);
expect(mails[0]!.text).toMatch(/\(876\) 555-1234: international rates/);
expect(mails[0]!.text).not.toMatch(/\(312\) 555-1234/);
clock += 60_000;
await w.riskyCallWatch(); // nothing new
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.riskyCalls', false);
await t.db.insert(calls).values(call('r4', '19005551234', '2026-10-29T12:01:30Z'));
clock += 60_000;
await w.riskyCallWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.riskyCalls', true);
});
it('providerWatch reports voip.ms calls the PBX never sent to the trunk, once', async () => {
await setSetting(t.db, 'alerts.balanceDropPerHour', 0);
const w = watch();
mails.length = 0;
clock = Date.parse('2026-10-29T14:00:00Z');
// The PBX sent one call to the trunk at 13:00 (a forward would look the same).
await t.db.insert(cdr).values({
uniqueid: 'u1',
linkedid: 'u1',
startAt: at('2026-10-29T13:00:05Z'),
lastdata: `PJSIP/13125550000@trunk-${trunkId},120`,
raw: {},
});
const rec = (uniqueid: string, date: string, destination: string) => ({
date,
callerid: '"Office" <3125551000>',
destination,
description: 'Outgoing',
account: '123456_pbx',
disposition: 'ANSWERED',
duration: '00:03:00',
seconds: '180',
rate: '0.25',
total: '0.7500',
uniqueid,
});
cdrs.push(
rec('v1', '2026-10-29 13:00:09', '3125550000'), // ours
rec('v2', '2026-10-29 13:10:00', '3125551000'), // incoming, to our DID
rec('v3', '2026-10-29 13:20:00', '01144207946095'), // not ours
rec('v4', '2026-10-29 11:00:00', '01144207946096'), // before the window
{ ...rec('v5', '2026-10-29 13:30:00', '01144207946097'), account: '123456_other' },
);
await w.providerWatch();
const q = seen.findLast((s) => s.get('method') === 'getCDR')!;
expect(
Object.fromEntries(['date_from', 'date_to', 'timezone', 'account'].map((k) => [k, q.get(k)])),
).toEqual({
date_from: '2026-10-29',
date_to: '2026-10-29',
timezone: '0',
account: '123456_pbx',
});
expect(mails.map((m) => m.subject)).toEqual([
"voip.ms calls this phone system didn't make (1)",
]);
expect(mails[0]!.text).toMatch(/to 01144207946095: answered, 180 s, cost 0\.7500/);
expect(mails[0]!.text).not.toMatch(/3125550000|3125551000|946096|946097/);
clock += 15 * 60_000;
await w.providerWatch(); // v3 again: already reported
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', false);
cdrs.push(rec('v6', '2026-10-29 14:00:00', '01144207946098'));
clock += 15 * 60_000;
await w.providerWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', true);
cdrs.length = 0;
});
it('balanceWatch emails once an hour when the balance drops more than the limit', async () => {
await setSetting(t.db, 'alerts.providerCalls', false);
await setSetting(t.db, 'alerts.balanceDropPerHour', 10);
const w = watch();
mails.length = 0;
clock = Date.parse('2026-10-29T16:00:00Z');
balance = '100.00';
await w.providerWatch();
clock += 15 * 60_000;
balance = '95.00';
await w.providerWatch();
expect(mails).toEqual([]);
clock += 15 * 60_000;
balance = '85.00';
await w.providerWatch();
expect(mails.map((m) => m.subject)).toEqual(['voip.ms balance dropped 15.00 in an hour']);
clock += 15 * 60_000;
balance = '70.00';
await w.providerWatch(); // within the hour of the last email
expect(mails).toHaveLength(1);
clock += 60 * 60_000;
balance = '200.00'; // a deposit
await w.providerWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', true);
});
});
+50
View File
@@ -207,6 +207,56 @@ for (const alg of ['RS256', 'ES256'] as const)
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
});
it('never matches by email an admin, or someone with a directory account (08 §7 #2)', async () => {
const [boss, dir] = await t.db
.insert(users)
.values([
{ email: 'diradmin@example.com', displayName: 'Boss', passwordHash: null, role: 'admin' },
{ email: 'diruser@example.com', displayName: 'Dir', passwordHash: null },
])
.returning();
await t.db
.insert(userIdentities)
.values({ userId: dir!.id, kind: 'ldap', subject: 'dir-user-id' });
for (const u of [boss!, dir!]) {
idp.st.user = { sub: `u-${u.email}`, email: u.email, email_verified: true };
const { callback } = await flow();
expect(errorOf(callback!)).toBe('not_connected');
expect(session(callback!)).toBeNull();
}
});
it('connect finishes only in the browser whose session asked (08 §7 #8)', async () => {
// Mallory starts Connect, then plants her sp_oidc cookie in Vic's browser and sends him on.
await t.addUser('mallory@example.com', 'mallory-password-1');
const [m] = await t.db.select().from(users).where(eq(users.email, 'mallory@example.com'));
await t.addUser('vic@example.com', 'vic-password-123');
const mallory = await t.login('mallory@example.com', 'mallory-password-1');
const vic = await t.login('vic@example.com', 'vic-password-123');
idp.st.user = { sub: 'u-vic', email: 'vic@example.com', email_verified: true };
for (const theirs of [vic, '']) {
const start = await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: mallory },
payload: {},
});
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
const atIdp = await fetch((start.json() as { url: string }).url, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
const callback = await t.app.inject({
url: back.pathname + back.search,
headers: { cookie: [`sp_oidc=${pending.value}`, theirs].filter(Boolean).join('; ') },
});
expect(errorOf(callback)).toBe('expired');
const ids = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, m!.id));
expect(ids).toEqual([]);
}
});
it('trustEmail matches an unverified email', async () => {
await t.db
.insert(users)
+24 -2
View File
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M7-T3: a recorded call (sp_recording) is linked to its calls row and playable by admins and the
// people on the call; delete and retention remove file and row.
// people on the call (not a phone that only rang, 08 §7 #3); delete and retention remove file and row.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { AmiMessage } from '../src/ami/events.ts';
import { calls, cdr, extensions, recordings } from '../src/db/schema.ts';
import { callExtensions, calls, cdr, extensions, recordings, users } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { cleanupRecordings } from '../src/routes/recordings.ts';
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
@@ -56,6 +56,8 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
};
});
afterAll(() => t?.close());
const bobId = async () =>
(await t.db.select().from(users).where(eq(users.email, 'bob@example.com')))[0]!.id;
it('the recording is linked to its call, with size and length', async () => {
const [c] = await t.db.select().from(calls);
@@ -91,6 +93,26 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
expect((await as.alice('GET', '/api/recordings')).statusCode).toBe(403);
});
it('a phone that only rang (ring group, queue) gets neither audio nor transcript (08 §7 #3)', async () => {
const [bob] = await t.db
.insert(extensions)
.values({
number: '102',
name: 'Bob',
userId: await bobId(),
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
})
.returning();
const [c] = await t.db.select().from(calls);
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: bob!.id, role: 'rang' });
expect((await as.bob('GET', `/api/recordings/${recId}/audio`)).statusCode).toBe(403);
expect((await as.bob('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(403);
const mine = (await as.bob('GET', '/api/me/calls')).json();
expect(mine.items).toEqual([expect.objectContaining({ id: c!.id, recordingId: null })]);
expect((await as.alice('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(200);
});
it('retention deletes recordings past their days; delete removes file and link', async () => {
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
+74 -2
View File
@@ -1,8 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone;
// /api/me/reports/summary, the calls made to me.
import type { LightMyRequestResponse } from 'fastify';
import { inArray } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, queueCalls, queues } from '../src/db/schema.ts';
import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -125,5 +128,74 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
await t.addUser('pat@example.com', 'pat-password-123');
const pat = await t.login('pat@example.com', 'pat-password-123');
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
});
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
const [mine, other] = await t.db
.insert(extensions)
.values(
(['103', '104'] as const).map((number) => ({
number,
name: number,
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: number === '103' ? sam.id : null,
})),
)
.returning();
await t.db.insert(calls).values({
linkedid: 'g',
direction: 'internal',
status: 'answered',
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
endedAt: new Date('2026-05-05T15:00:45Z'),
talkSeconds: 40,
answeredByExtensionId: mine!.id,
});
const ids = Object.fromEntries(
(
await t.db
.select({ id: calls.id, linkedid: calls.linkedid })
.from(calls)
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
).map((c) => [c.linkedid, c.id]),
);
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
await t.db
.update(calls)
.set({ answeredByExtensionId: other!.id })
.where(inArray(calls.id, [ids.a!]));
await t.db.insert(callExtensions).values([
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
]);
const cookie = await t.login('sam@example.com', 'sam-password-123');
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
expect(r.statusCode, r.body).toBe(200);
const s = r.json();
expect(s.days).toEqual([
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
]);
expect(s.totals).toEqual({
calls: 3,
answered: 1,
missed: 1,
avgTalkSeconds: 40,
talkSeconds: 40,
outsideSeconds: { inbound: 95, outbound: 0 },
});
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
// The company summary is unchanged by who rang.
expect(
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
).toBe(2);
});
});
+2 -2
View File
@@ -117,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 47 tables', async () => {
it('creates all 48 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 47 });
expect(r.rows[0]).toEqual({ n: 48 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+5 -3
View File
@@ -105,7 +105,7 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
expect(await warningCodes()).not.toContain('tls_cert');
});
it('a TLS trunk: no qualify, Test does not send OPTIONS, TLS stays on while it exists', async () => {
it('a TLS trunk: qualified like the others (24.0.0-rc3), TLS stays on while it exists', async () => {
const trunk = await req('POST', '/api/trunks', {
name: 'voip.ms TLS',
serverHost: '127.0.0.1',
@@ -119,13 +119,15 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
await t.app.generator.apply({ trigger: 'test' });
const { readFile } = await import('node:fs/promises');
const conf = await readFile(join(t.app.generator.dir, 'pjsip_selfphoneng.conf'), 'utf8');
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 0\n/);
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 60\n/);
expect(conf).toMatch(/media_encryption = sdes/);
expect(conf).toMatch(/verify_server = yes/);
t.live.amiConnected = true;
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
expect(test.statusCode).toBe(200);
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([]);
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([
expect.objectContaining({ Endpoint: `trunk-${trunk.json().id}` }),
]);
const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false });
expect(off.statusCode).toBe(400);
expect(off.json().error.message).toMatch(/uses TLS/);
+11 -1
View File
@@ -7,7 +7,7 @@ import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LiveMessage } from '@selfphoneng/shared';
import { extensions, voicemailMessages } from '../src/db/schema.ts';
import { extensions, voicemailMessages, webhookDeliveries, webhooks } from '../src/db/schema.ts';
import { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
@@ -175,11 +175,21 @@ describe.skipIf(!hasTestDb)('voicemail watcher', () => {
const url = `http://127.0.0.1:${(stt.address() as AddressInfo).port}/v1`;
await setSetting(t.db, 'ai.stt', { url, model: 'whisper' });
await setSetting(t.db, 'ai.voicemailTranscripts', true);
await t.db
.insert(webhooks)
.values({ url: 'https://hooks.example.com/x', secretEnc: 'x', events: ['voicemail.new'] });
try {
leave('INBOX', 3, '1792300700-00000004', '<104>');
await w.scan('101');
const r = (await rows()).find((x) => x.msgId === '1792300700-00000004')!;
expect(r.transcript).toBe('Please call me.');
// A webhook goes off this server: the words stay here (10 §1, 08 §7 #6).
const [hook] = await t.db
.select()
.from(webhookDeliveries)
.where(eq(webhookDeliveries.event, 'voicemail.new'));
expect((hook!.payload as { data: object }).data).toMatchObject({ id: r.id });
expect((hook!.payload as { data: object }).data).not.toHaveProperty('transcript');
expect(t.mails.at(-1)!.text).toContain(
'What they said (automatic, may have mistakes):\nPlease call me.',
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/probe",
"version": "0.0.10",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+14 -3
View File
@@ -1,10 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy on this machine that sets
// X-Forwarded-For. HOST: where to listen; with TRUST_PROXY=true 127.0.0.1 (only the proxy may
// reach it), else every address. PROBE_KEY: the key from SelfPhoneNG's Settings → Network;
// without one anyone who finds the probe can use it.
import { createProbe } from './server.ts';
const port = Number(process.env.PORT ?? 8080);
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
const trustProxy = process.env.TRUST_PROXY === 'true';
const host = process.env.HOST ?? (trustProxy ? '127.0.0.1' : '::');
const key = process.env.PROBE_KEY || undefined;
const probe = createProbe({ trustProxy, key });
probe.listen(port, host, () => console.log(`selfphoneng-probe listening on ${host} ${port}`));
if (!key)
console.warn(
'No PROBE_KEY: anyone who finds this probe can use it. Make one in SelfPhoneNG ' +
'(Settings → Network → Generate key) and set PROBE_KEY.',
);
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
+44 -3
View File
@@ -1,12 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits.
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits,
// believes X-Forwarded-For only from a proxy on this machine (08 §7 #4), and with PROBE_KEY only
// answers who sends it (#9).
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
import { createServer as httpServer, type Server as HttpServer } from 'node:http';
import {
createServer as httpServer,
type IncomingMessage,
type Server as HttpServer,
} from 'node:http';
import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runTest } from './checks.ts';
import { createProbe, parseCheck } from './server.ts';
import { clientIp, createProbe, parseCheck } from './server.ts';
const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
const listen = <T extends Server | HttpServer>(s: T) =>
@@ -165,3 +171,38 @@ describe('probe server', () => {
target.close();
});
});
describe('the probe key', () => {
it('with a key, only a request that sends it gets tested (08 §7 #9)', async () => {
const key = 'k3y'.repeat(15);
const probe = await listen(createProbe({ key }));
const post = (authorization?: string) =>
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(authorization && { authorization }) },
body: JSON.stringify({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 1 }] }),
});
expect((await post()).status).toBe(401);
expect((await post('Bearer wrong')).status).toBe(401);
expect((await post(`Bearer ${key}x`)).status).toBe(401);
expect((await post(`Bearer ${key}`)).status).toBe(200);
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
probe.close();
});
});
describe('the address that asked', () => {
const req = (peer: string, xff?: string) =>
({
headers: xff ? { 'x-forwarded-for': xff } : {},
socket: { remoteAddress: peer },
}) as unknown as IncomingMessage;
it('takes X-Forwarded-For only from the local proxy (08 §7 #4)', () => {
// Reached straight from the internet: the header is whatever the caller wrote.
expect(clientIp(req('203.0.113.5', '198.51.100.9'), true)).toBe('203.0.113.5');
for (const proxy of ['127.0.0.1', '::ffff:127.0.0.1', '::1'])
expect(clientIp(req(proxy, '10.0.0.1, 198.51.100.9'), true)).toBe('198.51.100.9');
expect(clientIp(req('::ffff:203.0.113.5', '198.51.100.9'), false)).toBe('203.0.113.5');
});
});
+26 -8
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe
// from being aimed at someone else; each address gets a few checks per 10 minutes.
// from being aimed at someone else; each address gets a few checks per 10 minutes. With a key
// (PROBE_KEY, made in SelfPhoneNG's Settings → Network), only who sends it is tested (08 §7 #9).
import { createHash, timingSafeEqual } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import { isIP } from 'node:net';
import { runTest, type Result, type Test } from './checks.ts';
@@ -9,6 +11,8 @@ import { runTest, type Result, type Test } from './checks.ts';
export type ProbeOptions = {
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
trustProxy?: boolean;
/** Only requests with `Authorization: Bearer <key>` are tested. None: anyone may ask. */
key?: string;
/** Checks per address per window. */
maxChecks?: number;
windowMs?: number;
@@ -48,15 +52,27 @@ export function parseCheck(body: unknown): { host: string; tests: Test[] } | str
return { host: b.host, tests };
}
const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
/**
* The address to test. X-Forwarded-For only counts when the request came from a proxy on this
* machine: reached directly, the header is whatever the caller wrote (08 §7 #4).
*/
export const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
const peer = req.socket.remoteAddress!.replace(/^::ffff:/, '');
const xff = req.headers['x-forwarded-for'];
const raw =
trustProxy && typeof xff === 'string'
? xff.split(',').at(-1)!.trim()
: req.socket.remoteAddress!;
return raw.replace(/^::ffff:/, '');
const local = peer === '127.0.0.1' || peer === '::1';
return trustProxy && local && typeof xff === 'string'
? xff
.split(',')
.at(-1)!
.trim()
.replace(/^::ffff:/, '')
: peer;
};
/** Equal, in the same time whatever was sent (hashes, so lengths don't show either). */
const sameKey = (a: string, b: string) =>
timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let size = 0;
@@ -78,7 +94,7 @@ function readJson(req: IncomingMessage): Promise<unknown> {
}
export function createProbe(opts: ProbeOptions = {}): Server {
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
const { trustProxy = false, key, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
// ponytail: in-process memory; addresses past the window are swept on each check.
const seen = new Map<string, number[]>();
const allow = (ip: string, now = Date.now()) => {
@@ -95,6 +111,8 @@ export function createProbe(opts: ProbeOptions = {}): Server {
if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
if (req.method !== 'POST' || req.url !== '/v1/check')
return send(res, 404, { error: 'not found' });
if (key && !sameKey(req.headers.authorization ?? '', `Bearer ${key}`))
return send(res, 401, { error: 'this probe needs its key' });
const ip = clientIp(req, trustProxy);
void readJson(req)
.then(async (body) => {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/web",
"version": "0.0.10",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
@@ -13,6 +13,7 @@
"@hookform/resolvers": "^5.9.1",
"@selfphoneng/shared": "workspace:*",
"@tanstack/react-query": "^5.104.3",
"lucide-react": "^1.56.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.90.0",
+10
View File
@@ -26,6 +26,8 @@ import type {
AnnouncementUpdate,
BlockedCreate,
BlockedDto,
SpamReportCreate,
SpamReportDto,
CallDetailDto,
CallDto,
CallFilter,
@@ -293,6 +295,9 @@ export const systemApi = {
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),
/** A new key for the network test probe, shown this once (08 §7 #9). */
probeKey: () =>
api<{ key: string }>('POST', '/api/settings/network/probe-key').then((r) => r.key),
tls: () => api<TlsCert>('GET', '/api/system/tls'),
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
networkTest: (mode: 'quick' | 'complete') =>
@@ -306,6 +311,9 @@ export const systemApi = {
export const meApi = {
calls: (cursor?: string) =>
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
reportSpam: (r: SpamReportCreate) => api<{ ok: true }>('POST', '/api/me/spam-reports', r),
/** The summary of the calls made to me. */
report: (r: ReportRange) => api<ReportSummary>('GET', `/api/me/reports/summary?${callQuery(r)}`),
get: () => api<MeDto>('GET', '/api/me'),
/** 14 §5.1: only for a caller ringing me now. */
callerCheck: (number: string) =>
@@ -505,6 +513,8 @@ export const blockedApi = {
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/blocked-numbers/${id}`),
reports: () => api<ListResponse<SpamReportDto>>('GET', '/api/spam-reports').then((r) => r.items),
dismiss: (id: string) => api<{ ok: true }>('DELETE', `/api/spam-reports/${id}`),
};
/** Filters as a query string (empty values left out). */
+1 -1
View File
@@ -26,7 +26,7 @@ export function RequireAuth({
}) {
const me = useMe();
const loc = useLocation();
if (me.isPending) return <p className="p-6 text-sm text-zinc-500">Loading…</p>;
if (me.isPending) return <p className="p-6 text-sm text-muted">Loading…</p>;
if (me.isError) {
if (me.error instanceof ApiError && me.error.status === 401)
return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname)}`} replace />;
@@ -143,7 +143,7 @@ export function DestinationPicker({
</p>
)}
{isIdKind(kind) && all[kind] && targets(kind).length === 0 && (
<p className="text-xs text-zinc-500">Nothing to pick yet ({what}).</p>
<p className="text-xs text-muted">Nothing to pick yet ({what}).</p>
)}
</fieldset>
);
+6 -5
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Native <dialog> (focus trap + Esc for free) as a centered modal or a right-hand drawer.
import { X } from 'lucide-react';
import { useEffect, useRef, type ReactNode } from 'react';
import { Button } from './ui.tsx';
@@ -28,27 +29,27 @@ export function Dialog({
const shape = drawer
? 'ml-auto mr-0 h-dvh max-h-dvh w-full max-w-md'
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-lg';
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-xl';
return (
<dialog
ref={ref}
onClose={onClose}
onClick={(e) => e.target === ref.current && onClose()}
aria-labelledby="dialog-title"
className={`${shape} bg-white p-0 text-zinc-900 shadow-xl backdrop:bg-black/40 dark:bg-zinc-900 dark:text-zinc-100`}
className={`${shape} bg-surface p-0 text-fg shadow-xl backdrop:bg-black/40`}
>
<div className="flex h-full flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<h2 id="dialog-title" className="text-base font-semibold">
{title}
</h2>
<Button variant="ghost" onClick={onClose} aria-label="Close" className="px-2 py-1">
✕
<X className="size-4" />
</Button>
</header>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
{footer && (
<footer className="flex justify-end gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">
<footer className="flex justify-end gap-2 border-t border-line px-4 py-3">
{footer}
</footer>
)}
+5 -5
View File
@@ -15,13 +15,13 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
Parked calls
</h2>
{parked.length === 0 ? (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
None. Transfer a call to 700 to park it; dial the slot to pick it up.
</p>
) : (
<ul
aria-label="Parked calls"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{parked.map((p) => (
<li
@@ -31,14 +31,14 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
<span className="w-10 font-mono font-semibold">{p.slot}</span>
<span className="min-w-0 flex-1 truncate">
{p.callerName ? `${p.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-zinc-500"> · parked by {p.parkedBy}</span>}
<span className="text-muted">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-muted"> · parked by {p.parkedBy}</span>}
</span>
<span className="font-mono tabular-nums">{since(p.since, now)}</span>
{onPickUp && (
<button
type="button"
className="rounded-md border border-zinc-300 px-2 py-1 dark:border-zinc-700"
className="rounded-md border border-line-strong bg-surface px-2 py-1"
onClick={() => onPickUp(p.slot)}
>
Pick up {p.slot}
+3 -3
View File
@@ -14,7 +14,7 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
code: in the app, choose <em>Fetch remote configuration</em> (or <em>Scan QR code</em>) and
point the camera at the code below. On a computer, paste the link instead.
</p>
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
<strong>MicroSIP</strong> (Windows) and <strong>Baresip</strong> (Linux) work too, set up by
hand: an admin can see the server, user name and password.
</p>
@@ -32,14 +32,14 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
className="rounded bg-white"
/>
<p className="font-mono text-xs break-all">{qr.data.url}</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Works until {new Date(qr.data.expiresAt).toLocaleString()}. It carries your phone
password: don't share it.
</p>
</div>
)}
{qr.error && <Alert>{qr.error.message}</Alert>}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
On a mobile phone, apps can miss calls while they're in the background (iPhones and
battery-saving Android phones stop them). For calls you can't miss, use a desk phone or this
browser, or forward unanswered calls to your mobile number.
+4 -4
View File
@@ -56,11 +56,11 @@ export function SignInCard() {
<div className="flex flex-col gap-1 text-sm">
<h3 className="font-medium">Password</h3>
{ways.password ? (
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password">
<Link className="text-link underline" to="/change-password">
Change password
</Link>
) : (
<p className="text-zinc-500">
<p className="text-muted">
You have no password here. An admin can give you one with Reset password.
</p>
)}
@@ -92,7 +92,7 @@ export function SignInCard() {
</>
) : (
<>
<p className="text-zinc-500">
<p className="text-muted">
Connect your {oidc.label} account to sign in with it as well.
</p>
<div>
@@ -133,7 +133,7 @@ function DirectoryConnect() {
}}
>
<h3 className="font-medium">Company directory</h3>
<p className="text-zinc-500">
<p className="text-muted">
Connect your directory account to sign in with its password as well.
</p>
<Field label="Directory username or email">
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SoundPicker (05 §3): pick a recorded prompt, play it, or upload a new one right here.
import { Play } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId, useRef } from 'react';
import { soundsApi } from '../api/client.ts';
@@ -63,7 +64,7 @@ export function SoundPicker({
void a.play().catch(() => undefined);
}}
>
▶
<Play className="size-4" />
</Button>
<Button onClick={() => file.current?.click()} disabled={upload.isPending}>
{upload.isPending ? 'Uploading…' : 'Upload new'}
+1 -1
View File
@@ -44,7 +44,7 @@ export function SpamNote({ check }: { check: SpamCheck | null | undefined }) {
>
{high ? 'Likely spam' : 'Possible spam'}
</span>
<span className="text-zinc-600 dark:text-zinc-400">{spamLine(check)}</span>
<span className="text-muted">{spamLine(check)}</span>
</span>
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { Moon, Sun } from 'lucide-react';
import { useState } from 'react';
import { toggleTheme } from '../theme.ts';
import { Button } from './ui.tsx';
@@ -12,7 +13,7 @@ export function ThemeToggle() {
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
onClick={() => setDark(toggleTheme())}
>
{dark ? '☀' : '☾'}
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
+35 -9
View File
@@ -3,15 +3,14 @@
import { useId, type ComponentProps, type ReactNode } from 'react';
import { APP_NAME } from '@selfphoneng/shared';
const focus =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500';
export const focusRing =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus';
const variants = {
primary: 'bg-sky-600 text-white hover:bg-sky-700 disabled:bg-sky-600/50',
secondary:
'border border-zinc-300 bg-white hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800',
primary: 'bg-primary text-white hover:bg-primary-hover disabled:bg-primary/50',
secondary: 'border border-line-strong bg-surface hover:bg-subtle',
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-red-600/50',
ghost: 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
ghost: 'hover:bg-subtle',
};
/** The icon (public/icon-192.png, cut from public/logo.png) and the product name. */
@@ -32,13 +31,40 @@ export function Button({
return (
<button
type="button"
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed ${variants[variant]} ${focus} ${className}`}
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed pointer-coarse:min-h-11 pointer-coarse:min-w-11 ${variants[variant]} ${focusRing} ${className}`}
{...props}
/>
);
}
export const inputClass = `w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900 ${focus} aria-invalid:border-red-500`;
/** A titled card: heading, an optional link or button on the right, then the body. */
export function Panel({
title,
action,
children,
}: {
title: string;
action?: ReactNode;
children: ReactNode;
}) {
const id = useId();
return (
<section
aria-labelledby={id}
className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 shadow-sm"
>
<div className="flex items-center justify-between gap-2">
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
{action}
</div>
{children}
</section>
);
}
export const inputClass = `w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm ${focusRing} aria-invalid:border-red-500`;
/** Label + control + error text. `children` gets the id and aria props to spread on the control. */
export function Field({
@@ -67,7 +93,7 @@ export function Field({
{(error || hint) && (
<p
id={msgId}
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-zinc-500'}`}
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-muted'}`}
>
{error ?? hint}
</p>
+64
View File
@@ -1,2 +1,66 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Theme colors: the logo's blue and navy. Pages use these names (bg-surface, text-muted,
border-line...), not palette colors, so light and dark are paired here once. Every text pair
is at least 4.5:1 in both themes. */
:root {
color-scheme: light;
--sp-page: #f8fafc;
--sp-surface: #ffffff;
--sp-subtle: #f1f5f9;
--sp-line: #e2e8f0;
--sp-line-strong: #cbd5e1;
--sp-fg: #0f172a;
--sp-muted: #475569;
--sp-primary: #2563eb;
--sp-primary-hover: #1d4ed8;
--sp-primary-soft: #eff6ff;
--sp-primary-line: #bfdbfe;
--sp-primary-ink: #1e3a8a;
--sp-link: #1d4ed8;
--sp-focus: #2563eb;
}
.dark {
color-scheme: dark;
--sp-page: #0b1120;
--sp-surface: #131c2e;
--sp-subtle: #1e293b;
--sp-line: #253349;
--sp-line-strong: #334155;
--sp-fg: #f1f5f9;
--sp-muted: #94a3b8;
--sp-primary: #2563eb;
--sp-primary-hover: #1d4ed8;
--sp-primary-soft: #172554;
--sp-primary-line: #1e3a8a;
--sp-primary-ink: #dbeafe;
--sp-link: #60a5fa;
--sp-focus: #60a5fa;
}
@theme inline {
--color-page: var(--sp-page);
--color-surface: var(--sp-surface);
--color-subtle: var(--sp-subtle);
--color-line: var(--sp-line);
--color-line-strong: var(--sp-line-strong);
--color-fg: var(--sp-fg);
--color-muted: var(--sp-muted);
--color-primary: var(--sp-primary);
--color-primary-hover: var(--sp-primary-hover);
--color-primary-soft: var(--sp-primary-soft);
--color-primary-line: var(--sp-primary-line);
--color-primary-ink: var(--sp-primary-ink);
--color-link: var(--sp-link);
--color-focus: var(--sp-focus);
}
/* iOS zooms into any field under 16px on focus and stays zoomed, which left the incoming call
dialog off screen. Unlayered, so it beats text-sm. */
@media (pointer: coarse) {
input,
select,
textarea {
font-size: 16px;
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ const router = createBrowserRouter([
const queryClient = new QueryClient();
document.body.className = 'bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100';
document.body.className = 'bg-page text-fg';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
+4 -4
View File
@@ -6,7 +6,7 @@ import { Link } from 'react-router';
import { aboutApi } from '../api/client.ts';
import { Alert, Brand } from '../components/ui.tsx';
const linkClass = 'text-sky-700 underline dark:text-sky-400';
const linkClass = 'text-link underline';
export function AboutPage() {
const about = useQuery({ queryKey: ['about'], queryFn: aboutApi.get });
@@ -44,18 +44,18 @@ export function AboutPage() {
<a
href="/api/about/source"
download="selfphone.tar.gz"
className="rounded-md bg-sky-600 px-4 py-2 font-medium text-white hover:bg-sky-700"
className="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover"
>
Code v{a.version}
</a>
<span className="text-zinc-500">
<span className="text-muted">
selfphone.tar.gz, {Math.max(1, Math.round(a.source.bytes / 1e6))} MB, built{' '}
{new Date(a.source.builtAt).toLocaleString()}
</span>
</div>
) : (
a && (
<p className="text-zinc-500">
<p className="text-muted">
This is a development build: the source archive is only in builds made with Docker.
The project&apos;s repository is below.
</p>
+1 -1
View File
@@ -44,7 +44,7 @@ export function ChangePasswordPage() {
return (
<AuthCard title="Change your password">
{forced && (
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<p className="mb-4 text-sm text-muted">
You signed in with a temporary password. Choose a new one to continue.
</p>
)}
+5 -5
View File
@@ -13,12 +13,12 @@ import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<main className="flex min-h-dvh flex-col items-center justify-center p-4">
<div className="w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="w-full max-w-sm rounded-xl border border-line bg-surface p-6 shadow-sm">
<img src="/logo-512.png" alt={APP_NAME} className="mx-auto mb-4 size-32 rounded-xl" />
<h1 className="mb-5 text-xl font-semibold">{title}</h1>
{children}
</div>
<Link to="/about" className="mt-3 text-xs text-zinc-500 hover:underline">
<Link to="/about" className="mt-3 text-xs text-muted hover:underline">
About SelfPhoneNG · source code
</Link>
</main>
@@ -73,12 +73,12 @@ export function LoginPage() {
{/* A full page load: the browser goes to the provider and comes back signed in. */}
<a
href={`/api/auth/oidc/start${next ? `?next=${encodeURIComponent(next)}` : ''}`}
className="rounded-md bg-sky-700 px-4 py-2 text-center text-sm font-medium text-white hover:bg-sky-800"
className="rounded-md bg-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-primary-hover"
>
Sign in with {oidc.label}
</a>
{showForm && (
<p className="text-center text-xs text-zinc-500" aria-hidden>
<p className="text-center text-xs text-muted" aria-hidden>
or with your password
</p>
)}
@@ -87,7 +87,7 @@ export function LoginPage() {
{!showForm && (
<button
type="button"
className="w-full text-center text-xs text-zinc-500 hover:underline"
className="w-full text-center text-xs text-muted hover:underline"
onClick={() => setAdminForm(true)}
>
Admin sign-in
+23 -18
View File
@@ -2,6 +2,7 @@
// 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; M6 adds "Ring everyone" to step 8 (an IVR needs a recorded greeting first).
import { Check } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
@@ -70,7 +71,7 @@ export function SetupPage() {
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'}`}
className={`rounded-full px-2 py-1 ${step === i + 1 ? 'bg-primary text-white' : 'bg-subtle'}`}
>
{i + 1}. {s}
</button>
@@ -109,7 +110,7 @@ function Nav({
hint?: string;
}) {
return (
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
<Button variant="primary" onClick={next} disabled={!canNext}>
Next
</Button>
@@ -118,7 +119,7 @@ function Nav({
{skip}
</Button>
)}
{hint && <span className="text-sm text-zinc-500">{hint}</span>}
{hint && <span className="text-sm text-muted">{hint}</span>}
</div>
);
}
@@ -127,7 +128,10 @@ 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>
<p className="text-sm">
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
The initial admin password has been changed.
</p>
<Nav next={next} />
</>
);
@@ -196,7 +200,7 @@ function CompanyForm({
</select>
)}
</Field>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button variant="primary" type="submit" disabled={save.isPending || !name.trim()}>
Save and continue
</Button>
@@ -343,7 +347,7 @@ function NetworkForm(p: {
I&apos;ve done this
</label>
</div>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button variant="primary" type="submit" disabled={save.isPending || !done}>
{save.isPending ? 'Saving…' : 'Save and continue'}
</Button>
@@ -399,7 +403,7 @@ function TrunkStep({ next }: { next: () => void }) {
const ok = status?.label === 'Registered' || status?.label === 'Reachable';
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Connect your voip.ms sub-account. Follow the checklist, save, and wait for{' '}
<strong>Registered</strong>.
</p>
@@ -457,7 +461,7 @@ function NumbersStep({ next }: { next: () => void }) {
);
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 9.
</p>
{trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />}
@@ -465,8 +469,8 @@ function NumbersStep({ next }: { next: () => void }) {
<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>}
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
{formatNumber(d.number)} {d.label && <span className="text-muted">{d.label}</span>}
</li>
))}
</ul>
@@ -554,7 +558,7 @@ function UsersStep({ next }: { next: () => void }) {
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Add the people who get a phone (up to 10 here; more later under Users &amp; Extensions).
Each gets the extension you type (any free number, like 1), or the next free one, and a
temporary password, shown once.
@@ -563,7 +567,8 @@ function UsersStep({ next }: { next: () => void }) {
<ul className="text-sm">
{withExt.map((u) => (
<li key={u.id}>
✓ {u.extension!.number} {u.displayName}
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
{u.extension!.number} {u.displayName}
</li>
))}
</ul>
@@ -614,7 +619,7 @@ function UsersStep({ next }: { next: () => void }) {
</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">
<div className="rounded-md border border-line p-3 text-sm">
<p className="mb-2 font-medium">
Give each person their temporary password (shown only now):
</p>
@@ -624,7 +629,7 @@ function UsersStep({ next }: { next: () => void }) {
<tr key={c.email}>
<td>{c.number}</td>
<td>{c.name}</td>
<td className="text-zinc-500">{c.email}</td>
<td className="text-muted">{c.email}</td>
<td className="font-mono">{c.password}</td>
</tr>
))}
@@ -682,7 +687,7 @@ function RoutingStep({ next }: { next: () => void }) {
const incomplete = Object.values(dest).some((d) => d === null);
return (
<>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
A digital receptionist ("press 1 for sales…") needs a recorded greeting: build it under
Digital receptionist later and pick it here or on the Phone numbers page.
</p>
@@ -715,7 +720,7 @@ function RoutingStep({ next }: { next: () => void }) {
})}
{everyone.isError && <Alert>{everyone.error.message}</Alert>}
{save.isError && <Alert>{save.error.message}</Alert>}
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button
variant="primary"
onClick={() => save.mutate()}
@@ -740,7 +745,7 @@ function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
<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">
<div className="border-t border-line pt-4">
<Button variant="primary" onClick={onFinish} disabled={busy}>
Finish
</Button>
@@ -753,6 +758,6 @@ function Loading({ q }: { q: { isError: boolean; error: Error | null } }): React
return q.isError ? (
<Alert>{q.error!.message}</Alert>
) : (
<p className="text-sm text-zinc-500">Loading…</p>
<p className="text-sm text-muted">Loading…</p>
);
}
+2 -2
View File
@@ -73,7 +73,7 @@ function TwoStep() {
<h2 id="two-step-h" className="font-medium">
Two-step sign-in
</h2>
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
Besides your password, signing in asks for a code from an authenticator app on your phone
(any app that does TOTP, such as Aegis, 2FAS, Google or Microsoft Authenticator).
</p>
@@ -115,7 +115,7 @@ function TwoStep() {
height={240}
className="rounded bg-white"
/>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Or type this key: <span className="font-mono break-all">{setup.data.secret}</span>
</p>
<Field label="2. Enter the 6-digit code it shows">
+117 -51
View File
@@ -1,6 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
// Admin shell (05 §2): sidebar grouped by section; unbuilt pages say which milestone brings them.
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Activity,
AudioLines,
Ban,
BellRing,
BookUser,
Cable,
ChartColumn,
Clock,
Hash,
Headset,
HistoryIcon,
LayoutDashboard,
Menu,
ListOrdered,
Megaphone,
MessageSquare,
MonitorSmartphone,
Music,
PhoneOutgoing,
Settings,
Split,
Users,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
@@ -9,28 +33,65 @@ import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Brand, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
// Grouped by job rather than spec order, so the 21 pages scan as six short lists.
export const ADMIN_PAGES = [
{ path: '', label: 'Dashboard', milestone: null },
{ path: 'users', label: 'Users & Extensions', milestone: null },
{ path: 'trunks', label: 'Trunks', milestone: null },
{ path: 'numbers', label: 'Phone numbers', milestone: null },
{ path: 'outbound', label: 'Outbound rules', milestone: null },
{ path: 'ring-groups', label: 'Ring groups', milestone: null },
{ path: 'queues', label: 'Queues', milestone: null },
{ path: 'ivrs', label: 'Digital receptionist', milestone: null },
{ path: 'hours', label: 'Office hours', milestone: null },
{ path: 'conferences', label: 'Conferences', milestone: null },
{ path: 'paging', label: 'Paging', milestone: null },
{ path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: null },
{ path: 'contacts', label: 'Contacts', milestone: null },
{ path: 'calls', label: 'Call history', milestone: null },
{ path: 'recordings', label: 'Recordings', milestone: null },
{ path: 'reports', label: 'Reports', milestone: null },
{ path: 'sms', label: 'SMS', milestone: 'M9' },
{ path: 'blocked', label: 'Blocked numbers', milestone: null },
{ path: 'settings', label: 'Settings', milestone: null },
{ path: 'system', label: 'System', milestone: null },
{ path: '', label: 'Dashboard', milestone: null, section: '', icon: LayoutDashboard },
{ path: 'trunks', label: 'Trunks', milestone: null, section: 'Call routing', icon: Cable },
{ path: 'numbers', label: 'Phone numbers', milestone: null, section: 'Call routing', icon: Hash },
{
path: 'outbound',
label: 'Outbound rules',
milestone: null,
section: 'Call routing',
icon: PhoneOutgoing,
},
{
path: 'ring-groups',
label: 'Ring groups',
milestone: null,
section: 'Call routing',
icon: BellRing,
},
{ path: 'queues', label: 'Queues', milestone: null, section: 'Call routing', icon: ListOrdered },
{
path: 'ivrs',
label: 'Digital receptionist',
milestone: null,
section: 'Call routing',
icon: Split,
},
{ path: 'hours', label: 'Office hours', milestone: null, section: 'Call routing', icon: Clock },
{
path: 'blocked',
label: 'Blocked numbers',
milestone: null,
section: 'Call routing',
icon: Ban,
},
{ path: 'users', label: 'Users & Extensions', milestone: null, section: 'People', icon: Users },
{ path: 'devices', label: 'Phones', milestone: null, section: 'People', icon: MonitorSmartphone },
{ path: 'contacts', label: 'Contacts', milestone: null, section: 'People', icon: BookUser },
{
path: 'conferences',
label: 'Conferences',
milestone: null,
section: 'Features',
icon: Headset,
},
{ path: 'paging', label: 'Paging', milestone: null, section: 'Features', icon: Megaphone },
{ path: 'sounds', label: 'Sounds & music', milestone: null, section: 'Features', icon: Music },
{ path: 'sms', label: 'SMS', milestone: 'M9', section: 'Features', icon: MessageSquare },
{ path: 'calls', label: 'Call history', milestone: null, section: 'History', icon: HistoryIcon },
{
path: 'recordings',
label: 'Recordings',
milestone: null,
section: 'History',
icon: AudioLines,
},
{ path: 'reports', label: 'Reports', milestone: null, section: 'History', icon: ChartColumn },
{ path: 'settings', label: 'Settings', milestone: null, section: 'System', icon: Settings },
{ path: 'system', label: 'System', milestone: null, section: 'System', icon: Activity },
] as const;
export function AdminLayout({ user }: { user: AuthUser }) {
@@ -52,29 +113,39 @@ export function AdminLayout({ user }: { user: AuthUser }) {
const links = (
<nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4">
<ul className="flex flex-col gap-0.5">
{ADMIN_PAGES.map((p) => (
<li key={p.path}>
<NavLink
to={`/admin${p.path ? `/${p.path}` : ''}`}
end={p.path === ''}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`flex items-center justify-between rounded-md px-3 py-1.5 text-sm ${isActive ? 'bg-sky-100 font-medium text-sky-900 dark:bg-sky-950 dark:text-sky-100' : 'hover:bg-zinc-200/60 dark:hover:bg-zinc-800'} ${p.milestone ? 'text-zinc-500' : ''}`
}
>
{p.label}
{p.milestone && <span className="text-[10px] text-zinc-400">{p.milestone}</span>}
</NavLink>
</li>
))}
</ul>
{[...new Set(ADMIN_PAGES.map((p) => p.section))].map((section) => (
<div key={section} className="mt-3 first:mt-0">
{section && (
<h2 className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted uppercase">
{section}
</h2>
)}
<ul className="flex flex-col gap-0.5">
{ADMIN_PAGES.filter((p) => p.section === section).map((p) => (
<li key={p.path}>
<NavLink
to={`/admin${p.path ? `/${p.path}` : ''}`}
end={p.path === ''}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`relative flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm pointer-coarse:py-2.5 ${isActive ? 'bg-primary-soft font-medium text-primary-ink before:absolute before:inset-y-1.5 before:left-0 before:w-1 before:rounded-full before:bg-primary' : 'hover:bg-subtle'} ${p.milestone ? 'text-muted' : ''}`
}
>
<p.icon className="size-4 shrink-0" />
<span className="flex-1">{p.label}</span>
{p.milestone && <span className="text-xs text-muted">{p.milestone}</span>}
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
);
return (
<div className="flex min-h-dvh">
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-zinc-200 bg-zinc-50 md:flex dark:border-zinc-800 dark:bg-zinc-900">
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-line bg-surface md:flex">
<p className="px-4 py-4 text-lg">
<Brand />
</p>
@@ -82,7 +153,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-1 border-b border-zinc-200 px-2 py-2 sm:gap-2 sm:px-4 dark:border-zinc-800">
<header className="flex items-center gap-1 border-b border-line px-2 py-2 sm:gap-2 sm:px-4">
<Button
variant="ghost"
className="md:hidden"
@@ -91,13 +162,11 @@ export function AdminLayout({ user }: { user: AuthUser }) {
aria-label="Menu"
onClick={() => setNavOpen((o) => !o)}
>
☰
<Menu className="size-5" />
</Button>
<Brand className="md:hidden" />
<div className="ml-auto flex items-center gap-1 whitespace-nowrap sm:gap-2">
<span className="hidden text-sm text-zinc-600 sm:inline dark:text-zinc-400">
{user.displayName}
</span>
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
<ThemeToggle />
{/* On phones this lives in the menu instead. */}
<span className="hidden sm:contents">
@@ -111,12 +180,9 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</div>
</header>
{navOpen && (
<div
id="mobile-nav"
className="border-b border-zinc-200 bg-zinc-50 pt-2 md:hidden dark:border-zinc-800 dark:bg-zinc-900"
>
<div id="mobile-nav" className="border-b border-line bg-surface pt-2 md:hidden">
{links}
<div className="border-t border-zinc-200 px-2 py-2 dark:border-zinc-800">
<div className="border-t border-line px-2 py-2">
<Button
variant="ghost"
className="w-full justify-start"
@@ -148,7 +214,7 @@ export function ComingSoon({ label, milestone }: { label: string; milestone: str
return (
<section>
<h1 className="mb-2 text-xl font-semibold">{label}</h1>
<p className="text-sm text-zinc-500">Coming in {milestone}.</p>
<p className="text-sm text-muted">Coming in {milestone}.</p>
</section>
);
}
+51 -7
View File
@@ -1,14 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings.
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings. Users'
// Report spam waits at the top for Block or Dismiss.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { blockedApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { Alert, Button, Field, Panel, inputClass } from '../../components/ui.tsx';
export function BlockedPage() {
const qc = useQueryClient();
const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list });
const reports = useQuery({ queryKey: ['blocked', 'reports'], queryFn: blockedApi.reports });
const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] });
const [number, setNumber] = useState('');
const [reason, setReason] = useState('');
@@ -24,14 +26,56 @@ export function BlockedPage() {
mutationFn: (id: string) => blockedApi.remove(id),
onSuccess: refresh,
});
// Blocking a number clears every report of it (api).
const block = useMutation({
mutationFn: (r: { number: string; reason: string }) => blockedApi.create(r),
onSuccess: refresh,
});
const dismiss = useMutation({
mutationFn: (id: string) => blockedApi.dismiss(id),
onSuccess: refresh,
});
const error = add.error ?? remove.error ?? block.error ?? dismiss.error ?? reports.error;
return (
<section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Blocked numbers</h1>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Calls from these numbers are rejected before any phone rings. Emergency calls you make are
never affected.
</p>
{!!reports.data?.length && (
<Panel title="Reported as spam">
<ul className="divide-y divide-line">
{reports.data.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
<span className="font-mono">{formatNumber(r.number)}</span>
<span className="min-w-0 flex-1">
{r.reason}
<span className="block text-xs text-muted">
{r.reportedBy ?? 'A former user'}, {new Date(r.createdAt).toLocaleString()}
</span>
</span>
<Button
variant="primary"
aria-label={`Block ${formatNumber(r.number)}`}
disabled={block.isPending}
onClick={() => block.mutate({ number: r.number, reason: r.reason })}
>
Block
</Button>
<Button
variant="ghost"
aria-label={`Dismiss the report of ${formatNumber(r.number)}`}
onClick={() => dismiss.mutate(r.id)}
>
Dismiss
</Button>
</li>
))}
</ul>
</Panel>
)}
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
@@ -66,14 +110,14 @@ export function BlockedPage() {
Block
</Button>
</form>
{(add.error ?? remove.error) && <Alert>{(add.error ?? remove.error)!.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">Nobody is blocked.</p>}
{error && <Alert>{error.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((b) => (
<li key={b.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm">
<span className="font-mono">{formatNumber(b.number)}</span>
<span className="flex-1 text-zinc-500">{b.reason}</span>
<span className="flex-1 text-muted">{b.reason}</span>
<Button
variant="ghost"
aria-label={`Unblock ${formatNumber(b.number)}`}
+11 -11
View File
@@ -21,10 +21,10 @@ export const fmtDuration = (s: number) =>
export const STATUS_LOOK: Record<CallDto['status'], string> = {
answered: 'bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100',
missed: 'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100',
voicemail: 'bg-sky-100 text-sky-900 dark:bg-sky-900 dark:text-sky-100',
voicemail: 'bg-primary-soft text-primary-ink',
abandoned: 'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
busy: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
failed: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
busy: 'bg-line text-fg',
failed: 'bg-line text-fg',
};
export const DIRECTION_TEXT = { inbound: 'In', outbound: 'Out', internal: 'Internal' };
@@ -71,7 +71,7 @@ export function CallsPage() {
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Call history</h1>
<a
className="rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
className="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm font-medium hover:bg-subtle"
href={callsApi.csvUrl(filter)}
>
Export CSV
@@ -148,10 +148,10 @@ export function CallsPage() {
</div>
</form>
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data && calls.length === 0 && <p className="text-sm text-zinc-500">No calls match.</p>}
{list.data && calls.length === 0 && <p className="text-sm text-muted">No calls match.</p>}
<ul
aria-label="Calls"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{calls.map((c) => (
<li key={c.id} className="flex flex-col gap-2 px-3 py-2 text-sm">
@@ -161,15 +161,15 @@ export function CallsPage() {
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-left"
onClick={() => setOpen(open === c.id ? null : c.id)}
>
<span className="w-36 text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-36 text-muted">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-14">{DIRECTION_TEXT[c.direction]}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{c.fromName ?? fmtNumber(c.fromNumber)}</span>
{c.fromName && (
<span className="text-zinc-500"> {fmtNumber(c.fromNumber)}</span>
<span className="text-muted"> {fmtNumber(c.fromNumber)}</span>
)} → {fmtNumber(c.toNumber)}
{c.answeredBy && (
<span className="text-zinc-500"> · answered by {c.answeredBy.name}</span>
<span className="text-muted"> · answered by {c.answeredBy.name}</span>
)}
<SpamNote check={c.spam} />
</span>
@@ -202,12 +202,12 @@ export function CallsPage() {
function Legs({ id }: { id: string }) {
const d = useQuery({ queryKey: ['call', id], queryFn: () => callsApi.get(id) });
if (!d.data) return <p className="text-xs text-zinc-500">Loading…</p>;
if (!d.data) return <p className="text-xs text-muted">Loading…</p>;
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<caption className="sr-only">Call legs</caption>
<thead className="text-left text-zinc-500">
<thead className="text-left text-muted">
<tr>
<th className="pr-3 font-normal">Time</th>
<th className="pr-3 font-normal">From channel</th>
+3 -3
View File
@@ -35,13 +35,13 @@ export function ConferencesPage() {
</div>
{rooms.isError && <Alert>{rooms.error.message}</Alert>}
{rooms.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No conference rooms yet. A room is a number (like 800) that several people dial to talk
together, with an optional PIN.
</p>
)}
{rooms.data && rooms.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{rooms.data.map((c) => (
<li
key={c.id}
@@ -49,7 +49,7 @@ export function ConferencesPage() {
>
<span className="font-mono">{c.number}</span>
<span className="font-medium">{c.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{c.pin ? `PIN ${c.pin}` : 'No PIN'}
{c.adminPin ? ` · admin PIN ${c.adminPin}` : ''} · up to {c.maxMembers}
{c.record ? ' · recorded' : ''}
+29 -21
View File
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// queue tiles, parked calls and extensions online.
// queue tiles, parked calls and extensions online, spam reports waiting.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
import { Link } from 'react-router';
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { blockedApi, liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button, inputClass } from '../../components/ui.tsx';
import { fmtNumber } from './CallsPage.tsx';
@@ -15,8 +15,8 @@ import { ParkedCalls } from '../../components/ParkedCalls.tsx';
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<h2 className="text-sm text-zinc-500">{title}</h2>
<div className="rounded-xl border border-line bg-surface shadow-sm p-4">
<h2 className="text-sm text-muted">{title}</h2>
<div className="mt-1 text-2xl font-semibold">{children}</div>
</div>
);
@@ -31,6 +31,11 @@ export function DashboardPage() {
const onCall = list.filter((e) =>
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
).length;
const reports = useQuery({
queryKey: ['blocked', 'reports'],
queryFn: blockedApi.reports,
refetchInterval: 30_000,
});
return (
<section className="flex flex-col gap-6">
@@ -40,7 +45,7 @@ export function DashboardPage() {
(t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'),
)}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<Card title="Extensions online">
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
</Card>
@@ -55,12 +60,18 @@ export function DashboardPage() {
<Card title="Telephony engine">
<span
className={
amiConnected ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
amiConnected ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}
>
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
</span>
</Card>
<Card title="Spam reports">
<Link to="/admin/blocked" className="hover:underline">
{reports.data?.length ?? '…'}
<span className="sr-only"> waiting on Blocked numbers</span>
</Link>
</Card>
</div>
<QueueTiles />
@@ -72,19 +83,19 @@ export function DashboardPage() {
<div>
<h2 className="mb-2 font-medium">Extensions</h2>
{list.length === 0 ? (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No extensions yet. Give a user a number under Users & Extensions.
</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.map((e) => (
<li key={e.number} className="flex items-center gap-3 px-3 py-2 text-sm">
<StatusDot ext={e} />
<span className="w-14 font-mono">{e.number}</span>
<span className="flex-1 truncate">{e.name}</span>
<span className="text-zinc-500">{statusLabel(e)}</span>
<span className="text-muted">{statusLabel(e)}</span>
{e.contacts && (
<span className="hidden w-24 text-right text-zinc-500 sm:inline">
<span className="hidden w-24 text-right text-muted sm:inline">
{e.contacts.length} device{e.contacts.length === 1 ? '' : 's'}
</span>
)}
@@ -118,15 +129,12 @@ function QueueTiles() {
null,
);
return (
<li
key={q.number}
className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
>
<Link className="text-sm text-zinc-500 hover:underline" to={`queues/${q.id}`}>
<li key={q.number} className="rounded-xl border border-line bg-surface shadow-sm p-4">
<Link className="text-sm text-muted hover:underline" to={`queues/${q.id}`}>
{q.number} {q.name}
</Link>
<div className="mt-1 text-2xl font-semibold">{q.waiting.length} waiting</div>
<div className="text-sm text-zinc-500">
<div className="text-sm text-muted">
Longest {oldest ? since(oldest, now) : '—'} · {available(q)} of {q.members.length}{' '}
agents free
</div>
@@ -258,11 +266,11 @@ function LiveCalls() {
</p>
)}
{calls.length === 0 ? (
<p className="text-sm text-zinc-500">No calls right now.</p>
<p className="text-sm text-muted">No calls right now.</p>
) : (
<ul
aria-label="Calls now"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{calls.map((c) => (
<li
@@ -272,15 +280,15 @@ function LiveCalls() {
>
<span className="min-w-0 flex-1">
<span className="font-medium">{c.from.name ?? fmtNumber(c.from.number)}</span>
{c.from.name && <span className="text-zinc-500"> {fmtNumber(c.from.number)}</span>}
{c.from.name && <span className="text-muted"> {fmtNumber(c.from.number)}</span>}
{' → '}
{fmtNumber(c.to.number)}
<span className="text-zinc-500">
<span className="text-muted">
{' '}
· {STATE_TEXT[c.state]} {since(c.answeredAt ?? c.startedAt, now)}
{c.recording && ' · recording'}
</span>
<span className="block font-mono text-xs text-zinc-500" data-channels>
<span className="block font-mono text-xs text-muted" data-channels>
{c.channels.join(' ')}
</span>
</span>
+7 -7
View File
@@ -42,14 +42,14 @@ export function IvrsPage() {
</div>
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No menus yet. A menu greets callers ("Thanks for calling. For sales press 1…") and sends
them where they choose. Upload the greeting under Sounds &amp; music, or right in the
form.
</p>
)}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((r) => (
<li
key={r.id}
@@ -57,7 +57,7 @@ export function IvrsPage() {
>
<span className="font-mono">{r.number}</span>
<span className="font-medium">{r.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{r.options.map((o) => `${o.digit}: ${o.summary}`).join(' · ') || 'No keys yet'}
</span>
<Button onClick={() => setEditing(r)}>Edit</Button>
@@ -230,10 +230,10 @@ function IvrForm({
aria-label={`Key ${d}`}
aria-pressed={selected === d}
onClick={() => setSelected(d)}
className={`flex min-h-16 flex-col items-start rounded-md border p-2 text-left text-xs ${selected === d ? 'border-sky-500 ring-2 ring-sky-500' : 'border-zinc-300 dark:border-zinc-700'} ${dest ? 'bg-sky-50 dark:bg-sky-950' : ''}`}
className={`flex min-h-16 flex-col items-start rounded-md border p-2 text-left text-xs ${selected === d ? 'border-primary ring-2 ring-primary' : 'border-line-strong'} ${dest ? 'bg-primary-soft' : ''}`}
>
<span className="text-lg font-semibold">{d}</span>
<span className="line-clamp-2 text-zinc-600 dark:text-zinc-400">
<span className="line-clamp-2 text-muted">
{dest ? describe(dest) : dest === null ? 'Not finished' : 'Unused'}
</span>
</button>
@@ -242,7 +242,7 @@ function IvrForm({
</div>
{errors.options && <Alert>{errors.options}</Alert>}
{selected && (
<div className="flex flex-col gap-2 rounded-md border border-zinc-200 p-3 dark:border-zinc-800">
<div className="flex flex-col gap-2 rounded-md border border-line p-3">
{keys[selected] === undefined ? (
<Button onClick={() => setKeys((k) => ({ ...k, [selected]: { type: 'hangup' } }))}>
Use key {selected}
@@ -281,7 +281,7 @@ function IvrForm({
/>
<span>
Callers may dial an extension directly (e.g. 101)
<span className="block text-xs text-zinc-500">
<span className="block text-xs text-muted">
A key that is also the first digit of an extension waits 3 seconds for more digits.
</span>
</span>
+7 -7
View File
@@ -37,26 +37,26 @@ export function NumbersPage() {
</Button>
</div>
{noTrunk && (
<p className="text-sm text-zinc-500">Add a trunk first: numbers arrive through one.</p>
<p className="text-sm text-muted">Add a trunk first: numbers arrive through one.</p>
)}
{dids.isError && <Alert>{dids.error.message}</Alert>}
{dids.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
{dids.isPending && <p className="text-sm text-muted">Loading…</p>}
{dids.data?.length === 0 && !noTrunk && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No numbers yet. Add the numbers (DIDs) you bought at voip.ms.
</p>
)}
{dids.data && dids.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{dids.data.map((d) => (
<li
key={d.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="font-mono">{formatNumber(d.number)}</span>
{d.label && <span className="text-zinc-500">{d.label}</span>}
{d.label && <span className="text-muted">{d.label}</span>}
<span className="flex-1 truncate">→ {d.destinationSummary}</span>
<span className="hidden text-zinc-500 sm:inline">{trunkName(d.trunkId)}</span>
<span className="hidden text-muted sm:inline">{trunkName(d.trunkId)}</span>
<Button onClick={() => setEditing(d)}>Edit</Button>
<Button
variant="ghost"
@@ -235,7 +235,7 @@ function DidDrawer({
<input type="checkbox" className="mt-1" {...form.register('recordInbound')} />
<span>
Record every call to this number
<span className="block text-xs text-zinc-500">
<span className="block text-xs text-muted">
Callers hear the recording notice from Settings → Recording first, if you set one.
Check the consent rules where you and your callers are.
</span>
+17 -16
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Office hours (05 §2, 03 §6.10): when calls go one way or another. A weekly grid (click and drag
// half hours), the exact ranges as a list, holidays, and force open / closed / automatic.
import { X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -36,11 +37,11 @@ const rangeText = (r: OpenRange) =>
export function OfficeHoursPage() {
const [tab, setTab] = useState<'hours' | 'holidays'>('hours');
const tabClass = (on: boolean) =>
`rounded-md px-3 py-2 text-sm font-medium ${on ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
`rounded-md px-3 py-2 text-sm font-medium ${on ? 'bg-subtle' : 'hover:bg-subtle'}`;
return (
<section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Office hours</h1>
<div role="tablist" className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800">
<div role="tablist" className="flex gap-1 border-b border-line pb-2">
<button
role="tab"
aria-selected={tab === 'hours'}
@@ -108,7 +109,7 @@ function HoursTab() {
{list.isError && <Alert>{list.error.message}</Alert>}
{override.error && <Alert>{override.error.message}</Alert>}
{list.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No office hours yet. Set your opening hours, then point your phone number at them: callers
go one way when you're open and another when you're closed.
</p>
@@ -117,13 +118,13 @@ function HoursTab() {
<article
key={tc.id}
aria-label={tc.name}
className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
className="flex flex-col gap-3 rounded-xl border border-line bg-surface shadow-sm p-4"
>
<div className="flex flex-wrap items-center gap-3">
<h2 className="flex-1 font-semibold">{tc.name}</h2>
<StatusChip tc={tc} />
</div>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<p className="text-sm text-muted">
{tc.openRanges.map(rangeText).join('; ') || 'Never open'} · {tc.timezone}
{tc.useHolidays ? ' · closed on holidays' : ''}
{tc.overrideDigit !== null &&
@@ -199,7 +200,7 @@ function WeekGrid({
onPointerUp={() => setPaint(null)}
onPointerLeave={() => setPaint(null)}
>
<div className="grid w-max grid-cols-[2.5rem_repeat(48,0.75rem)] gap-px text-[10px] text-zinc-500">
<div className="grid w-max grid-cols-[2.5rem_repeat(48,0.75rem)] gap-px text-[10px] text-muted">
<span />
{Array.from({ length: SLOTS }, (_, i) => (
<span key={i} className="text-center">
@@ -213,7 +214,7 @@ function WeekGrid({
<span
key={i}
data-cell={`${d}-${i}`}
className={`h-5 cursor-pointer touch-none rounded-sm ${on ? 'bg-green-500' : 'bg-zinc-200 dark:bg-zinc-800'}`}
className={`h-5 cursor-pointer touch-none rounded-sm ${on ? 'bg-green-500' : 'bg-line'}`}
onPointerDown={(e) => {
// Touch captures the pointer to this cell; let go so dragging paints the others.
const el = e.target as Element;
@@ -254,14 +255,14 @@ function RangeList({
aria-label={`Remove ${rangeText(r)}`}
onClick={() => onChange(ranges.filter((_, j) => j !== i))}
>
✕
<X className="size-4" />
</Button>
</li>
))}
{ranges.length === 0 && <li className="text-zinc-500">No open hours: always closed.</li>}
{ranges.length === 0 && <li className="text-muted">No open hours: always closed.</li>}
</ul>
<fieldset className="flex min-w-0 flex-wrap items-end gap-2 rounded-md border border-zinc-200 p-2 dark:border-zinc-800">
<legend className="px-1 text-xs text-zinc-500">Add hours</legend>
<fieldset className="flex min-w-0 flex-wrap items-end gap-2 rounded-md border border-line p-2">
<legend className="px-1 text-xs text-muted">Add hours</legend>
{DAYS.map((d) => (
<label key={d} className="flex items-center gap-1 text-sm">
<input
@@ -417,7 +418,7 @@ function HoursForm({
</Field>
<fieldset className="flex min-w-0 flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Open hours</legend>
<p className="text-xs text-zinc-500">Click or drag across the grid; green is open.</p>
<p className="text-xs text-muted">Click or drag across the grid; green is open.</p>
<WeekGrid ranges={ranges} onChange={setRanges} />
{errors.openRanges && <Alert>{errors.openRanges}</Alert>}
<RangeList ranges={ranges} onChange={setRanges} />
@@ -544,18 +545,18 @@ function HolidaysTab() {
Add US federal holidays for {year}
</Button>
{importUs.data && (
<span role="status" className="text-sm text-zinc-500">
<span role="status" className="text-sm text-muted">
Added {importUs.data.added}.
</span>
)}
</div>
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No holidays yet.</p>}
{list.data?.length === 0 && <p className="text-sm text-muted">No holidays yet.</p>}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((h) => (
<li key={h.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm">
<span className="flex-1 font-medium">{h.name}</span>
<span className="text-zinc-500">
<span className="text-muted">
{fmt(h.startDate)}
{h.endDate !== h.startDate && ` – ${fmt(h.endDate)}`}
{h.recurringYearly && ' · every year'}
+17 -18
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound rules (05 §2, 03 §6.12): ordered list (first match wins), form, and a test box.
// ponytail: ↑/↓ buttons instead of drag-to-reorder; keyboard-friendly, no drag library.
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
@@ -56,19 +57,17 @@ export function OutboundPage() {
New rule
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
<p className="max-w-prose text-sm text-muted">
When someone dials an outside number, the first rule whose pattern matches decides how the
call goes out. Put more specific rules (like toll-free) above general ones.
</p>
{routes.isError && <Alert>{routes.error.message}</Alert>}
{move.isError && <Alert>{move.error.message}</Alert>}
{routes.data?.length === 0 && (
<p className="text-sm text-zinc-500">
No rules yet. They are created with your first trunk.
</p>
<p className="text-sm text-muted">No rules yet. They are created with your first trunk.</p>
)}
{routes.data && routes.data.length > 0 && (
<ol className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ol className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{routes.data.map((r, i) => (
<li
key={r.id}
@@ -82,7 +81,7 @@ export function OutboundPage() {
disabled={i === 0 || move.isPending}
onClick={() => move.mutate({ from: i, to: i - 1 })}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
@@ -91,18 +90,18 @@ export function OutboundPage() {
disabled={i === routes.data.length - 1 || move.isPending}
onClick={() => move.mutate({ from: i, to: i + 1 })}
>
↓
<ArrowDown className="size-4" />
</Button>
</span>
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className={`font-medium ${r.enabled ? '' : 'text-zinc-400 line-through'}`}>
<span className="w-5 text-muted">{i + 1}.</span>
<span className={`font-medium ${r.enabled ? '' : 'text-muted line-through'}`}>
{r.name}
</span>
{!r.enabled && <span className="text-xs text-zinc-500">off</span>}
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
{!r.enabled && <span className="text-xs text-muted">off</span>}
<span className="flex-1 truncate font-mono text-xs text-muted">
{r.patterns.map((p) => p.match).join(' ')}
</span>
<span className="hidden text-zinc-500 sm:inline">
<span className="hidden text-muted sm:inline">
{CLASS_LABEL[r.requiredClass]} · {r.trunkIds.map(trunkName).join(' → ')}
</span>
<Button onClick={() => setEditing(r)}>Edit</Button>
@@ -162,7 +161,7 @@ function TestBox() {
if (!exts.data?.length) return null;
return (
<form
className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
className="flex flex-col gap-3 rounded-xl border border-line bg-surface shadow-sm p-4"
onSubmit={(e) => {
e.preventDefault();
if (number.trim()) test.mutate();
@@ -303,7 +302,7 @@ function RouteDrawer({
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Patterns</legend>
<details className="text-xs text-zinc-500">
<details className="text-xs text-muted">
<summary className="cursor-pointer">Pattern help</summary>
<p className="mt-1">
<code>X</code> any digit, <code>Z</code> 1–9, <code>N</code> 2–9, <code>[1-5]</code>{' '}
@@ -342,7 +341,7 @@ function RouteDrawer({
onClick={() => patterns.remove(i)}
disabled={patterns.fields.length === 1}
>
✕
<X className="size-4" />
</Button>
</div>
))}
@@ -375,7 +374,7 @@ function RouteDrawer({
<ol className="flex flex-col gap-1">
{chosen.map((id, i) => (
<li key={id} className="flex items-center gap-2 text-sm">
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className="w-5 text-muted">{i + 1}.</span>
<span className="flex-1">{trunks.find((t) => t.id === id)?.name ?? id}</span>
<Button
variant="ghost"
@@ -387,14 +386,14 @@ function RouteDrawer({
setTrunks(ids);
}}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
aria-label="Remove trunk"
onClick={() => setTrunks(chosen.filter((x) => x !== id))}
>
✕
<X className="size-4" />
</Button>
</li>
))}
+4 -4
View File
@@ -38,14 +38,14 @@ export function PagingPage() {
</div>
{groups.isError && <Alert>{groups.error.message}</Alert>}
{groups.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No paging groups yet. Dialing a paging group's number (like 850) makes its desk phones
answer by themselves, so everyone near them hears you. To talk to one desk phone this way,
dial *80 and its extension (like *80101).
</p>
)}
{groups.data && groups.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{groups.data.map((g) => (
<li
key={g.id}
@@ -53,7 +53,7 @@ export function PagingPage() {
>
<span className="font-mono">{g.number}</span>
<span className="font-medium">{g.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{g.duplex ? 'Two-way' : 'One-way'}:{' '}
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'}
</span>
@@ -193,7 +193,7 @@ function PagingForm({
{x.number} {x.name}
</label>
))}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Only desk phones are paged, not the browser phone, and phones already on a call are
skipped. Most Yealink, Grandstream, Polycom and Snom phones answer pages by themselves.
</p>
+13 -19
View File
@@ -47,7 +47,7 @@ export function PhonesPage() {
Add phone
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
<p className="max-w-prose text-sm text-muted">
Add a desk phone by its MAC address (on a sticker under the phone) and it sets itself up:
account, voicemail and pickup keys.
</p>
@@ -58,11 +58,11 @@ export function PhonesPage() {
</Alert>
)}
{phones.isError && <Alert>{phones.error.message}</Alert>}
{phones.data?.length === 0 && <p className="text-sm text-zinc-500">No phones yet.</p>}
{phones.data?.length === 0 && <p className="text-sm text-muted">No phones yet.</p>}
{phones.data && phones.data.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<div className="overflow-x-auto rounded-xl border border-line bg-surface shadow-sm">
<table className="w-full text-left text-sm">
<thead className="text-zinc-500">
<thead className="text-muted">
<tr>
<th className="px-3 py-2 font-medium">MAC</th>
<th className="px-3 py-2 font-medium">Phone</th>
@@ -71,18 +71,18 @@ export function PhonesPage() {
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
<tbody className="divide-y divide-line">
{phones.data.map((d) => (
<tr key={d.id}>
<td className="px-3 py-2 font-mono">{fmtMac(d.mac)}</td>
<td className="px-3 py-2">
{VENDOR[d.vendor].replace(' (set up by hand)', '')} {d.model}
{d.label && <span className="text-zinc-500"> · {d.label}</span>}
{d.label && <span className="text-muted"> · {d.label}</span>}
</td>
<td className="px-3 py-2">
{d.extensionNumber} {d.extensionName}
</td>
<td className="px-3 py-2 text-zinc-500">
<td className="px-3 py-2 text-muted">
{d.lastProvisionedAt
? `${new Date(d.lastProvisionedAt).toLocaleString()} from ${d.lastProvisionedIp}`
: 'Never'}
@@ -248,7 +248,7 @@ function PhoneDrawer({
)}
</Field>
{form.vendor === 'grandstream' && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Grandstream line keys differ by model, so busy-lamp keys aren't set: add them on the
phone.
</p>
@@ -273,16 +273,13 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
});
const row = (k: string, v: string) => (
<div className="contents">
<dt className="text-zinc-500">{k}</dt>
<dt className="text-muted">{k}</dt>
<dd className="font-mono break-all">{v}</dd>
</div>
);
if (phone.vendor === 'generic')
return (
<section
aria-label="Manual settings"
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
>
<section aria-label="Manual settings" className="rounded-md bg-subtle p-3 text-sm">
<p className="mb-2">Enter these on the phone:</p>
{creds.data && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
@@ -298,10 +295,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
</section>
);
return (
<section
aria-label="Set up the phone"
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
>
<section aria-label="Set up the phone" className="rounded-md bg-subtle p-3 text-sm">
<p className="mb-2">
In the phone's web page (Settings → Auto Provision), or with DHCP option 66, set:
</p>
@@ -310,7 +304,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
{row('Username', phone.mac)}
{row('Password', phone.provisioningPassword)}
</dl>
<p className="mt-2 text-zinc-500">
<p className="mt-2 text-muted">
Then reboot the phone.{' '}
{phone.lastProvisionedAt
? 'It last fetched its settings ' +
@@ -327,7 +321,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
{preview && (
<pre
aria-label="Config preview"
className="mt-2 max-h-80 overflow-auto rounded bg-white p-2 text-xs dark:bg-zinc-950"
className="mt-2 max-h-80 overflow-auto rounded bg-surface p-2 text-xs"
>
{preview}
</pre>
+21 -19
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queues (05 §2, 03 §6.8): callers wait with music until an agent is free. The list shows live
// counts; a queue's page shows who is waiting, the agents (pause/unpause) and today's numbers.
import { ArrowLeft, X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useParams } from 'react-router';
@@ -59,13 +60,13 @@ function QueueList() {
</div>
{queues.isError && <Alert>{queues.error.message}</Alert>}
{queues.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No queues yet. In a queue (like 550 for Support) callers wait with music until one of the
agents is free.
</p>
)}
{queues.data && queues.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{queues.data.map((q) => {
const l = live[q.number];
return (
@@ -77,7 +78,7 @@ function QueueList() {
<Link className="font-medium underline-offset-2 hover:underline" to={q.id}>
{q.name}
</Link>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{q.members.length} agent{q.members.length === 1 ? '' : 's'}
{l ? ` · ${available(l)} available · ${l.waiting.length} waiting` : ''}
</span>
@@ -152,13 +153,14 @@ function QueueDetail({ id }: { id: string }) {
return (
<section className="flex flex-col gap-6">
<div>
<Link className="text-sm text-zinc-500 hover:underline" to="..">
← Queues
<Link className="inline-flex items-center gap-1 text-sm text-muted hover:underline" to="..">
<ArrowLeft className="size-4" />
Queues
</Link>
<h1 className="text-xl font-semibold">
<span className="font-mono">{q.number}</span> {q.name}
</h1>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
{STRATEGY_LABEL[q.strategy]} · waits up to {q.maxWaitSeconds} s, then {q.timeoutSummary}
</p>
</div>
@@ -166,18 +168,18 @@ function QueueDetail({ id }: { id: string }) {
<div>
<h2 className="mb-2 font-medium">Waiting</h2>
{!live?.waiting.length ? (
<p className="text-sm text-zinc-500">Nobody is waiting.</p>
<p className="text-sm text-muted">Nobody is waiting.</p>
) : (
<ol
aria-label="Waiting callers"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{live.waiting.map((w, i) => (
<li key={w.linkedid} className="flex items-center gap-3 px-3 py-2 text-sm">
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className="w-5 text-muted">{i + 1}.</span>
<span className="flex-1">
{w.callerName ? `${w.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(w.callerNumber)}</span>
<span className="text-muted">{fmtNumber(w.callerNumber)}</span>
</span>
<span className="font-mono tabular-nums">{since(w.since, now)}</span>
</li>
@@ -190,11 +192,11 @@ function QueueDetail({ id }: { id: string }) {
<h2 className="mb-2 font-medium">Agents</h2>
{toggle.error && <Alert>{toggle.error.message}</Alert>}
{q.members.length === 0 ? (
<p className="text-sm text-zinc-500">No agents yet. Add some with Edit.</p>
<p className="text-sm text-muted">No agents yet. Add some with Edit.</p>
) : (
<ul
aria-label="Agents"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{q.members.map((m) => {
const e = exts.data?.find((x) => x.id === m.extensionId);
@@ -207,7 +209,7 @@ function QueueDetail({ id }: { id: string }) {
>
<span className="w-12 font-mono">{e?.number}</span>
<span className="min-w-0 flex-1 truncate">{e?.name}</span>
<span className="text-zinc-500">
<span className="text-muted">
{paused ? 'Paused' : STATE_LABEL[l?.state ?? 'unavailable']}
{l ? ` · ${l.callsTaken} call${l.callsTaken === 1 ? '' : 's'}` : ''}
</span>
@@ -222,7 +224,7 @@ function QueueDetail({ id }: { id: string }) {
})}
</ul>
)}
<p className="mt-1 text-xs text-zinc-500">
<p className="mt-1 text-xs text-muted">
Agents can also log in and out from a phone with *45{q.number}.
</p>
</div>
@@ -246,8 +248,8 @@ function QueueDetail({ id }: { id: string }) {
],
] as const
).map(([k, v]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">{k}</dt>
<div key={k} className="rounded-xl border border-line bg-surface shadow-sm p-3">
<dt className="text-muted">{k}</dt>
<dd className="text-lg font-semibold">{v ?? '…'}</dd>
</div>
))}
@@ -418,7 +420,7 @@ function QueueForm({
{members.map((m) => (
<li key={m.extensionId} className="flex items-center gap-2 text-sm">
<span className="flex-1">{label(m.extensionId)}</span>
<label className="flex items-center gap-1 text-zinc-500">
<label className="flex items-center gap-1 text-muted">
Priority
<select
aria-label={`Priority of ${label(m.extensionId)}`}
@@ -449,7 +451,7 @@ function QueueForm({
setMembers((ms) => ms.filter((x) => x.extensionId !== m.extensionId))
}
>
✕
<X className="size-4" />
</Button>
</li>
))}
@@ -472,7 +474,7 @@ function QueueForm({
))}
</select>
)}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Priority 0 gets calls first; higher numbers only when the lower ones are all busy.
</p>
</fieldset>
+11 -14
View File
@@ -15,19 +15,19 @@ const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).
function Transcript({ id }: { id: string }) {
const t = useQuery({ queryKey: ['transcript', id], queryFn: () => recordingsApi.transcript(id) });
if (t.error) return <Alert>{t.error.message}</Alert>;
if (!t.data) return <p className="text-zinc-500">Loading…</p>;
if (!t.data) return <p className="text-muted">Loading…</p>;
return (
<ol aria-label="Transcript" className="mt-1 flex flex-col gap-1">
{t.data.lines.map((l, i) => (
<li key={i} className="flex gap-2">
<span className="w-10 shrink-0 text-zinc-500 tabular-nums">{mmss(l.at)}</span>
<span className="w-10 shrink-0 text-muted tabular-nums">{mmss(l.at)}</span>
<span className="w-20 shrink-0 font-medium">
{l.side === 'caller' ? 'Caller' : 'Other side'}
</span>
<span>{l.text}</span>
</li>
))}
{!t.data.lines.length && <li className="text-zinc-500">No words.</li>}
{!t.data.lines.length && <li className="text-muted">No words.</li>}
</ol>
);
}
@@ -55,7 +55,7 @@ export function RecordingsPage() {
return (
<section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Recordings</h1>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Calls are recorded when a user's record mode or a phone number says so. How long they're
kept is under Settings → Recording. With call transcripts on (Settings → AI), each call is
written out a minute or two after it ends.
@@ -90,21 +90,21 @@ export function RecordingsPage() {
</form>
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data && items.length === 0 && (
<p className="text-sm text-zinc-500">{q ? 'No call mentions that.' : 'No recordings.'}</p>
<p className="text-sm text-muted">{q ? 'No call mentions that.' : 'No recordings.'}</p>
)}
<ul
aria-label="Recordings"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{items.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2 text-sm">
<span className="w-36 text-zinc-500">{new Date(r.startedAt).toLocaleString()}</span>
<span className="w-36 text-muted">{new Date(r.startedAt).toLocaleString()}</span>
<span className="min-w-0 flex-1">
{r.call
? `${r.call.fromName ?? fmtNumber(r.call.fromNumber)} → ${fmtNumber(r.call.toNumber)}`
: 'Call not found'}
{r.durationMs !== null && (
<span className="text-zinc-500">
<span className="text-muted">
{' '}
· {fmtDuration(Math.round(r.durationMs / 1000))}
</span>
@@ -119,7 +119,7 @@ export function RecordingsPage() {
/>
<a
href={recordingsApi.audioUrl(r.id, true)}
className="rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="rounded-md px-3 py-2 text-sm font-medium hover:bg-subtle"
>
Download
</a>
@@ -127,15 +127,12 @@ export function RecordingsPage() {
Delete
</Button>
{r.summary && (
<p
aria-label="Summary"
className="w-full whitespace-pre-line text-zinc-700 dark:text-zinc-300"
>
<p aria-label="Summary" className="w-full whitespace-pre-line text-fg">
{r.summary}
</p>
)}
{r.aiError && (
<p className="w-full text-xs text-zinc-500">
<p className="w-full text-xs text-muted">
{r.hasTranscript ? '' : 'No transcript: '}
{r.aiError}
</p>
+101 -72
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
// day, missed per day, busiest hours, queue table.
// day, missed per day, busiest hours, queue table. `mine` (05 §4): the calls made to me, without
// the company's outside minutes, balance and queues.
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
import { reportsApi } from '../../api/client.ts';
import { meApi, reportsApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { fmtDuration } from './CallsPage.tsx';
@@ -45,14 +46,14 @@ function Bars({
{label}
{segments.length > 1 &&
segments.map(([k, text, color]) => (
<span key={k} className="flex items-center gap-1 text-xs font-normal text-zinc-500">
<span key={k} className="flex items-center gap-1 text-xs font-normal text-muted">
<span className={`inline-block size-2.5 rounded-sm ${color}`} />
{text}
</span>
))}
<span className="text-xs font-normal text-zinc-500">most in a day: {max}</span>
<span className="text-xs font-normal text-muted">most in a day: {max}</span>
</figcaption>
<div className="flex h-40 items-end gap-px border-b border-zinc-300 dark:border-zinc-700">
<div className="flex h-40 items-end gap-px border-b border-line-strong">
{days.map((d) => (
<div
key={d.date}
@@ -67,7 +68,7 @@ function Bars({
</div>
))}
</div>
<div className="flex justify-between text-xs text-zinc-500">
<div className="flex justify-between text-xs text-muted">
<span>{shortDate(days[0]!.date)}</span>
<span>{shortDate(days.at(-1)!.date)}</span>
</div>
@@ -84,7 +85,7 @@ function Heatmap({ hours }: { hours: number[][] }) {
<tr>
<th />
{hours[0]!.map((_, h) => (
<th key={h} scope="col" className="w-6 font-normal text-zinc-500">
<th key={h} scope="col" className="w-6 font-normal text-muted">
{h % 3 === 0 ? h : <span className="sr-only">{h}</span>}
</th>
))}
@@ -93,14 +94,14 @@ function Heatmap({ hours }: { hours: number[][] }) {
<tbody>
{hours.map((row, d) => (
<tr key={d}>
<th scope="row" className="pr-1 text-left font-normal text-zinc-500">
<th scope="row" className="pr-1 text-left font-normal text-muted">
{WEEKDAYS[d]}
</th>
{row.map((n, h) => (
<td
key={h}
title={`${WEEKDAYS[d]} ${h}:00: ${n} calls`}
className="h-6 w-6 rounded-sm bg-zinc-100 dark:bg-zinc-800"
className="h-6 w-6 rounded-sm bg-subtle"
style={
n ? { backgroundColor: `rgb(14 165 233 / ${0.15 + (0.85 * n) / max})` } : {}
}
@@ -116,18 +117,23 @@ function Heatmap({ hours }: { hours: number[][] }) {
);
}
export function ReportsPage() {
export function ReportsPage({ mine = false }: { mine?: boolean }) {
const [draft, setDraft] = useState<ReportRange>({});
const [range, setRange] = useState<ReportRange>({});
const summary = useQuery({
queryKey: ['reports', 'summary', range],
queryFn: () => reportsApi.summary(range),
queryKey: [mine ? 'me' : 'reports', 'summary', range],
queryFn: () => (mine ? meApi.report(range) : reportsApi.summary(range)),
});
const queues = useQuery({
queryKey: ['reports', 'queues', range],
queryFn: () => reportsApi.queues(range),
enabled: !mine,
});
const balance = useQuery({
queryKey: ['reports', 'balance'],
queryFn: reportsApi.balance,
enabled: !mine,
});
const balance = useQuery({ queryKey: ['reports', 'balance'], queryFn: reportsApi.balance });
const s = summary.data;
const date = (k: 'from' | 'to', label: string) => (
<Field label={label}>
@@ -145,7 +151,8 @@ export function ReportsPage() {
return (
<section className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">Reports</h1>
<h1 className="text-xl font-semibold">{mine ? 'My reports' : 'Reports'}</h1>
{mine && <p className="text-sm text-muted">Calls that rang you, not the ones you made.</p>}
<form
aria-label="Dates"
className="flex flex-wrap items-end gap-3"
@@ -160,7 +167,7 @@ export function ReportsPage() {
Show
</Button>
{s && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
{shortDate(s.from)} – {shortDate(s.to)}
{!range.from && !range.to && ' (last 30 days)'}
</p>
@@ -174,19 +181,33 @@ export function ReportsPage() {
{(
[
['Calls', s.totals.calls, null],
['Answered', s.totals.answered, null],
[
mine ? 'You answered' : 'Answered',
s.totals.answered,
mine ? 'not the ones a coworker picked up' : null,
],
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
[
'Average talk time',
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
null,
],
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
[
'Outside minutes',
fmtMinutes(s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound),
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
'Talk time',
fmtMinutes(s.totals.talkSeconds),
mine ? null : 'people talking, inside calls too',
],
...(mine
? []
: ([
[
'Outside minutes',
fmtMinutes(
s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound,
),
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
],
] as const)),
...(balance.data ?? []).map(
(b) =>
[
@@ -197,19 +218,25 @@ export function ReportsPage() {
),
] as const
).map(([k, v, note]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">{k}</dt>
<div key={k} className="rounded-xl border border-line bg-surface shadow-sm p-3">
<dt className="text-muted">{k}</dt>
<dd className="text-lg font-semibold tabular-nums">{v}</dd>
{note && <dd className="text-xs text-zinc-500">{note}</dd>}
{note && <dd className="text-xs text-muted">{note}</dd>}
</div>
))}
</dl>
<Bars label="Calls per day" days={s.days} segments={SERIES} />
<Bars
label="Outside minutes per day"
label="Calls per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
segments={mine ? SERIES.filter(([k]) => k !== 'outbound') : SERIES}
/>
{!mine && (
<Bars
label="Outside minutes per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
/>
)}
<Bars
label="Missed per day"
days={s.days}
@@ -222,53 +249,55 @@ export function ReportsPage() {
</>
)}
<div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-zinc-500">No queues.</p>}
{!!queues.data?.length && (
<div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-zinc-500">
<tr>
{[
'Queue',
'Calls',
'Answered',
'Hung up waiting',
'Timed out',
'Average wait',
'Answered in time',
].map((h) => (
<th key={h} scope="col" className="px-2 py-1 font-normal">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
<span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
{!mine && (
<div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
{!!queues.data?.length && (
<div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-muted">
<tr>
{[
'Queue',
'Calls',
'Answered',
'Hung up waiting',
'Timed out',
'Average wait',
'Answered in time',
].map((h) => (
<th key={h} scope="col" className="px-2 py-1 font-normal">
{h}
</th>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</thead>
<tbody className="divide-y divide-line">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
<span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</section>
);
}
+9 -8
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Ring groups (05 §2, 03 §6.7): one number that rings several people, everyone at once or one at
// a time, then goes somewhere else when nobody answers.
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -40,13 +41,13 @@ export function RingGroupsPage() {
</div>
{groups.isError && <Alert>{groups.error.message}</Alert>}
{groups.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No ring groups yet. A ring group is one number (like 500 for Sales) that rings several
people.
</p>
)}
{groups.data && groups.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{groups.data.map((g) => (
<li
key={g.id}
@@ -54,7 +55,7 @@ export function RingGroupsPage() {
>
<span className="font-mono">{g.number}</span>
<span className="font-medium">{g.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{g.strategy === 'ringall' ? 'Everyone at once' : 'One at a time'}:{' '}
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'} · then {g.noAnswerSummary}
</span>
@@ -238,7 +239,7 @@ function RingGroupForm({
<ol className="flex flex-col gap-1">
{members.map((m, i) => (
<li key={m} className="flex items-center gap-2 text-sm">
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className="w-5 text-muted">{i + 1}.</span>
<span className="flex-1">{label(m)}</span>
<Button
className="px-2 py-1"
@@ -246,7 +247,7 @@ function RingGroupForm({
disabled={i === 0}
onClick={() => move(i, -1)}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
className="px-2 py-1"
@@ -254,7 +255,7 @@ function RingGroupForm({
disabled={i === members.length - 1}
onClick={() => move(i, 1)}
>
↓
<ArrowDown className="size-4" />
</Button>
<Button
variant="ghost"
@@ -262,7 +263,7 @@ function RingGroupForm({
aria-label={`Remove ${label(m)}`}
onClick={() => setMembers((ms) => ms.filter((x) => x !== m))}
>
✕
<X className="size-4" />
</Button>
</li>
))}
@@ -282,7 +283,7 @@ function RingGroupForm({
))}
</select>
)}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Members' call forwarding and voicemail don't apply here; Do not disturb does.
</p>
</fieldset>
+141 -55
View File
@@ -52,16 +52,13 @@ export function SettingsPage() {
return (
<section className="flex max-w-2xl flex-col gap-4">
<h1 className="text-xl font-semibold">Settings</h1>
<nav
aria-label="Settings"
className="flex flex-wrap gap-1 border-b border-zinc-200 dark:border-zinc-800"
>
<nav aria-label="Settings" className="flex flex-wrap gap-1 border-b border-line">
{TABS.map((t) => (
<NavLink
key={t.id}
to={`/admin/settings/${t.id}`}
className={({ isActive }) =>
`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium ${isActive || (t.id === 'email' && tab === 'email') ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`
`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium ${isActive || (t.id === 'email' && tab === 'email') ? 'border-primary' : 'border-transparent hover:border-line-strong'}`
}
>
{t.label}
@@ -128,7 +125,7 @@ export function EmailTab({ s }: { s: SettingsDto }) {
});
}}
>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
The mail server used for emergency alerts and voicemail emails. Leave the server empty to
turn email off.
</p>
@@ -209,7 +206,7 @@ export function EmailTab({ s }: { s: SettingsDto }) {
</div>
{s.smtp && (
<div className="flex flex-col gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="flex flex-col gap-2 border-t border-line pt-4">
<div className="flex items-end gap-2">
<Field label="Send a test email to">
{(a) => (
@@ -317,7 +314,7 @@ export function EmergencyTab({ s }: { s: SettingsDto }) {
</Field>
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-sm font-medium">Ring these phones</legend>
{exts.data?.length === 0 && <p className="text-sm text-zinc-500">No extensions yet.</p>}
{exts.data?.length === 0 && <p className="text-sm text-muted">No extensions yet.</p>}
{exts.data?.map((x) => (
<label key={x.id} className="flex items-center gap-2 text-sm">
<input
@@ -340,7 +337,7 @@ export function EmergencyTab({ s }: { s: SettingsDto }) {
{test.isPending ? 'Testing…' : 'Test notifications'}
</Button>
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
The test sends the email and rings the phones above with &quot;TEST&quot; in front. It never
dials 911. Never test by calling 911.
</p>
@@ -398,7 +395,7 @@ function RecordingTab({ s }: { s: SettingsDto }) {
onChange={setNotice}
noneLabel="No notice"
/>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Played to callers of phone numbers set to record (Phone numbers → Record every call).
</p>
<Field label="Keep recordings for (days)" hint="0 keeps them forever">
@@ -427,7 +424,7 @@ function RecordingTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -462,7 +459,7 @@ function NumberingTab({ s }: { s: SettingsDto }) {
save.mutate(Object.fromEntries(RANGES.map(([k]) => [k, v[k].map(Number)])));
}}
>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<p className="text-sm text-muted">
Where <strong>Next free</strong> looks when you add a user, ring group, queue, digital
receptionist, conference room or paging group. You can always type any other free number of
1 to 6 digits instead, for example <strong>1</strong> for the first user: SelfPhoneNG checks
@@ -471,8 +468,8 @@ function NumberingTab({ s }: { s: SettingsDto }) {
</p>
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-2 text-sm">
<span />
<span className="text-zinc-500">From</span>
<span className="text-zinc-500">To</span>
<span className="text-muted">From</span>
<span className="text-muted">To</span>
{RANGES.map(([k, label]) => (
<Fragment key={k}>
<span>{label}</span>
@@ -495,7 +492,7 @@ function NumberingTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -508,6 +505,11 @@ function NetworkTab({ s }: { s: SettingsDto }) {
const [watch, setWatch] = useState(s['network.publicIpWatch']);
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
const qc = useQueryClient();
const makeKey = useMutation({
mutationFn: systemApi.probeKey,
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
});
return (
<form
className="flex flex-col gap-4"
@@ -539,7 +541,7 @@ function NetworkTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={watch} onChange={(e) => setWatch(e.target.checked)} />
Re-check the public IP every 5 minutes (dynamic IP)
</label>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
When it changes, the new IP is saved and admins get an email asking for a telephony restart.
Asks api.ipify.org (or ifconfig.me) for the address.
</p>
@@ -558,13 +560,61 @@ function NetworkTab({ s }: { s: SettingsDto }) {
/>
)}
</Field>
{s['network.probeUrl'] && (
<section
aria-labelledby="probe-key-h"
className="flex flex-col gap-2 rounded-lg border border-line p-3 text-sm"
>
<h3 id="probe-key-h" className="font-medium">
Probe key
</h3>
{s['network.hasProbeKey'] ? (
<p className="text-muted">Set: the probe only answers this server.</p>
) : (
<p className="text-amber-800 dark:text-amber-200">
Not set: anyone who finds your probe can use it. Strongly recommended: generate a key
and set it on the probe.
</p>
)}
{makeKey.data ? (
<>
<p>
Set this on the probe (its compose file or service), then restart it. It&rsquo;s
shown only now.
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 rounded bg-subtle p-2 font-mono text-xs break-all select-all">
PROBE_KEY={makeKey.data}
</code>
<Button
type="button"
onClick={() => void navigator.clipboard.writeText(makeKey.data)}
>
Copy
</Button>
</div>
</>
) : (
<div>
<Button type="button" onClick={() => makeKey.mutate()} disabled={makeKey.isPending}>
{s['network.hasProbeKey'] ? 'Generate a new key' : 'Generate key'}
</Button>
</div>
)}
<p className="text-xs text-muted">
A new probe address drops the key: generate one for the new probe.
</p>
</section>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{(save.error ?? detect.error) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
{(save.error ?? detect.error ?? makeKey.error) && (
<Alert>{(save.error ?? detect.error ?? makeKey.error)!.message}</Alert>
)}
</form>
);
}
@@ -638,7 +688,7 @@ function AiTab({ s }: { s: SettingsDto }) {
});
}}
>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<p className="text-sm text-muted">
AI runs on this server or your local network only: an address must be localhost or a local
IP (127.x, 10.x, 172.16–31.x, 192.168.x). Calls and text never go anywhere else. Leave an
address empty to turn that part off.
@@ -648,7 +698,7 @@ function AiTab({ s }: { s: SettingsDto }) {
return (
<fieldset key={kind} className="flex flex-col gap-2">
<legend className="mb-1 font-medium">{title}</legend>
<p className="text-xs text-zinc-500">{hint}</p>
<p className="text-xs text-muted">{hint}</p>
<div className="flex flex-wrap gap-2">
<input
aria-label={`${title} address`}
@@ -700,7 +750,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={vmText} onChange={(e) => setVmText(e.target.checked)} />
Voicemail transcripts (speech to text)
</label>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
New messages are written out before the voicemail email goes, and the text is in the email
and on the voicemail page.
</p>
@@ -708,7 +758,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={callText} onChange={(e) => setCallText(e.target.checked)} />
Call transcripts (speech to text)
</label>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Recorded calls are written out a minute or two after they end, the caller and the other side
apart, and can be searched on the Recordings page. Only calls recorded while this is on.
</p>
@@ -725,7 +775,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -746,6 +796,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
const [provOn, setProvOn] = useState(s['provisioning.enabled']);
const [perExt, setPerExt] = useState(String(s['calls.maxPerExtension']));
const [total, setTotal] = useState(String(s['calls.maxTotal']));
const [riskyCalls, setRiskyCalls] = useState(s['alerts.riskyCalls']);
const [providerCalls, setProviderCalls] = useState(s['alerts.providerCalls']);
const [balanceDrop, setBalanceDrop] = useState(String(s['alerts.balanceDropPerHour']));
const box = (checked: boolean, set: (v: boolean) => void, label: string) => (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={checked} onChange={(e) => set(e.target.checked)} />
@@ -764,6 +817,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
'provisioning.enabled': provOn,
'calls.maxPerExtension': Number(perExt),
'calls.maxTotal': Number(total),
'alerts.riskyCalls': riskyCalls,
'alerts.providerCalls': providerCalls,
'alerts.balanceDropPerHour': Number(balanceDrop),
'sip.udpPort': Number(sipPort),
'sip.tcpEnabled': tcpOn,
'sip.tlsEnabled': tlsOn,
@@ -773,7 +829,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
>
<h2 className="font-medium">Desk phones</h2>
{box(restrict, setRestrict, 'Only accept desk phones from the networks below')}
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Local networks: {s['network.localNets'].join(', ')}. Phones anywhere else (at home, on a
mobile network) can't sign in while this is on. Browser phones still work from anywhere:
they come through this website.
@@ -792,7 +848,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
)}
</Field>
<h2 className="font-medium">Outside calls at once</h2>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Limits the damage if someone steals a phone's password. Forwarded calls count for the person
who set the forward. 911 is never limited. Callers over the limit hear "all circuits are
busy". 0 = no limit.
@@ -823,6 +879,36 @@ function SecurityTab({ s }: { s: SettingsDto }) {
)}
</Field>
</div>
<h2 className="font-medium">Fraud alerts</h2>
<p className="-mt-2 text-xs text-muted">
Emails to every admin. The last two need the voip.ms API login on the trunk (Trunks).
</p>
{box(riskyCalls, setRiskyCalls, 'Calls to international, premium and Caribbean numbers')}
{box(
providerCalls,
setProviderCalls,
"Calls voip.ms billed that this phone system didn't make",
)}
<p className="-mt-2 text-xs text-muted">
Checked every 15 minutes. That&apos;s what a stolen trunk password looks like: the calls
never pass through this server, so no limit here can stop them.
</p>
<Field
label="voip.ms balance drop in an hour"
hint="In your account's currency. 0 = no email."
>
{(p) => (
<input
{...p}
className={`${inputClass} max-w-32`}
type="number"
min={0}
step="any"
value={balanceDrop}
onChange={(e) => setBalanceDrop(e.target.value)}
/>
)}
</Field>
<h2 className="font-medium">Phone setup files</h2>
{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')}
{box(lanOnly, setLanOnly, 'Only to phones on the local networks')}
@@ -842,7 +928,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
)}
</Field>
{box(tcpOn, setTcpOn, 'Accept SIP over TCP')}
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
For phones and trunks set to TCP. Phones set up by this server use UDP.
</p>
<h2 className="font-medium">Encrypted SIP (TLS)</h2>
@@ -852,7 +938,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
: (cert.data?.error ?? '…')}
</p>
{box(tlsOn, setTlsOn, 'Accept SIP over TLS')}
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Needed for an encrypted voip.ms trunk. Turning it on or changing the port needs a telephony
restart. After renewing the certificate, run scripts/tls-cert.sh.
</p>
@@ -871,7 +957,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -938,7 +1024,7 @@ function BackupsTab({ s }: { s: SettingsDto }) {
)}
</Field>
{box(recs, setRecs, 'Include call recordings (they can be large)')}
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Backups hold the database, voicemail, sounds and music on hold, in data/backups on the
server. Copy them somewhere else too. Passwords inside are encrypted with APP_SECRET from
.env: keep a copy of .env, or a backup can't be restored. To restore, run
@@ -948,7 +1034,7 @@ function BackupsTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -960,16 +1046,16 @@ function BackupsTab({ s }: { s: SettingsDto }) {
</Button>
</div>
{now.error && <Alert>{now.error.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No backups yet.</p>}
<ul aria-label="Backups" className="divide-y divide-zinc-200 dark:divide-zinc-800">
{list.data?.length === 0 && <p className="text-sm text-muted">No backups yet.</p>}
<ul aria-label="Backups" className="divide-y divide-line">
{list.data?.map((b) => (
<li key={b.name} className="flex items-center justify-between gap-3 py-2 text-sm">
<span>
{new Date(b.createdAt).toLocaleString()}{' '}
<span className="text-zinc-500">({(b.size / 1048576).toFixed(1)} MB)</span>
<span className="text-muted">({(b.size / 1048576).toFixed(1)} MB)</span>
</span>
<a
className="text-sky-700 hover:underline dark:text-sky-400"
className="text-link hover:underline"
href={`/api/system/backups/${b.name}/download`}
download
>
@@ -1054,12 +1140,12 @@ function WebhooksTab({ s }: { s: SettingsDto }) {
/>
Allow addresses on private networks (10.x, 192.168.x, localhost …)
</label>
<p className="-mt-4 text-xs text-zinc-500">
<p className="-mt-4 text-xs text-muted">
Off by default, so a webhook can't reach services inside your network. Turn it on for a
receiver in your own network (home lab).
</p>
{save.error && <Alert>{save.error.message}</Alert>}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Each POST has the headers X-SelfPhoneNG-Event, X-SelfPhoneNG-Timestamp and
X-SelfPhoneNG-Signature: sha256= the hex HMAC-SHA256 of timestamp + &quot;.&quot; + body
with the webhook&apos;s secret. A failed POST is tried again after 1 and 5 minutes, half an
@@ -1084,12 +1170,12 @@ function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
});
const remove = useMutation({ mutationFn: () => webhooksApi.remove(w.id), onSuccess: onChange });
return (
<li className="flex flex-col gap-2 rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<li className="flex flex-col gap-2 rounded-md border border-line p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium break-all">{w.url}</span>
<span className="text-zinc-500">{w.enabled ? 'On' : 'Off'}</span>
<span className="text-muted">{w.enabled ? 'On' : 'Off'}</span>
</div>
<p className="text-zinc-500">{w.events.join(', ')}</p>
<p className="text-muted">{w.events.join(', ')}</p>
<p>
Secret: <code className="break-all">{w.secret}</code>
</p>
@@ -1113,7 +1199,7 @@ function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
</p>
)}
{showLog && (
<ul aria-label="Deliveries" className="text-xs text-zinc-600 dark:text-zinc-400">
<ul aria-label="Deliveries" className="text-xs text-muted">
{log.data?.length === 0 && <li>Nothing sent yet.</li>}
{log.data?.map((d) => (
<li key={d.id}>
@@ -1195,19 +1281,19 @@ function TokensTab() {
</div>
)}
</form>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Send it as the header Authorization: Bearer &lt;token&gt;. A token can do everything its
admin can, except manage tokens. It stops working when revoked or when the admin is
disabled.
</p>
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No tokens.</p>}
<ul aria-label="API tokens" className="divide-y divide-zinc-200 dark:divide-zinc-800">
{list.data?.length === 0 && <p className="text-sm text-muted">No tokens.</p>}
<ul aria-label="API tokens" className="divide-y divide-line">
{list.data?.map((t) => (
<li key={t.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<span>
<span className="font-medium">{t.name}</span>{' '}
<code className="text-zinc-500">{t.tokenPrefix}…</code>
<span className="block text-xs text-zinc-500">
<code className="text-muted">{t.tokenPrefix}…</code>
<span className="block text-xs text-muted">
By {t.createdBy}, last used{' '}
{t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'}
</span>
@@ -1353,7 +1439,7 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
</span>
)}
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Each person then connects their own Contrack account in My settings, Contacts. If the AI
inside Contrack uses a cloud provider, what is stored there reaches that provider: turn
it off in Contrack, or point it at your local AI.
@@ -1409,7 +1495,7 @@ function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) {
});
return (
<div className="flex flex-col gap-1 text-sm">
<p className="text-zinc-500">
<p className="text-muted">
{st.ftcCursor
? `Downloading: at the complaints of ${st.ftcCursor.day}.`
: st.ftcLastSyncAt
@@ -1511,7 +1597,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
else submit();
}}
>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
When an outside caller from the US isn&rsquo;t in anyone&rsquo;s contacts, look the number
up and show what&rsquo;s known about them while it rings. Information only: calls are never
blocked or sent elsewhere. IPQualityScore answers first when it&rsquo;s set up; the FTC list
@@ -1588,7 +1674,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Desk phones</legend>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Shown in front of the caller&rsquo;s name when the answer comes in before the phone rings.
Letters, digits, spaces and . &apos; &amp; @ - only.
</p>
@@ -1742,16 +1828,16 @@ function SignInTab({ s }: { s: SettingsDto }) {
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">OpenID Connect</legend>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Keycloak, Authentik, Authelia, Zitadel, Entra ID, Google and others. At the provider, make
a confidential client using the standard (authorization code) flow, and add these:
</p>
<dl className="flex flex-col gap-2 text-sm">
{uris.map(([label, uri]) => (
<div key={label} className="flex flex-wrap items-center gap-2">
<dt className="w-48 text-zinc-500">{label}</dt>
<dt className="w-48 text-muted">{label}</dt>
<dd className="flex items-center gap-2">
<code className="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-800">{uri}</code>
<code className="rounded bg-subtle px-2 py-1">{uri}</code>
<Button onClick={() => void navigator.clipboard?.writeText(uri)}>Copy</Button>
</dd>
</div>
@@ -1834,7 +1920,7 @@ function SignInTab({ s }: { s: SettingsDto }) {
)}
{test.error && <Alert>{test.error.message}</Alert>}
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Then connect your own account in My account, and sign in from a private window.
</p>
</fieldset>
@@ -1910,12 +1996,12 @@ function DirectoryForm({ s }: { s: SettingsDto }) {
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Directory (LDAP)</legend>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
When a password isn&apos;t the one set here, SelfPhoneNG asks the directory. People sign
in with their directory username or email; the first time, they&apos;re matched to their
user here by email. Nobody gets a user just for being in the directory.
</p>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Every hour, someone no longer in the directory (or no longer matched by the user filter)
is disabled here, password and OpenID sign-in included. It never enables anyone again: do
that on Users.
+17 -16
View File
@@ -2,6 +2,7 @@
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements.
// Upload by picking or dropping a file; play, rename, delete. Music-on-hold classes and their
// files (03 §8) and announcements live here too.
import { ArrowDown, ArrowUp } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -68,7 +69,7 @@ export function SoundsPage() {
<h1 className="text-xl font-semibold">Sounds &amp; music</h1>
<form
aria-label="Upload a sound"
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-sky-500 bg-sky-50 dark:bg-sky-950' : 'border-zinc-300 dark:border-zinc-700'}`}
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-primary bg-primary-soft' : 'border-line-strong'}`}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@@ -122,19 +123,19 @@ export function SoundsPage() {
{add.isPending ? 'Converting…' : 'Upload'}
</Button>
</div>
{file && <p className="text-sm text-zinc-500">Selected: {file.name}</p>}
{file && <p className="text-sm text-muted">Selected: {file.name}</p>}
{tooBig && <Alert>That file is larger than 20 MB.</Alert>}
{add.error && <Alert>{add.error.message}</Alert>}
</form>
{sounds.isError && <Alert>{sounds.error.message}</Alert>}
{sounds.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No sounds yet. Upload greetings for your digital receptionist and announcements.
</p>
)}
{sounds.data && sounds.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{sounds.data.map((s) => (
<li
key={s.id}
@@ -142,7 +143,7 @@ export function SoundsPage() {
>
<span className="min-w-0 flex-1">
<span className="font-medium">{s.name}</span>{' '}
<span className="text-zinc-500">{secs(s.durationMs)}</span>
<span className="text-muted">{secs(s.durationMs)}</span>
</span>
<audio
controls
@@ -210,7 +211,7 @@ function MusicOnHold() {
<h2 id="moh-h" className="text-lg font-semibold">
Music on hold
</h2>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
What callers hear on hold or waiting in a queue. Default music is built in. Make your own
set here, then choose it for a user (Phone tab) or a queue.
</p>
@@ -297,7 +298,7 @@ function MohClassCard({
<div
aria-label={`Music on hold ${cls.name}`}
role="group"
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 font-medium">{cls.name}</span>
@@ -306,7 +307,7 @@ function MohClassCard({
</Button>
</div>
{cls.files.length === 0 && (
<p className="text-sm text-zinc-500">No music yet: callers hear the default music.</p>
<p className="text-sm text-muted">No music yet: callers hear the default music.</p>
)}
{cls.files.length > 1 && (
<label className="flex items-center gap-2 text-sm">
@@ -317,7 +318,7 @@ function MohClassCard({
onChange={(e) => update.mutate({ shuffle: e.target.checked })}
/>
Shuffle
<span className="text-zinc-500">
<span className="text-muted">
{cls.shuffle
? '(plays in random order)'
: '(each caller on hold starts at the top, in this order)'}
@@ -329,7 +330,7 @@ function MohClassCard({
<li key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{cls.files.length > 1 && !cls.shuffle && (
<span className="flex items-center gap-0.5">
<span className="w-5 text-right text-zinc-500 tabular-nums">{i + 1}.</span>
<span className="w-5 text-right text-muted tabular-nums">{i + 1}.</span>
<Button
variant="ghost"
aria-label={`Move ${f.name} up`}
@@ -337,7 +338,7 @@ function MohClassCard({
disabled={i === 0 || update.isPending}
onClick={() => move(i, -1)}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
@@ -346,12 +347,12 @@ function MohClassCard({
disabled={i === cls.files.length - 1 || update.isPending}
onClick={() => move(i, 1)}
>
↓
<ArrowDown className="size-4" />
</Button>
</span>
)}
<span className="min-w-0 flex-1">
{f.name} <span className="text-zinc-500">{secs(f.durationMs)}</span>
{f.name} <span className="text-muted">{secs(f.durationMs)}</span>
</span>
<audio
controls
@@ -425,19 +426,19 @@ function Announcements() {
</h2>
<Button onClick={() => setEditing('new')}>Add announcement</Button>
</div>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
An announcement plays a sound ("Our hours are…") and then sends the caller on, e.g. back to
a menu. Use them from phone numbers, menus and office hours.
</p>
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((a) => (
<li
key={a.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="font-medium">{a.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
plays "{a.soundName}", then {a.destinationSummary}
</span>
<Button onClick={() => setEditing(a)}>Edit</Button>
+25 -28
View File
@@ -33,12 +33,9 @@ function Panel({
children: ReactNode;
}) {
return (
<section
aria-label={title}
className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900/60"
>
<header className="flex items-center gap-2 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<span className="text-sky-600 dark:text-sky-400">{icon(path)}</span>
<section aria-label={title} className="rounded-xl border border-line bg-surface shadow-sm">
<header className="flex items-center gap-2 border-b border-line px-4 py-3">
<span className="text-link">{icon(path)}</span>
<h2 className="flex-1 font-medium">{title}</h2>
{action}
</header>
@@ -54,7 +51,7 @@ function Copy({ text, label }: { text: string; label: string }) {
type="button"
aria-label={`Copy ${label}`}
title={done ? 'Copied' : `Copy ${label}`}
className="rounded p-0.5 text-sky-600 hover:bg-zinc-100 dark:text-sky-400 dark:hover:bg-zinc-800"
className="rounded p-0.5 text-link hover:bg-subtle"
onClick={() =>
void navigator.clipboard.writeText(text).then(() => {
setDone(true);
@@ -70,8 +67,8 @@ function Copy({ text, label }: { text: string; label: string }) {
/** One "label value" cell of the information grid. */
function Item({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-zinc-100 py-2 dark:border-zinc-800">
<dt className="text-zinc-500">{label}</dt>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-line py-2">
<dt className="text-muted">{label}</dt>
<dd className="flex items-center gap-1.5 font-semibold">{children}</dd>
</div>
);
@@ -87,7 +84,7 @@ const STATUS: Record<DiagnosticCheck['status'], [string, string]> = {
function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] }) {
return (
<ul aria-label={label} className="divide-y divide-zinc-100 dark:divide-zinc-800">
<ul aria-label={label} className="divide-y divide-line">
{checks.map((c) => (
<li key={c.id} className="flex gap-3 px-4 py-2 text-sm">
<span
@@ -97,9 +94,9 @@ function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] })
<div className="min-w-0 flex-1">
<p>
<span className="font-medium">{c.label}</span>
<span className="text-zinc-500"> · {STATUS[c.status][0]}</span>
<span className="text-muted"> · {STATUS[c.status][0]}</span>
</p>
<p className="break-words text-zinc-600 dark:text-zinc-400">{c.detail}</p>
<p className="break-words text-muted">{c.detail}</p>
{c.fix && (
<p className="mt-1 rounded-md bg-amber-50 px-2 py-1 break-words text-amber-900 dark:bg-amber-950/50 dark:text-amber-100">
<strong>How to fix:</strong> {c.fix}
@@ -128,12 +125,12 @@ function Diagnostics() {
}
>
<h3 className="px-4 pt-3 text-sm font-medium">This server</h3>
{inside.isPending && <p className="px-4 py-2 text-sm text-zinc-500">Checking…</p>}
{inside.isPending && <p className="px-4 py-2 text-sm text-muted">Checking…</p>}
{inside.error && <Alert>{inside.error.message}</Alert>}
{inside.data && <Checks label="Server checks" checks={inside.data.checks} />}
<div className="flex flex-col gap-2 border-t border-zinc-200 px-4 py-3 text-sm dark:border-zinc-800">
<div className="flex flex-col gap-2 border-t border-line px-4 py-3 text-sm">
<h3 className="font-medium">From the internet</h3>
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
The network test probe connects back to this server from outside, the way a phone on
mobile data or your SIP provider does. Quick: ports 80, 443 and SIP. Complete: also SIP
over TLS, the browser phone and the call audio ports.
@@ -151,16 +148,16 @@ function Diagnostics() {
</Button>
</div>
{inside.data && !hasProbe && (
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Set a probe first:{' '}
<Link to="/admin/settings/network" className="text-sky-700 underline dark:text-sky-400">
<Link to="/admin/settings/network" className="text-link underline">
Settings → Network
</Link>
.
</p>
)}
{test.isPending && (
<p role="status" className="text-zinc-500">
<p role="status" className="text-muted">
Testing… (up to half a minute)
</p>
)}
@@ -203,7 +200,7 @@ export function SystemPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">System</h1>
<Link to="/about" className="text-sm text-sky-700 hover:underline dark:text-sky-400">
<Link to="/about" className="text-sm text-link hover:underline">
About and source code
</Link>
</div>
@@ -222,7 +219,7 @@ export function SystemPage() {
<Item label="Company">{s.companyName}</Item>
<Item label="Public IP">
{s.publicIp ?? 'not set'}
<span className="font-normal text-zinc-500">
<span className="font-normal text-muted">
{s.publicIpDynamic ? 'dynamic, re-checked' : 'static'}
</span>
{s.publicIp && <Copy text={s.publicIp} label="public IP" />}
@@ -231,7 +228,7 @@ export function SystemPage() {
<Item label="Ports">
<span>
SIP {p.sip}
<span className="font-normal text-zinc-500">{p.sipTcp ? ' UDP+TCP' : ' UDP'}</span>
<span className="font-normal text-muted">{p.sipTcp ? ' UDP+TCP' : ' UDP'}</span>
{p.sips && ` · SIPS ${p.sips}`} · Media {p.rtp} · HTTPS {p.https}
</span>
</Item>
@@ -266,13 +263,13 @@ export function SystemPage() {
<div className="flex items-center gap-2">
<pre
aria-label="Restart command"
className="flex-1 overflow-x-auto rounded-md bg-zinc-100 px-3 py-2 font-mono dark:bg-zinc-950"
className="flex-1 overflow-x-auto rounded-md bg-subtle px-3 py-2 font-mono"
>
{RESTART}
</pre>
<Copy text={RESTART} label="restart command" />
</div>
<p className="text-zinc-500">
<p className="text-muted">
Calls in progress drop, so do it when nobody is on the phone. Phones and trunks sign in
again by themselves within a minute; the web app, voicemail and history aren&apos;t
affected.
@@ -288,7 +285,7 @@ export function SystemPage() {
action={
<a
href="/api/system/events.csv"
className="flex items-center gap-1.5 rounded-md border border-zinc-300 px-2.5 py-1 text-sm hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
className="flex items-center gap-1.5 rounded-md border border-line-strong bg-surface px-2.5 py-1 text-sm hover:bg-subtle"
>
{icon(DOWNLOAD)} Download
</a>
@@ -296,12 +293,12 @@ export function SystemPage() {
>
{events.error && <Alert>{events.error.message}</Alert>}
{events.data && !rows.length && (
<p className="px-4 py-3 text-sm text-zinc-500">Nothing logged yet.</p>
<p className="px-4 py-3 text-sm text-muted">Nothing logged yet.</p>
)}
{!!rows.length && (
<div className="overflow-x-auto">
<table aria-label="Events" className="w-full text-left text-sm">
<thead className="text-zinc-500">
<thead className="text-muted">
<tr>
{['Severity', 'Event', 'Time', 'Source', 'Details'].map((h) => (
<th key={h} scope="col" className="px-4 py-2 font-medium whitespace-nowrap">
@@ -310,7 +307,7 @@ export function SystemPage() {
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
<tbody className="divide-y divide-line">
{rows.map((e) => (
<tr key={e.id}>
<td className="px-4 py-1.5 whitespace-nowrap">
@@ -332,7 +329,7 @@ export function SystemPage() {
</div>
)}
{events.hasNextPage && (
<div className="border-t border-zinc-100 py-2 text-center dark:border-zinc-800">
<div className="border-t border-line py-2 text-center">
<Button
onClick={() => void events.fetchNextPage()}
disabled={events.isFetchingNextPage}
+20 -16
View File
@@ -20,8 +20,7 @@ import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useLive } from '../../live/store.ts';
export function trunkStatus(t: Pick<TrunkDto, 'enabled' | 'authMode'>, live?: TrunkLive) {
if (!t.enabled)
return { label: 'Off', color: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' };
if (!t.enabled) return { label: 'Off', color: 'bg-line text-fg' };
const ok = 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200';
const bad = 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200';
const wait = 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200';
@@ -65,22 +64,22 @@ export function TrunksPage() {
New trunk
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
<p className="max-w-prose text-sm text-muted">
A trunk connects the phone system to your phone company (voip.ms or any SIP provider), so
calls can go to and come from the outside world.
</p>
{trunks.isError && <Alert>{trunks.error.message}</Alert>}
{trunks.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
{trunks.isPending && <p className="text-sm text-muted">Loading…</p>}
{trunks.data?.length === 0 && (
<p className="text-sm text-zinc-500">No trunks yet. Add your voip.ms sub-account.</p>
<p className="text-sm text-muted">No trunks yet. Add your voip.ms sub-account.</p>
)}
{trunks.data && trunks.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{trunks.data.map((t) => (
<li key={t.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm">
<StatusPill t={t} />
<span className="font-medium">{t.name}</span>
<span className="flex-1 truncate text-zinc-500">
<span className="flex-1 truncate text-muted">
{t.username ? `${t.username}@` : ''}
{t.serverHost}
</span>
@@ -404,7 +403,7 @@ export function TrunkForm({
Enabled
</label>
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<details className="rounded-md border border-line p-3 text-sm">
<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">
@@ -434,10 +433,10 @@ export function TrunkForm({
</details>
{voipms && (
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<details className="rounded-md border border-line p-3 text-sm">
<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">
<p className="text-muted">
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.
@@ -506,9 +505,9 @@ export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
return (
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 font-medium">Your voip.ms numbers</legend>
{list.isPending && <p className="text-zinc-500">Asking voip.ms…</p>}
{list.isPending && <p className="text-muted">Asking voip.ms…</p>}
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data?.length === 0 && <p className="text-zinc-500">voip.ms lists no numbers.</p>}
{list.data?.length === 0 && <p className="text-muted">voip.ms lists no numbers.</p>}
{list.data?.map((d) => (
<label key={d.number} className="flex items-center gap-2">
<input
@@ -522,8 +521,8 @@ export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
}
/>
{formatNumber(d.number)}
{d.description && <span className="text-zinc-500">{d.description}</span>}
{d.added && <span className="text-zinc-500">(added)</span>}
{d.description && <span className="text-muted">{d.description}</span>}
{d.added && <span className="text-muted">(added)</span>}
</label>
))}
{fresh.length > 0 && (
@@ -558,7 +557,7 @@ function TestPanel({ trunk }: { trunk: TrunkDto }) {
const live = useLive((s) => s.trunks[trunk.id]);
const status = trunkStatus(trunk, live);
return (
<div className="flex flex-col gap-2 rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900">
<div className="flex flex-col gap-2 rounded-md bg-subtle p-3 text-sm">
<div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.color}`}>
{status.label}
@@ -582,7 +581,7 @@ function TestPanel({ trunk }: { trunk: TrunkDto }) {
/** What the customer sets in the voip.ms portal (06 §1). */
function VoipmsChecklist() {
return (
<details className="rounded-md border border-sky-200 bg-sky-50 p-3 text-sm dark:border-sky-900 dark:bg-sky-950/40">
<details className="rounded-md border border-primary-line bg-primary-soft p-3 text-sm">
<summary className="cursor-pointer font-medium">Set up voip.ms first (checklist)</summary>
<ol className="mt-2 flex list-decimal flex-col gap-1.5 pl-5">
<li>
@@ -592,6 +591,11 @@ function VoipmsChecklist() {
<em>G.711u</em>, DTMF <em>RFC 2833</em>, NAT <em>Yes</em>, CallerID number = your main
number, international calling <em>off</em> unless you need it.
</li>
<li>
<strong>Turn on IP restriction</strong> (edit the sub-account → Security) and allow only
this server&apos;s public IP. A stolen password then can&apos;t make calls from anywhere
else. If your public IP changes, update it there too.
</li>
<li>
<strong>Pick a POP server</strong> close to you (e.g. chicago1.voip.ms). Enter the same
one below.
+11 -11
View File
@@ -82,7 +82,7 @@ export function UserDrawer({
<div
role="tablist"
aria-label="User settings"
className="mb-4 flex gap-1 border-b border-zinc-200 dark:border-zinc-800"
className="mb-4 flex gap-1 border-b border-line"
>
{tabs.map((t) => (
<button
@@ -93,7 +93,7 @@ export function UserDrawer({
disabled={!t.enabled}
title={t.enabled ? undefined : 'Give the user an extension number first'}
onClick={() => setTab(t.id)}
className={`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium disabled:cursor-not-allowed disabled:text-zinc-400 ${tab === t.id ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`}
className={`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium disabled:cursor-not-allowed disabled:text-muted ${tab === t.id ? 'border-primary' : 'border-transparent hover:border-line-strong'}`}
>
{t.label}
</button>
@@ -243,9 +243,9 @@ function GeneralTab({
</label>
)}
{user && (
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button onClick={() => onResetPassword(user)}>Reset password</Button>
<p className="mt-1 text-xs text-zinc-500">
<p className="mt-1 text-xs text-muted">
Creates a new temporary password and signs them out everywhere.
</p>
{user.twoStep && <ResetTwoStep user={user} />}
@@ -413,7 +413,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
};
const row = (label: string, value: ReactNode) => (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="w-28 shrink-0 text-zinc-500">{label}</dt>
<dt className="w-28 shrink-0 text-muted">{label}</dt>
<dd className="font-mono break-all">{value}</dd>
</div>
);
@@ -421,7 +421,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
return (
<div className="flex flex-col gap-4 text-sm">
{error && <Alert>{error}</Alert>}
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
Use these to set up a desk phone or softphone by hand. Viewing the password is recorded in
the audit log.
</p>
@@ -436,7 +436,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
{creds && row('Dial plan', creds.dialPlan)}
</dl>
{creds && (
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
The dial plan is for analog phone adapters (Grandstream HT80x and HT81x): numbers that
match go out at once, the rest after a pause or #.
</p>
@@ -446,7 +446,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
Reveal password
</Button>
)}
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
{!confirming ? (
<Button onClick={() => setConfirming(true)}>New SIP password</Button>
) : (
@@ -462,7 +462,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
)}
{done && <p className="mt-2 text-green-700 dark:text-green-400">New password saved.</p>}
</div>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<h3 className="mb-2 font-medium">Phone app</h3>
<PhoneAppSetup load={() => extensionsApi.softphoneQr(ext.id)} />
</div>
@@ -614,7 +614,7 @@ function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap'
<p>
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
</p>
</>
@@ -650,7 +650,7 @@ function ResetTwoStep({ user }: { user: UserDto }) {
Turn off two-step sign-in
</Button>
)}
<p className="mt-1 text-xs text-zinc-500">
<p className="mt-1 text-xs text-muted">
For a lost phone: they sign in with their password alone and can turn it on again.
</p>
{reset.error && <Alert>{reset.error.message}</Alert>}
+12 -14
View File
@@ -45,7 +45,7 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
</div>
{users.isError && <Alert>{users.error.message}</Alert>}
{users.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
{users.isPending && <p className="text-sm text-muted">Loading…</p>}
{users.data && (
<UsersTable
users={users.data}
@@ -105,7 +105,7 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
they must change it when they first sign in.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md bg-zinc-100 px-3 py-2 font-mono text-base dark:bg-zinc-800">
<code className="flex-1 rounded-md bg-subtle px-3 py-2 font-mono text-base">
{tempPassword?.password}
</code>
<Button
@@ -131,7 +131,7 @@ function UsersTable({
}) {
const live = useLive((s) => s.extensions);
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
if (users.length === 0) return <p className="text-sm text-zinc-500">No users yet.</p>;
if (users.length === 0) return <p className="text-sm text-muted">No users yet.</p>;
const ext = (u: UserDto) =>
u.extension ? (
<span className="inline-flex items-center gap-1.5 font-mono">
@@ -144,13 +144,11 @@ function UsersTable({
const badge = (u: UserDto) => (
<span className="flex flex-wrap gap-1">
{u.role === 'admin' && (
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-950 dark:text-sky-200">
<span className="rounded bg-primary-soft px-1.5 py-0.5 text-xs text-primary-ink">
admin
</span>
)}
{!u.isActive && (
<span className="rounded bg-zinc-200 px-1.5 py-0.5 text-xs dark:bg-zinc-700">inactive</span>
)}
{!u.isActive && <span className="rounded bg-line px-1.5 py-0.5 text-xs">inactive</span>}
</span>
);
// 12 §5: every way this user can sign in.
@@ -173,18 +171,18 @@ function UsersTable({
{/* Stacked cards on small screens (05 §1). */}
<ul className="flex flex-col gap-2 md:hidden">
{users.map((u) => (
<li key={u.id} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<li key={u.id} className="rounded-xl border border-line bg-surface shadow-sm p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">
{u.displayName}
{u.extension && <span className="ml-2 text-sm text-zinc-500">{ext(u)}</span>}
{u.extension && <span className="ml-2 text-sm text-muted">{ext(u)}</span>}
</p>
<p className="truncate text-sm text-zinc-500">{u.email}</p>
<p className="truncate text-sm text-muted">{u.email}</p>
</div>
{badge(u)}
</div>
<div className="mt-2 flex items-center justify-between text-xs text-zinc-500">
<div className="mt-2 flex items-center justify-between text-xs text-muted">
<span>
Last login: {fmtDate(u.lastLoginAt)} · {ways(u)}
</span>
@@ -194,7 +192,7 @@ function UsersTable({
))}
</ul>
<table className="hidden w-full text-left text-sm md:table">
<thead className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<thead className="border-b border-line text-muted">
<tr>
<th className="py-2 font-medium">Ext.</th>
<th className="py-2 font-medium">Name</th>
@@ -209,13 +207,13 @@ function UsersTable({
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-zinc-100 dark:border-zinc-800/60">
<tr key={u.id} className="border-b border-line">
<td className="py-2">{ext(u)}</td>
<td className="py-2 font-medium">{u.displayName}</td>
<td className="py-2">{u.email}</td>
<td className="py-2">{badge(u)}</td>
<td className="py-2">{ways(u)}</td>
<td className="py-2 text-zinc-500">{fmtDate(u.lastLoginAt)}</td>
<td className="py-2 text-muted">{fmtDate(u.lastLoginAt)}</td>
<td className="py-2 text-right">{actions(u)}</td>
</tr>
))}
+19 -27
View File
@@ -2,6 +2,7 @@
// Contacts (spec 11): my book with the company's (read-only) at /me/contacts, the company's at
// /admin/contacts. Add, edit, import (.vcf, .csv), share (an offer the colleague accepts or
// denies), click to call. useContactName() gives every page names for numbers.
import { X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import {
@@ -133,7 +134,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
href={ctk.publicUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
className="inline-flex items-center rounded-md border border-line-strong bg-surface px-3 py-2 text-sm font-medium hover:bg-subtle"
>
Import in Contrack
</a>
@@ -146,7 +147,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)}
</div>
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
@@ -161,11 +162,9 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)}
{ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />}
{list.data && shown.length === 0 && (
<p className="text-sm text-zinc-500">
{q ? 'No contact matches that.' : 'No contacts yet.'}
</p>
<p className="text-sm text-muted">{q ? 'No contact matches that.' : 'No contacts yet.'}</p>
)}
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{shown.map((c) => (
<li key={c.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm">
{canShare && (
@@ -184,9 +183,9 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)}
<span className="min-w-40 flex-1">
<span className="font-medium">{c.name}</span>
{c.company && <span className="text-zinc-500"> · {c.company}</span>}
{c.company && <span className="text-muted"> · {c.company}</span>}
{base === MINE && c.book === 'company' && (
<span className="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<span className="ml-2 rounded bg-subtle px-1.5 py-0.5 text-xs text-muted">
Company
</span>
)}
@@ -196,22 +195,17 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
<button
key={n.number}
type="button"
className="text-sky-700 underline dark:text-sky-400"
className="text-link underline"
aria-label={`Call ${c.name} ${n.label} ${show(n.number)}`}
onClick={() => call(n.number)}
>
{n.label && <span className="text-zinc-500 no-underline">{n.label} </span>}
{n.label && <span className="text-muted no-underline">{n.label} </span>}
{show(n.number)}
</button>
))}
</span>
{c.url && (
<a
href={c.url}
target="_blank"
rel="noreferrer"
className="text-sky-700 underline dark:text-sky-400"
>
<a href={c.url} target="_blank" rel="noreferrer" className="text-link underline">
Open in Contrack
</a>
)}
@@ -272,7 +266,7 @@ function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
return (
<div
role="status"
className="flex flex-wrap items-center gap-2 rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950"
className="flex flex-wrap items-center gap-2 rounded-md bg-primary-soft p-3 text-sm"
>
<span className="flex-1">
{count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
@@ -385,7 +379,7 @@ function ContactDrawer(props: {
setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) })
}
>
✕
<X className="size-4" />
</Button>
</div>
))}
@@ -454,7 +448,7 @@ function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => voi
}
>
<div className="flex flex-col gap-3 text-sm">
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
vCard files (.vcf) from a phone, Google Contacts, Outlook or Apple Contacts, or a CSV with
a header row: name, company, email, label1, number1, label2, number2…
</p>
@@ -474,7 +468,7 @@ function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => voi
if (read.length) run.mutate(false);
}}
/>
{run.isPending && <p className="text-zinc-500">Reading…</p>}
{run.isPending && <p className="text-muted">Reading…</p>}
{preview && (
<div role="status" className="flex flex-col gap-1">
<p>
@@ -538,7 +532,7 @@ function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean)
</p>
) : (
<div className="flex flex-col gap-2 text-sm">
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
Each person gets a copy to accept into their own contacts, or deny.
</p>
<label className="flex items-center gap-2 font-medium">
@@ -564,7 +558,7 @@ function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean)
setTo(next);
}}
/>
{p.name} <span className="text-zinc-500">({p.number})</span>
{p.name} <span className="text-muted">({p.number})</span>
</label>
))}
{share.error && <Alert>{share.error.message}</Alert>}
@@ -604,7 +598,7 @@ function SharedWithYou() {
return (
<section
aria-labelledby="shared-h"
className="flex flex-col gap-3 rounded-lg border border-sky-200 bg-sky-50 p-3 dark:border-sky-900 dark:bg-sky-950"
className="flex flex-col gap-3 rounded-lg border border-primary-line bg-primary-soft p-3"
>
<h2 id="shared-h" className="text-lg font-semibold">
Shared with you
@@ -643,11 +637,9 @@ function SharedWithYou() {
<li key={s.id} className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1">
{s.contact.name}
{s.contact.company && (
<span className="text-zinc-500"> · {s.contact.company}</span>
)}
{s.contact.company && <span className="text-muted"> · {s.contact.company}</span>}
{s.contact.numbers.map((n) => (
<span key={n.number} className="text-zinc-500">
<span key={n.number} className="text-muted">
{' '}
· {show(n.number)}
</span>
+218 -97
View File
@@ -1,12 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Reports, Settings, People, and the
// softphone.
import {
BookUser,
ChartColumn,
House,
Info,
LogOut,
Menu,
Phone,
PhoneMissed,
Settings,
Shield,
Users,
Voicemail,
X,
} from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
import { meApi, signOut } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Alert, Brand, Button } from '../../components/ui.tsx';
import { Alert, Brand, Button, Panel } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
@@ -16,8 +32,9 @@ import { fmtNumber } from '../admin/CallsPage.tsx';
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
import { ReportsPage } from '../admin/ReportsPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
const link = 'text-link underline';
export function MePage({ user }: { user: AuthUser }) {
const qc = useQueryClient();
@@ -36,15 +53,25 @@ export function MePage({ user }: { user: AuthUser }) {
const toasts = useLive((s) => s.toasts);
const offers = useContactShares().data?.length ?? 0;
const [menu, setMenu] = useState(false);
const close = () => setMenu(false);
async function logout() {
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
qc.clear();
nav('/login', { replace: true });
}
const tab = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium pointer-coarse:py-3 ${isActive ? 'bg-primary-soft text-primary-ink' : 'hover:bg-subtle'}`;
const icon = 'size-4 shrink-0';
const row = tab({ isActive: false });
return (
<main className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-6 p-4 sm:p-8">
<header className="flex flex-wrap items-center justify-between gap-2">
<header className="flex items-center justify-between gap-2">
<h1 className="text-xl">
<Brand />
</h1>
<div className="flex items-center gap-2">
<div className="hidden items-center gap-2 md:flex">
{user.role === 'admin' && (
<Link className={`${link} text-sm`} to="/admin">
Admin
@@ -54,47 +81,87 @@ export function MePage({ user }: { user: AuthUser }) {
About
</Link>
<ThemeToggle />
<Button
variant="ghost"
onClick={async () => {
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
qc.clear();
nav('/login', { replace: true });
}}
>
<Button variant="ghost" onClick={logout}>
Sign out
</Button>
</div>
<Button
variant="ghost"
className="relative md:hidden"
aria-expanded={menu}
aria-controls="me-nav"
aria-label={offers > 0 ? `Menu, ${offers} shared with you` : 'Menu'}
onClick={() => setMenu((o) => !o)}
>
<Menu className="size-5" />
{offers > 0 && (
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary" />
)}
</Button>
</header>
{/* On phones the tabs and the header links fold into the menu button. */}
<nav
id="me-nav"
aria-label="My phone"
className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800"
className={`${menu ? 'flex' : 'hidden'} flex-col gap-1 border-b border-line pb-2 md:flex md:flex-row`}
>
<NavLink to="/me" end className={tab}>
<NavLink to="/me" end className={tab} onClick={close}>
<House className={icon} />
Home
</NavLink>
<NavLink to="/me/calls" className={tab}>
<NavLink to="/me/calls" className={tab} onClick={close}>
<Phone className={icon} />
Calls
</NavLink>
<NavLink to="/me/voicemail" className={tab}>
<NavLink to="/me/voicemail" className={tab} onClick={close}>
<Voicemail className={icon} />
Voicemail
</NavLink>
<NavLink to="/me/contacts" className={tab}>
<NavLink to="/me/contacts" className={tab} onClick={close}>
<BookUser className={icon} />
Contacts
{offers > 0 && (
<span className="ml-1 rounded-full bg-sky-600 px-1.5 text-xs text-white">
<span className="rounded-full bg-primary px-1.5 text-xs text-white">
<span className="sr-only">, shared with you: </span>
{offers}
</span>
)}
</NavLink>
<NavLink to="/me/settings" className={tab}>
{user.extension && (
<NavLink to="/me/reports" className={tab} onClick={close}>
<ChartColumn className={icon} />
Reports
</NavLink>
)}
<NavLink to="/me/settings" className={tab} onClick={close}>
<Settings className={icon} />
Settings
</NavLink>
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`}>
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`} onClick={close}>
<Users className={icon} />
People
</NavLink>
<div className="mt-1 flex flex-col gap-1 border-t border-line pt-2 md:hidden">
{user.role === 'admin' && (
<Link className={row} to="/admin">
<Shield className={icon} />
Admin
</Link>
)}
<Link className={row} to="/about">
<Info className={icon} />
About
</Link>
<div className="flex items-center justify-between pl-3 text-sm font-medium">
Theme
<ThemeToggle />
</div>
<button type="button" className={`${row} text-left`} onClick={logout}>
<LogOut className={icon} />
Sign out
</button>
</div>
</nav>
{toasts.map((t) => (
<div
@@ -111,7 +178,7 @@ export function MePage({ user }: { user: AuthUser }) {
useLive.setState((s) => ({ toasts: s.toasts.filter((x) => x.id !== t.id) }))
}
>
✕
<X className="size-4" />
</Button>
</div>
))}
@@ -122,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) {
<Route path="calls" element={<MyCallsPage />} />
<Route path="voicemail" element={<VoicemailPage />} />
<Route path="contacts" element={<ContactsPage />} />
<Route path="reports" element={<ReportsPage mine />} />
<Route path="settings" element={<MeSettingsPage />} />
<Route path="people" element={<PresencePanel me={me} />} />
</Routes>
@@ -135,39 +203,50 @@ export function MePage({ user }: { user: AuthUser }) {
);
}
const seeAll = 'text-sm font-medium text-link hover:underline';
const when = (iso: string) =>
new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
/** One line in a Home card: a round icon, two lines of text, and an optional button. */
function Row({
icon,
title,
detail,
children,
}: {
icon: ReactNode;
title: string;
detail: string;
children?: ReactNode;
}) {
return (
<li className="flex items-center gap-3 py-2">
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-subtle">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{title}</span>
<span className="block text-xs text-muted">{detail}</span>
</span>
{children}
</li>
);
}
function Home({ user }: { user: AuthUser }) {
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const fresh = list.data?.filter(isNew) ?? [];
const nameOf = useContactName();
return (
<div className="flex flex-col gap-6">
<p>Hi {user.displayName}.</p>
<p className="text-2xl font-semibold">Hi {user.displayName}</p>
<SharedNotice />
{user.extension && <StatusCard ext={user.extension.number} />}
{user.extension && <MissedCalls />}
<div className="grid gap-4 md:grid-cols-2">
{user.extension && <MissedCalls />}
<NewVoicemails />
</div>
{user.extension && <ParkedOnHome />}
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
<h2 id="new-vm" className="text-lg font-semibold">
New voicemails
</h2>
{list.error && <Alert>{list.error.message}</Alert>}
{list.data && fresh.length === 0 && <p className="text-sm text-zinc-500">None.</p>}
<ul className="flex flex-col gap-1">
{fresh.slice(0, 5).map((m) => (
<li key={m.id} className="text-sm">
<span className="font-semibold">{nameOf(m.callerNumber) ?? callerOf(m)}</span>{' '}
<span className="text-zinc-500">
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
</span>
</li>
))}
</ul>
{list.data && list.data.length > 0 && (
<Link className={`${link} text-sm`} to="/me/voicemail">
Open voicemail
</Link>
)}
</section>
<p className="text-sm">
<Link className={link} to="/change-password">
Change password
@@ -177,13 +256,49 @@ function Home({ user }: { user: AuthUser }) {
);
}
function NewVoicemails() {
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const fresh = list.data?.filter(isNew) ?? [];
const nameOf = useContactName();
return (
<Panel
title="New voicemails"
action={
list.data &&
list.data.length > 0 && (
<Link className={seeAll} to="/me/voicemail">
Open voicemail
</Link>
)
}
>
{list.error && <Alert>{list.error.message}</Alert>}
{list.data && fresh.length === 0 && (
<p className="text-sm text-muted">You're all caught up.</p>
)}
{fresh.length > 0 && (
<ul className="-my-2 divide-y divide-line">
{fresh.slice(0, 5).map((m) => (
<Row
key={m.id}
icon={<Voicemail className="size-4 text-link" />}
title={nameOf(m.callerNumber) ?? callerOf(m)}
detail={`${when(m.receivedAt)} · ${mmss(m.durationS)}`}
/>
))}
</ul>
)}
</Panel>
);
}
/** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
function SharedNotice() {
const offers = useContactShares().data ?? [];
if (!offers.length) return null;
const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', ');
return (
<p role="status" className="rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950">
<p role="status" className="rounded-md bg-primary-soft p-3 text-sm">
{from} shared {offers.length} contact{offers.length === 1 ? '' : 's'} with you.{' '}
<Link className={link} to="/me/contacts">
Accept or deny
@@ -202,19 +317,20 @@ function StatusCard({ ext }: { ext: string }) {
me.data.cfna && `no answer → ${showTarget(me.data.cfna)}`,
].filter(Boolean);
return (
<section
aria-label="My status"
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
<Panel
title="My status"
action={
<Link className={seeAll} to="/me/settings">
Change
</Link>
}
>
<DndSwitch me={me.data} />
<QueueLogins ext={ext} />
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}{' '}
<Link className={link} to="/me/settings">
Change
</Link>
<p className="text-sm text-muted">
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}
</p>
</section>
</Panel>
);
}
@@ -242,7 +358,7 @@ function QueueLogins({ ext }: { ext: string }) {
onChange={(e) => toggle.mutate({ id: q.id, paused: !e.target.checked })}
/>
Taking calls from queue {q.name} ({q.number})
<span className="text-xs text-zinc-500">(also *45{q.number} from your phone)</span>
<span className="text-xs text-muted">(also *45{q.number} from your phone)</span>
</label>
);
})}
@@ -258,7 +374,7 @@ function ParkedOnHome() {
<div className="flex flex-col gap-1">
<ParkedCalls onPickUp={call} />
{note && (
<p role="status" className="text-sm text-zinc-500">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
@@ -273,44 +389,49 @@ function MissedCalls() {
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
const nameOf = useContactName();
return (
<section aria-labelledby="missed-h" className="flex flex-col gap-2">
<h2 id="missed-h" className="text-lg font-semibold">
Missed calls
</h2>
{calls.data && missed.length === 0 && <p className="text-sm text-zinc-500">None.</p>}
<Panel
title="Missed calls"
action={
calls.data &&
calls.data.items.length > 0 && (
<Link className={seeAll} to="/me/calls">
All my calls
</Link>
)
}
>
{calls.data && missed.length === 0 && <p className="text-sm text-muted">No missed calls.</p>}
{note && (
<p role="status" className="text-sm text-zinc-500">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
<ul className="flex flex-col gap-1 text-sm">
{missed.map((c) => {
const o = otherParty(c);
return (
<li key={c.id}>
<span className="font-semibold">
{nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
</span>{' '}
<span className="text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>{' '}
{o.number && (
<button
type="button"
className={link}
onClick={() => call(o.number!)}
aria-label={`Call back ${fmtNumber(o.number)}`}
>
Call back
</button>
)}
</li>
);
})}
</ul>
{calls.data && calls.data.items.length > 0 && (
<Link className={`${link} text-sm`} to="/me/calls">
All my calls
</Link>
{missed.length > 0 && (
<ul className="-my-2 divide-y divide-line">
{missed.map((c) => {
const o = otherParty(c);
return (
<Row
key={c.id}
icon={<PhoneMissed className="size-4 text-red-600 dark:text-red-400" />}
title={nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
detail={when(c.startedAt)}
>
{o.number && (
<Button
variant="ghost"
className="text-link"
onClick={() => call(o.number!)}
aria-label={`Call back ${fmtNumber(o.number)}`}
>
<Phone className="size-4" />
</Button>
)}
</Row>
);
})}
</ul>
)}
</section>
</Panel>
);
}
+13 -18
View File
@@ -37,7 +37,7 @@ export function DndSwitch({ me }: { me: MeDto }) {
onChange={(e) => save.mutate({ dnd: e.target.checked })}
/>
Do not disturb
<span className="text-xs text-zinc-500">(also *78 on, *79 off from your phone)</span>
<span className="text-xs text-muted">(also *78 on, *79 off from your phone)</span>
</label>
);
}
@@ -45,7 +45,7 @@ export function DndSwitch({ me }: { me: MeDto }) {
export function MeSettingsPage() {
const me = useMe();
if (me.error) return <Alert>{me.error.message}</Alert>;
if (!me.data) return <p className="text-sm text-zinc-500">Loading…</p>;
if (!me.data) return <p className="text-sm text-muted">Loading…</p>;
// ponytail: the forms start from what was loaded; a *72 from the phone while this page is open
// shows after a reload (remounting on every change would also wipe "Saved." and errors).
return <SettingsForms me={me.data} />;
@@ -119,7 +119,7 @@ function Forwarding({ me }: { me: MeDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save forwarding
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -188,7 +188,7 @@ function Voicemail({ me }: { me: MeDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save voicemail settings
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -218,7 +218,7 @@ function CallReportForm({ me }: { me: MeDto }) {
<h2 id="report-h" className="text-lg font-semibold">
Call report
</h2>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
An email listing your calls: who, which numbers, how long, and if they were recorded. No
calls, no email. It goes out a few minutes after each period ends, in the company time zone.
</p>
@@ -285,14 +285,14 @@ function CallReportForm({ me }: { me: MeDto }) {
onChange={(e) => setForm({ ...form, callReportSummaries: e.target.checked })}
/>
Add a summary of each recorded call
<span className="text-xs text-zinc-500">(when your admin has call summaries on)</span>
<span className="text-xs text-muted">(when your admin has call summaries on)</span>
</label>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save call report
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -313,12 +313,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
const options = useMutation({ mutationFn: meApi.contrackOptions, onSuccess: done });
const where = contrack.publicUrl ? (
<a
className="text-sky-700 underline dark:text-sky-400"
href={contrack.publicUrl}
target="_blank"
rel="noreferrer"
>
<a className="text-link underline" href={contrack.publicUrl} target="_blank" rel="noreferrer">
Contrack
</a>
) : (
@@ -365,9 +360,9 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
<span>
With the summary and action items of each recorded call
{!contrack.summariesAvailable && (
<span className="text-zinc-500"> (when your admin turns on call summaries)</span>
<span className="text-muted"> (when your admin turns on call summaries)</span>
)}
<span className="block text-xs text-zinc-500">
<span className="block text-xs text-muted">
What was said goes into Contrack. If Contrack's own AI uses a cloud provider, it can
reach that provider.
</span>
@@ -376,7 +371,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
</div>
)}
{!contrack.tokenPrefix && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<p className="text-sm text-muted">
Your contacts live in your own {where} account. There, open Settings → Account → API
tokens → Create token, with Read and write (so contacts shared with you can be added) and
Expires: Never, and paste it here. Only you see your contacts unless you share them.
@@ -448,7 +443,7 @@ function MobileApp() {
<li key={d.id} className="flex flex-wrap items-center gap-3">
<span>
{d.name}{' '}
<span className="text-zinc-500">
<span className="text-muted">
({d.platform === 'ios' ? 'iPhone' : 'Android'}, paired{' '}
{new Date(d.createdAt).toLocaleDateString()}
{d.lastUsedAt && `, last used ${new Date(d.lastUsedAt).toLocaleString()}`})
@@ -485,7 +480,7 @@ function MobileApp() {
Or type it in the app: server <span className="font-mono">{pair.data.server}</span>,
code <span className="font-mono">{pair.data.code}</span>
</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Works once, until {new Date(pair.data.expiresAt).toLocaleTimeString()}. Whoever uses it
gets into your account: don't share it.
</p>
+72 -10
View File
@@ -1,15 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My calls (05 §4): my call history; click the other number to call back (the softphone when it's
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts".
import { useInfiniteQuery } from '@tanstack/react-query';
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts", "Report
// spam".
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import type { MyCallDto } from '@selfphoneng/shared';
import { meApi, recordingsApi } from '../../api/client.ts';
import { Alert, Button } from '../../components/ui.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { softphone } from '../../softphone/SoftphoneEngine.ts';
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
import { AddToContacts, useContactName } from './ContactsPage.tsx';
import { SpamNote } from '../../components/SpamNote.tsx';
import { SpamNote, spamLine } from '../../components/SpamNote.tsx';
/** The other side of the call, from where I sit. */
export const otherParty = (c: MyCallDto) =>
@@ -30,6 +31,64 @@ export function useCallBack() {
return { call, note };
}
/** To the admins' Blocked numbers: the spam check's line when it flagged the call, else my words. */
function ReportSpam({ c }: { c: MyCallDto }) {
const flagged = c.spam && c.spam.risk !== 'low' ? spamLine(c.spam) : null;
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const send = useMutation({
mutationFn: (r: string) => meApi.reportSpam({ callId: c.id, reason: r }),
});
if (send.isSuccess)
return (
<span role="status" className="text-muted">
Reported to your admin
</span>
);
return (
<>
{open ? (
<form
aria-label={`Report ${fmtNumber(c.fromNumber)} as spam`}
className="flex w-full flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
send.mutate(reason);
}}
>
<Field label="Why is it spam?">
{(p) => (
<input
{...p}
className={inputClass}
maxLength={200}
placeholder="Robocall about my car warranty"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
)}
</Field>
<Button type="submit" variant="primary" disabled={!reason.trim() || send.isPending}>
Report
</Button>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
</form>
) : (
<Button
variant="ghost"
disabled={send.isPending}
onClick={() => (flagged ? send.mutate(flagged) : setOpen(true))}
>
Report spam
</Button>
)}
{send.error && <Alert>{send.error.message}</Alert>}
</>
);
}
export function MyCallsPage() {
const list = useInfiniteQuery({
queryKey: ['me', 'calls'],
@@ -47,12 +106,12 @@ export function MyCallsPage() {
</h2>
{list.isError && <Alert>{list.error.message}</Alert>}
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
{list.data && calls.length === 0 && <p className="text-sm text-zinc-500">No calls yet.</p>}
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{list.data && calls.length === 0 && <p className="text-sm text-muted">No calls yet.</p>}
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{calls.map((c) => {
const other = otherParty(c);
const known = nameOf(other.number);
@@ -61,7 +120,7 @@ export function MyCallsPage() {
key={c.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="w-36 text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-36 text-muted">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-10">{c.myRole === 'out' ? 'Out' : 'In'}</span>
<span
className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`}
@@ -70,7 +129,7 @@ export function MyCallsPage() {
{other.number ? (
<button
type="button"
className="text-sky-700 underline dark:text-sky-400"
className="text-link underline"
aria-label={`Call back ${fmtNumber(other.number)}`}
onClick={() => call(other.number!)}
>
@@ -80,7 +139,7 @@ export function MyCallsPage() {
'Unknown'
)}
{c.direction !== 'internal' && (
<span className="text-zinc-500"> · {DIRECTION_TEXT[c.direction]}</span>
<span className="text-muted"> · {DIRECTION_TEXT[c.direction]}</span>
)}
{!known && other.number && c.direction !== 'internal' && (
<AddToContacts number={other.number} name={other.name} />
@@ -98,6 +157,9 @@ export function MyCallsPage() {
)}
<StatusBadge status={c.status} />
<span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span>
{c.myRole === 'in' && c.direction === 'inbound' && other.number && !known && (
<ReportSpam c={c} />
)}
</li>
);
})}
+11 -11
View File
@@ -68,32 +68,32 @@ function Messages() {
onChange={(e) => setQ(e.target.value)}
/>
)}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No messages.</p>}
{list.data?.length === 0 && <p className="text-sm text-muted">No messages.</p>}
{!!list.data?.length && shown?.length === 0 && (
<p className="text-sm text-zinc-500">No message mentions that.</p>
<p className="text-sm text-muted">No message mentions that.</p>
)}
<ul className="flex flex-col gap-2">
{shown?.map((m) => (
<li
key={m.id}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 sm:flex-row sm:items-center dark:border-zinc-800"
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3 sm:flex-row sm:items-center"
>
<div className="min-w-0 flex-1">
<p className={isNew(m) ? 'font-semibold' : ''}>
{who(m)}
{isNew(m) && (
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
<span className="ml-2 rounded bg-primary-soft px-1.5 py-0.5 text-xs text-primary-ink">
New
</span>
)}
</p>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
{m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''}
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
</p>
{m.transcript !== null && (
<p className="mt-1 text-sm" title="Automatic transcript, may have mistakes">
{m.transcript || <span className="text-zinc-500">(no words)</span>}
{m.transcript || <span className="text-muted">(no words)</span>}
</p>
)}
</div>
@@ -108,7 +108,7 @@ function Messages() {
<div className="flex gap-2">
<a
href={meApi.audioUrl(m.id, true)}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-subtle"
>
Download
</a>
@@ -217,7 +217,7 @@ function Greeting(props: {
<div
role="group"
aria-labelledby={id}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3"
>
<p id={id} className="font-medium">
{label}
@@ -231,7 +231,7 @@ function Greeting(props: {
className="w-full sm:w-80"
/>
) : (
<p className="text-sm text-zinc-500">Not recorded. {props.fallback}</p>
<p className="text-sm text-muted">Not recorded. {props.fallback}</p>
)}
<div className="flex flex-wrap gap-2">
{recording ? (
@@ -265,8 +265,8 @@ function Greeting(props: {
)}
</div>
{recording && <p className="text-sm text-red-600 dark:text-red-400">Recording…</p>}
{upload.isPending && <p className="text-sm text-zinc-500">Saving…</p>}
{upload.isSuccess && !upload.isPending && <p className="text-sm text-zinc-500">Saved.</p>}
{upload.isPending && <p className="text-sm text-muted">Saving…</p>}
{upload.isSuccess && !upload.isPending && <p className="text-sm text-muted">Saved.</p>}
{(micError ?? upload.error?.message ?? remove.error?.message) && (
<Alert>{micError ?? upload.error?.message ?? remove.error?.message}</Alert>
)}
+3 -3
View File
@@ -54,7 +54,7 @@ export function PresencePanel({ me }: { me: string | null }) {
onChange={(e) => setQ(e.target.value)}
/>
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
@@ -63,7 +63,7 @@ export function PresencePanel({ me }: { me: string | null }) {
<li
key={e.number}
data-number={e.number}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900"
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-subtle"
onDragOver={(ev) => {
if (active && ev.dataTransfer.types.includes(CALL_DRAG)) ev.preventDefault();
}}
@@ -75,7 +75,7 @@ export function PresencePanel({ me }: { me: string | null }) {
<StatusDot ext={e} />
<span className="min-w-0 flex-1 truncate text-sm">
<span className="font-medium">{e.name}</span>{' '}
<span className="text-zinc-500">
<span className="text-muted">
{e.number} · {statusLabel(e)}
</span>
</span>
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more