14 Commits
Author SHA1 Message Date
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
iamdoubz 79bb1fa78b release: version 0.0.10
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 5s
release / images (release) Successful in 2m53s
2026-10-27 18:37:53 -05:00
iamdoubz 11b531d5b4 Merge pull request 'Feat/spam' (#5) from feat/spam into main
ci / check (push) Successful in 1m6s
ci / images (push) Successful in 6s
Reviewed-on: #5
2026-10-27 18:35:05 -05:00
iamdoubz 70083f8f83 api: share a spam check from its start
ci / check (push) Successful in 1m11s
ci / images (push) Successful in 21s
ci / check (pull_request) Successful in 1m9s
ci / images (pull_request) Successful in 33s
SD5 follow-up (14 §4): checks for the same number were shared only once
they reached the IPQualityScore request, after reading the cache and the
pause state. A check that finished in between cost a second request
(CI: "five at once cost one request" failed). Now shared before anything
is awaited.
2026-10-27 18:32:04 -05:00
iamdoubz 6b0c5aa827 web: spam check in call history
ci / check (push) Failing after 1m9s
ci / images (push) Successful in 44s
SD5 (14 §9.5): each incoming call keeps what the spam check knew when it
ended (saved answers and the FTC list only, no new lookup), so the badge
stays in call history after the saved answer expires. Hidden when the
viewer's contacts name the number. New admin guide page, spam-check.md.

Follow-up: the owner's checks with real keys and a real call (14 §9).
2026-10-27 18:16:24 -05:00
iamdoubz e05065599e web: show the spam check while a call rings
SD4 (14 §5): GET /api/me/caller-check for callers ringing my extensions
now, a badge and line in the softphone's ringing dialog and call card,
and the desk phone label in the caller-name lookup when the answer comes
within 1.2 s. Contacts are never checked; low risk shows nothing.

Follow-up: try it on a real call, in the browser and on a desk phone.
2026-10-27 16:51:29 -05:00
iamdoubz 49d529e185 api: download the FTC complaint list
SD3 (14 §2.2): spamFtcSync reads the FTC's Do Not Call complaints a day
at a time, 15 pages a minute, saving its place; failures are kept for
Settings → Spam check and retried the next minute. Download now starts
one. Old complaints are dropped after windowDays.

Follow-up: one real download with the owner's api.data.gov key.
2026-10-27 16:40:14 -05:00
iamdoubz 70f14288ca api: pause spam check when IPQualityScore credits run out
SD2 (14 §3): out of credits pauses IPQualityScore (one warning, one
event), the FTC list answers meanwhile, the dashboard shows a dismissable
spam_quota warning, and spamQuotaCheck resumes it every 6 hours once
credits are back. A newly saved key resumes it at once.

Follow-up: confirm with the owner's real key that the Credit Usage API
costs no credit (TODO in 14 §2.1).
2026-10-27 16:17:42 -05:00
iamdoubz 6f87d6f663 api: spam check settings, lookups and cache
SD1 (14 §9): the spam/lookups tables and calls columns, the
IPQualityScore and FTC lookups with the cache, the spam settings with
write-only keys, the provider Test routes and Settings → Spam check.
2026-10-27 15:32:31 -05:00
iamdoubz a3dfcb6b14 docs: spam check spec
SD1 (spec 14, decision 0006): IPQualityScore first, then the FTC Do Not
Call complaint list, for outside US callers who aren't in anyone's
contacts. Information only, off by default; credits running out pause
IPQualityScore until its credit usage API says they're back.
2026-10-27 13:51:08 -05:00
86 changed files with 8369 additions and 685 deletions

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ write the software around it: database, config generator, API, web UI.
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names | | `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles | | `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
| `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API | | `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API |
| `docs/spec/14-spam.md` | Spam check: IPQualityScore and the FTC complaint list for unknown callers | Working on spam check or caller information |
## Hard rules ## Hard rules
+41
View File
@@ -5,6 +5,47 @@ 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 the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
Docker. Docker.
## 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,
by itself. Portainer: change both tags to `0.0.10` and redeploy. Compose:
`sudo scripts/update.sh v0.0.10`.
- **Spam check** (spec 14, off until you turn it on in Settings → Spam check): when an outside
US caller isn't in the company's or your contacts, the number is looked up and what's known
shows while it rings. The browser phone shows a **Likely spam** or **Possible spam** badge with
a line such as "Fraud score 92 · spammer · VOIP"; desk phones show the caller's name as
**SPAM LIKELY** or **Spam risk** (you can change these) when the answer is quick; call
history keeps the badge. It's information only: calls are never blocked or delayed, and a
failed lookup just shows nothing. Two sources, either or both:
- **IPQualityScore** (1,000 lookups a month free): sends callers' numbers to IPQualityScore,
so turning it on asks you to confirm. Answers are kept 14 days for flagged numbers and 3 for
the rest. When the lookups run out the dashboard says so, checks fall back to the FTC list,
and it carries on by itself when lookups are back.
- **FTC complaint list** (free api.data.gov key): downloaded to the server, so nothing leaves
it. The first download takes about 7 hours; then it fetches what's new every 24 hours.
- See the [admin guide](docs/admin-guide/spam-check.md), including privacy and GDPR.
- Database changes: `spam_lookups`, `ftc_complaints` (the api backs up first).
## v0.0.9 — 2026-10-27 ## v0.0.9 — 2026-10-27
**Before you update:** this version changes the database (paired phones); the api backs up first, **Before you update:** this version changes the database (paired phones); the api backs up first,
+4 -1
View File
@@ -7,7 +7,7 @@
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers 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. (reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
**Status:** pre-1.0 (v0.0.9), for testing. See [what's left for v1](docs/v1-checklist.md). **Status:** pre-1.0 (v0.0.11), for testing. See [what's left for v1](docs/v1-checklist.md).
## What it does today ## What it does today
@@ -19,6 +19,9 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
pickup, conference rooms, paging and intercom, music on hold. pickup, conference rooms, paging and intercom, music on hold.
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen, - **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
whisper, barge) and reports. whisper, barge) and reports.
- **Spam check** (off until you turn it on): outside callers who aren't contacts are looked up
with IPQualityScore or the FTC complaint list, and flagged in the browser phone, on desk phones
and in call history. Calls are never blocked.
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every - **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
database change), backups and restore, background health checks, webhooks and API tokens, database change), backups and restore, background health checks, webhooks and API tokens,
two-step sign-in for admins, limits on outside calls at once. two-step sign-in for admins, limits on outside calls at once.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@selfphoneng/api", "name": "@selfphoneng/api",
"version": "0.0.9", "version": "0.0.11",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
@@ -0,0 +1,25 @@
CREATE TABLE "ftc_complaints" (
"id" text PRIMARY KEY NOT NULL,
"number" text NOT NULL,
"reported_at" timestamp with time zone NOT NULL,
"subject" text,
"robocall" boolean,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "spam_lookups" (
"number" text PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"risk" text NOT NULL,
"info" jsonb NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_lookups_risk_check" CHECK ("spam_lookups"."risk" in ('low', 'medium', 'high'))
);
--> statement-breakpoint
ALTER TABLE "calls" ADD COLUMN "spam_risk" text;--> statement-breakpoint
ALTER TABLE "calls" ADD COLUMN "spam_info" jsonb;--> statement-breakpoint
CREATE INDEX "ftc_complaints_number_index" ON "ftc_complaints" USING btree ("number");--> statement-breakpoint
CREATE INDEX "ftc_complaints_reported_at_index" ON "ftc_complaints" USING btree ("reported_at");--> statement-breakpoint
CREATE INDEX "spam_lookups_expires_at_index" ON "spam_lookups" USING btree ("expires_at");
File diff suppressed because it is too large. Load diff
@@ -99,6 +99,13 @@
"when": 1793052019332, "when": 1793052019332,
"tag": "0013_mobile_devices", "tag": "0013_mobile_devices",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1793127210386,
"tag": "0014_spam_check",
"breakpoints": true
} }
] ]
} }
+35
View File
@@ -630,11 +630,46 @@ export const calls = pgTable(
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }), answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
talkSeconds: integer().notNull().default(0), talkSeconds: integer().notNull().default(0),
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }), recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
/** The spam check of an incoming call's number (14 §6); null = not checked. */
spamRisk: text(),
spamInfo: jsonb(),
...timestamps, ...timestamps,
}, },
(t) => [index().on(t.startedAt.desc())], (t) => [index().on(t.startedAt.desc())],
); );
// ───────────── spam check (14 §7) ─────────────
/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */
export const spamLookups = pgTable(
'spam_lookups',
{
number: text().primaryKey(),
provider: text().notNull(),
risk: text().notNull(),
info: jsonb().notNull(),
expiresAt: tstz().notNull(),
...timestamps,
},
(t) => [
index().on(t.expiresAt),
check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`),
],
);
/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */
export const ftcComplaints = pgTable(
'ftc_complaints',
{
id: text().primaryKey(),
number: text().notNull(),
reportedAt: tstz().notNull(),
subject: text(),
robocall: boolean(),
createdAt: createdAt(),
},
(t) => [index().on(t.number), index().on(t.reportedAt)],
);
export const callExtensions = pgTable( export const callExtensions = pgTable(
'call_extensions', 'call_extensions',
{ {
+19 -2
View File
@@ -10,6 +10,7 @@ import type { Db } from './db/client.ts';
import { import {
extensions, extensions,
sessions, sessions,
spamLookups,
systemEvents, systemEvents,
trunks, trunks,
users, users,
@@ -30,6 +31,8 @@ import { logCallsToContrack } from './contacts/timeline.ts';
import { checkDirectory } from './sso/directory.ts'; import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts'; import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts'; import { logEvent } from './services/events.ts';
import { spamQuotaCheck } from './spam/check.ts';
import { ftcSync } from './spam/ftc.ts';
import { deliverDue } from './services/webhooks.ts'; import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts'; import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts'; import { getSetting, setSetting } from './services/settings.ts';
@@ -42,7 +45,11 @@ export type JobDeps = {
generator: Pick<Generator, 'apply'>; generator: Pick<Generator, 'apply'>;
voicemail: { scan(ext: string): Promise<void> }; voicemail: { scan(ext: string): Promise<void> };
send: SendMail; send: SendMail;
log: { info(o: object, m: string): void; error(o: object, m: string): void }; log: {
info(o: object, m: string): void;
warn(o: object, m: string): void;
error(o: object, m: string): void;
};
now?: () => Date; now?: () => Date;
statfs?: typeof statfs; statfs?: typeof statfs;
}; };
@@ -70,7 +77,8 @@ export async function mailAdmins(d: JobDeps, subject: string, text: string) {
await d.send({ to, subject, text }).catch((err: unknown) => d.log.error({ err }, subject)); await d.send({ to, subject, text }).catch((err: unknown) => d.log.error({ err }, subject));
} }
/** Recordings, voicemail, expired sessions and old webhook deliveries (04 §8 retentionCleanup). */ /** Recordings, voicemail, expired sessions, old webhook deliveries and spam check answers
* (04 §8 retentionCleanup, 14 §4). */
export async function retentionCleanup(d: JobDeps) { export async function retentionCleanup(d: JobDeps) {
const recordings = await cleanupRecordings( const recordings = await cleanupRecordings(
d.db, d.db,
@@ -113,6 +121,7 @@ export async function retentionCleanup(d: JobDeps) {
await d.db await d.db
.delete(systemEvents) .delete(systemEvents)
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`)); .where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
await d.db.delete(spamLookups).where(lt(spamLookups.expiresAt, sql`now()`));
await cleanupContactShares(d.db); await cleanupContactShares(d.db);
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted'); if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
} }
@@ -274,6 +283,12 @@ export function createJobs(d: JobDeps) {
}; };
return { return {
spamQuotaCheck: () => spamQuotaCheck(d.db),
/** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */
spamFtcSync: async () => {
const reason = await ftcSync(d.db, { now: now() });
if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed');
},
oidcCheck, oidcCheck,
directoryCheck, directoryCheck,
daily, daily,
@@ -311,6 +326,8 @@ export function startJobs(d: JobDeps): () => void {
run('contrackLog', j.contrackLog); run('contrackLog', j.contrackLog);
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck); if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck); if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
run('spamFtcSync', j.spamFtcSync);
run('callTranscripts', async () => { run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log }); await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
}); });
+18 -5
View File
@@ -10,10 +10,12 @@ import {
type CallDto, type CallDto,
type ListResponse, type ListResponse,
type MyCallDto, type MyCallDto,
type SpamCheck,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts'; import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts'; import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
import { contactNames } from '../contacts/book.ts';
import { badRequest, notFound } from '../errors.ts'; import { badRequest, notFound } from '../errors.ts';
import { getSetting } from '../services/settings.ts'; import { getSetting } from '../services/settings.ts';
@@ -65,7 +67,8 @@ async function conditions(db: Db, f: Filter): Promise<SQL[]> {
return where; return where;
} }
async function page(db: Db, where: SQL[], limit: number) { /** `viewer`: whose books hide the spam check for a number they name (14 §6). */
async function page(db: Db, where: SQL[], limit: number, viewer: string) {
const rows = await db const rows = await db
.select({ c: calls, did: dids, a: answerer }) .select({ c: calls, did: dids, a: answerer })
.from(calls) .from(calls)
@@ -76,6 +79,12 @@ async function page(db: Db, where: SQL[], limit: number) {
.limit(limit + 1); .limit(limit + 1);
const more = rows.length > limit; const more = rows.length > limit;
const items = rows.slice(0, limit); const items = rows.slice(0, limit);
const known = await contactNames(
db,
viewer,
items.filter(({ c }) => c.spamRisk).map(({ c }) => c.fromNumber),
{ wait: false },
).catch(() => null);
return { return {
items: items.map(({ c, did, a }): CallDto => ({ items: items.map(({ c, did, a }): CallDto => ({
id: c.id, id: c.id,
@@ -91,6 +100,10 @@ async function page(db: Db, where: SQL[], limit: number) {
answeredBy: a ? { id: a.id, number: a.number, name: a.name } : null, answeredBy: a ? { id: a.id, number: a.number, name: a.name } : null,
talkSeconds: c.talkSeconds, talkSeconds: c.talkSeconds,
recordingId: c.recordingId, recordingId: c.recordingId,
spam:
c.spamRisk && known && !known.has(c.fromNumber ?? '')
? ({ risk: c.spamRisk, ...(c.spamInfo as object) } as SpamCheck)
: null,
})), })),
nextCursor: more ? encodeCursor(items.at(-1)!.c) : null, nextCursor: more ? encodeCursor(items.at(-1)!.c) : null,
}; };
@@ -111,7 +124,7 @@ export async function callRoutes(app: FastifyInstance) {
{ preHandler: requireAdmin }, { preHandler: requireAdmin },
async (req): Promise<ListResponse<CallDto>> => { async (req): Promise<ListResponse<CallDto>> => {
const f = CallFilterSchema.parse(req.query); const f = CallFilterSchema.parse(req.query);
return page(db, await conditions(db, f), f.limit); return page(db, await conditions(db, f), f.limit, req.user!.id);
}, },
); );
@@ -123,7 +136,7 @@ export async function callRoutes(app: FastifyInstance) {
'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds', 'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds',
]; ];
// ponytail: one query capped at 50,000 rows; stream with a cursor if exports get bigger. // ponytail: one query capped at 50,000 rows; stream with a cursor if exports get bigger.
const { items } = await page(db, where, 50_000); const { items } = await page(db, where, 50_000, req.user!.id);
const fmt = new Intl.DateTimeFormat('sv-SE', { const fmt = new Intl.DateTimeFormat('sv-SE', {
timeZone: tz, timeZone: tz,
dateStyle: 'short', dateStyle: 'short',
@@ -156,7 +169,7 @@ export async function callRoutes(app: FastifyInstance) {
app.get('/api/calls/:id', { preHandler: requireAdmin }, async (req): Promise<CallDetailDto> => { app.get('/api/calls/:id', { preHandler: requireAdmin }, async (req): Promise<CallDetailDto> => {
const { id } = z.object({ id: z.uuid() }).parse(req.params); const { id } = z.object({ id: z.uuid() }).parse(req.params);
const { items } = await page(db, [eq(calls.id, id)], 1); const { items } = await page(db, [eq(calls.id, id)], 1, req.user!.id);
const [c] = await db.select({ linkedid: calls.linkedid }).from(calls).where(eq(calls.id, id)); const [c] = await db.select({ linkedid: calls.linkedid }).from(calls).where(eq(calls.id, id));
if (!items[0] || !c) throw notFound('Call not found'); if (!items[0] || !c) throw notFound('Call not found');
const legs = await db const legs = await db
@@ -189,7 +202,7 @@ export async function callRoutes(app: FastifyInstance) {
.where(eq(extensions.userId, req.user!.id)); .where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin."); if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
const where = await conditions(db, { ...f, extensionId: ext.id }); const where = await conditions(db, { ...f, extensionId: ext.id });
const result = await page(db, where, f.limit); const result = await page(db, where, f.limit, req.user!.id);
const ids = result.items.map((c) => c.id); const ids = result.items.map((c) => c.id);
const roles = ids.length const roles = ids.length
? await db ? await db
+46 -4
View File
@@ -15,6 +15,7 @@ import {
type ContactImportResult, type ContactImportResult,
type ContactShareDto, type ContactShareDto,
type ListResponse, type ListResponse,
type SpamCheck,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts'; import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
import { import {
@@ -37,6 +38,7 @@ import { getSetting } from '../services/settings.ts';
import { decrypt } from '../crypto/secrets.ts'; import { decrypt } from '../crypto/secrets.ts';
import { contrackCheck } from '../contacts/contrack.ts'; import { contrackCheck } from '../contacts/contrack.ts';
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts'; import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
import { checkCaller, usNumber } from '../spam/check.ts';
const IdParams = z.object({ id: z.uuid() }); const IdParams = z.object({ id: z.uuid() });
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') }); const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
@@ -187,12 +189,52 @@ export async function contactRoutes(app: FastifyInstance) {
.select({ userId: extensions.userId }) .select({ userId: extensions.userId })
.from(extensions) .from(extensions)
.where(eq(extensions.number, q.data.ext)); .where(eq(extensions.number, q.data.ext));
const names = await contactNames(db, e?.userId ?? null, [q.data.number], { const owner = e?.userId ?? null;
const names = await contactNames(db, owner, [q.data.number], {
wait: false, wait: false,
}).catch(() => new Map<string, string>()); }).catch(() => new Map<string, string>());
return reply let name = names.get(q.data.number) ?? '';
.type('text/plain; charset=utf-8') // 14 §5.2: no contact → the spam check's label, if it answers in 1.2 s (of the 2 s).
.send(phoneSafeName(names.get(q.data.number) ?? '')); // app.log: this route's own log is silent (the token is in the query).
// ponytail: the label replaces the carrier's caller ID name, which the dialplan doesn't
// send; IPQualityScore's name for the line follows it when it has one.
if (!name) {
const c = await checkCaller({ db, log: app.log }, owner, usNumber(q.data.number), 1200);
if (c && c.risk !== 'low') {
const labels = (await getSetting(db, 'spam')).deskPhoneLabels;
name = `${labels[c.risk]} ${c.provider === 'ipqs' ? (c.info.name ?? '') : ''}`;
}
}
return reply.type('text/plain; charset=utf-8').send(phoneSafeName(name));
},
);
/**
* The softphone's spam check (14 §5.1): only for a caller ringing one of my extensions right
* now, so nobody can spend the credits on numbers that never called.
*/
app.get(
'/api/me/caller-check',
{ preHandler: requireAuth },
async (req): Promise<{ check: SpamCheck | null }> => {
const { number } = z.object({ number: z.string().max(30) }).parse(req.query);
const n = usNumber(number);
const mine = (
await db
.select({ number: extensions.number })
.from(extensions)
.where(eq(extensions.userId, req.user!.id))
).map((x) => x.number);
const ringing = app.live.calls
.list()
.some(
(c) =>
c.direction === 'inbound' &&
usNumber(c.from.number) === n &&
c.extensions.some((x) => mine.includes(x)),
);
if (!n || !ringing) return { check: null };
return { check: await checkCaller({ db, log: req.log }, req.user!.id, n, 5000) };
}, },
); );
+93 -1
View File
@@ -11,6 +11,7 @@ import {
OidcInputSchema, OidcInputSchema,
SettingsSchemas, SettingsSchemas,
SmtpInputSchema, SmtpInputSchema,
SpamInputSchema,
type AiTestResult, type AiTestResult,
type AuthMode, type AuthMode,
type Destination, type Destination,
@@ -19,13 +20,16 @@ import {
type OidcTestResult, type OidcTestResult,
type SettingKey, type SettingKey,
type SettingsDto, type SettingsDto,
type SpamInput,
type SpamSettings,
type SpamTestResult,
type Warning, type Warning,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts'; import { dids, extensions, trunks } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts'; import { HttpError, badRequest } from '../errors.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts'; import { SLOW, requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts'; import { decrypt, encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts'; import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts'; import { assertDestinationExists } from '../services/destinations.ts';
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts'; import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
@@ -39,6 +43,10 @@ import { forgetBook } from '../contacts/contrack.ts';
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts'; import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
import { directoryUserFor } from '../sso/directory.ts'; import { directoryUserFor } from '../sso/directory.ts';
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts'; import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
import { ftcTest } from '../spam/ftc.ts';
import { SpamError } from '../spam/http.ts';
import { ipqsCredits } from '../spam/ipqs.ts';
import { patchSpamState, resumeIpqs } from '../spam/check.ts';
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -52,6 +60,7 @@ const READ_ONLY = new Set<string>([
'system.restartRequired', 'system.restartRequired',
'system.transportHash', 'system.transportHash',
'system.directoryWarning', 'system.directoryWarning',
'spam.state',
'sms.webhookToken', 'sms.webhookToken',
'setup.completed', 'setup.completed',
]); ]);
@@ -62,10 +71,20 @@ async function toDto(db: Db): Promise<SettingsDto> {
'contacts.contrack': ctk, 'contacts.contrack': ctk,
'auth.oidc': oidc, 'auth.oidc': oidc,
'auth.ldap': ldap, 'auth.ldap': ldap,
spam,
...rest ...rest
} = await getAllSettings(db); } = await getAllSettings(db);
return { return {
...rest, ...rest,
spam: {
...spam,
ipqs: spam.ipqs && { hasKey: true },
ftc: spam.ftc && {
hasKey: true,
syncHours: spam.ftc.syncHours,
windowDays: spam.ftc.windowDays,
},
},
'auth.ldap': ldap && { 'auth.ldap': ldap && {
enabled: ldap.enabled, enabled: ldap.enabled,
preset: ldap.preset, preset: ldap.preset,
@@ -209,6 +228,28 @@ export async function settingsRoutes(app: FastifyInstance) {
trustEmail: input.trustEmail, trustEmail: input.trustEmail,
}; };
} }
/** The stored form of `spam` (14 §7): keys are write-only (empty keeps the stored one). */
async function spamFromInput(input: SpamInput): Promise<SpamSettings> {
const v = SpamInputSchema.parse(input);
const old = await getSetting(db, 'spam');
const keyEnc = (typed: string | undefined, stored: string | undefined, name: string) => {
if (typed) return encrypt(typed);
if (stored) return stored;
throw badRequest(`Enter the ${name} key`);
};
const ipqs = v.ipqs && {
keyEnc: keyEnc(v.ipqs.key, old.ipqs?.keyEnc, 'IPQualityScore'),
};
const ftc = v.ftc && {
keyEnc: keyEnc(v.ftc.key, old.ftc?.keyEnc, 'api.data.gov'),
syncHours: v.ftc.syncHours,
windowDays: v.ftc.windowDays,
};
if (v.enabled && !ipqs && !ftc)
throw badRequest('Set up IPQualityScore or the FTC list to turn spam check on');
return { ...v, ipqs, ftc };
}
app.addHook('preHandler', requireAdmin); app.addHook('preHandler', requireAdmin);
app.get('/api/settings', () => toDto(db)); app.get('/api/settings', () => toDto(db));
@@ -258,6 +299,8 @@ export async function settingsRoutes(app: FastifyInstance) {
forgetBook(null); forgetBook(null);
} else if (key === 'auth.ldap') { } else if (key === 'auth.ldap') {
values[key] = await ldapFromInput(raw); values[key] = await ldapFromInput(raw);
} else if (key === 'spam') {
values[key] = await spamFromInput(raw as SpamInput);
} else if (key === 'auth.oidc') { } else if (key === 'auth.oidc') {
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw)); values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw); } else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
@@ -308,6 +351,8 @@ export async function settingsRoutes(app: FastifyInstance) {
} }
for (const [key, value] of Object.entries(values)) for (const [key, value] of Object.entries(values))
await setSetting(db, key as SettingKey, value as never); await setSetting(db, key as SettingKey, value as never);
// 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( const changed = Object.keys(values).filter(
(k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]), (k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]),
); );
@@ -403,6 +448,32 @@ export async function settingsRoutes(app: FastifyInstance) {
}, },
); );
/**
* Settings → Spam check → Test (14 §7.1), with the typed key or else the saved one. IPQualityScore
* answers with its credits left, which spends none.
*/
for (const provider of ['ipqs', 'ftc'] as const)
app.post(
`/api/settings/spam/${provider}/test`,
{ config: SLOW },
async (req): Promise<SpamTestResult> => {
const { key } = z
.object({ key: z.string().trim().max(200).optional() })
.parse(req.body ?? {});
const keyEnc = (await getSetting(db, 'spam'))[provider]?.keyEnc;
const k = key || (keyEnc && decrypt(keyEnc));
if (!k) return { ok: false, error: 'Enter the key first' };
try {
if (provider === 'ftc') return { ok: true, detail: await ftcTest(k) };
const credits = await ipqsCredits(k);
return { ok: true, detail: `Works: ${credits} credits left this billing period.` };
} catch (err) {
if (err instanceof SpamError) return { ok: false, error: err.message };
throw err;
}
},
);
/** The server's public IPv4, as an outside service sees it (only sent when asked). */ /** 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 () => { app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp(); const ip = await detectPublicIp();
@@ -488,6 +559,14 @@ export async function settingsRoutes(app: FastifyInstance) {
message: s['system.directoryWarning'], message: s['system.directoryWarning'],
link: '/admin/settings/sign-in', link: '/admin/settings/sign-in',
}); });
const st = s['spam.state'];
if (s.spam.enabled && s.spam.ipqs && st.ipqsPausedAt && !st.warningDismissedAt)
items.push({
code: 'spam_quota',
message: `IPQualityScore's lookups are used up. Spam checks ${s.spam.ftc ? 'use the FTC list' : 'are off'} until they come back.`,
link: '/admin/settings/spam',
dismissable: true,
});
if (!s.smtp) if (!s.smtp)
items.push({ items.push({
code: 'smtp_missing', code: 'smtp_missing',
@@ -496,4 +575,17 @@ export async function settingsRoutes(app: FastifyInstance) {
}); });
return { items }; return { items };
}); });
/** Settings → Spam check → Download now (14 §2.2): due at the next minute, unless under way. */
app.post('/api/settings/spam/ftc/sync', async () => {
if (!(await getSetting(db, 'spam')).ftc) throw badRequest('Set up the FTC list first');
await patchSpamState(db, { ftcLastSyncAt: null });
return { ok: true };
});
/** Hidden until the next time the credits run out (14 §3). */
app.post('/api/system/warnings/spam_quota/dismiss', async () => {
await patchSpamState(db, { warningDismissedAt: new Date().toISOString() });
return { ok: true };
});
} }
+14 -4
View File
@@ -9,6 +9,7 @@ import { join } from 'node:path';
import { and, asc, eq, inArray, isNull, lt } from 'drizzle-orm'; import { and, asc, eq, inArray, isNull, lt } from 'drizzle-orm';
import { field, type AmiMessage } from '../ami/events.ts'; import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { checkNumber, usNumber, type SpamDeps } from '../spam/check.ts';
import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/schema.ts'; import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/schema.ts';
import { durationMs } from '../media/audio.ts'; import { durationMs } from '../media/audio.ts';
@@ -160,7 +161,7 @@ async function linkRecording(db: Db | Tx, dir: string, s: CallSummary, linkedid:
export async function aggregateCall( export async function aggregateCall(
db: Db, db: Db,
linkedid: string, linkedid: string,
opts: { recordingsDir?: string } = {}, opts: { recordingsDir?: string; log?: SpamDeps['log'] } = {},
): Promise<void> { ): Promise<void> {
const rows = await db const rows = await db
.select() .select()
@@ -180,6 +181,13 @@ export async function aggregateCall(
const [d] = s.did const [d] = s.did
? await db.select({ id: dids.id }).from(dids).where(eq(dids.number, s.did)) ? await db.select({ id: dids.id }).from(dids).where(eq(dids.number, s.did))
: []; : [];
// 14 §6: what the spam check knows now (the cache, the local FTC copy), kept with the call.
const spam =
s.direction === 'inbound' && s.fromNumber
? await checkNumber({ db, log: opts.log ?? { warn() {} } }, usNumber(s.fromNumber), {
cachedOnly: true,
})
: null;
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.delete(calls).where(eq(calls.linkedid, linkedid)); await tx.delete(calls).where(eq(calls.linkedid, linkedid));
const [call] = await tx const [call] = await tx
@@ -197,6 +205,8 @@ export async function aggregateCall(
didId: d?.id ?? null, didId: d?.id ?? null,
answeredByExtensionId: idOf(s.answeredBy), answeredByExtensionId: idOf(s.answeredBy),
talkSeconds: s.talkSeconds, talkSeconds: s.talkSeconds,
spamRisk: spam?.risk ?? null,
spamInfo: spam && { provider: spam.provider, info: spam.info },
recordingId: opts.recordingsDir recordingId: opts.recordingsDir
? await linkRecording(tx, opts.recordingsDir, s, linkedid) ? await linkRecording(tx, opts.recordingsDir, s, linkedid)
: null, : null,
@@ -218,7 +228,7 @@ export async function aggregateCall(
export type CdrDeps = { export type CdrDeps = {
db: Db; db: Db;
log: { error(o: object, msg: string): void }; log: { error(o: object, msg: string): void; warn(o: object, msg: string): void };
/** How long after a call's last Cdr event to aggregate it (04 §5.4: 5 s). */ /** How long after a call's last Cdr event to aggregate it (04 §5.4: 5 s). */
settleMs?: number; settleMs?: number;
/** Where MixMonitor writes (RECORDINGS_DIR); recordings are linked to their calls. */ /** Where MixMonitor writes (RECORDINGS_DIR); recordings are linked to their calls. */
@@ -233,7 +243,7 @@ export function watchCdr(d: CdrDeps, ami: Pick<EventEmitter, 'on'>) {
linkedid, linkedid,
setTimeout(() => { setTimeout(() => {
timers.delete(linkedid); timers.delete(linkedid);
void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir }).catch( void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir, log: d.log }).catch(
(err: unknown) => d.log.error({ err, linkedid }, 'call aggregation failed'), (err: unknown) => d.log.error({ err, linkedid }, 'call aggregation failed'),
); );
}, d.settleMs ?? 5000), }, d.settleMs ?? 5000),
@@ -257,7 +267,7 @@ export function watchCdr(d: CdrDeps, ami: Pick<EventEmitter, 'on'>) {
.leftJoin(calls, eq(calls.linkedid, cdr.linkedid)) .leftJoin(calls, eq(calls.linkedid, cdr.linkedid))
.where(and(isNull(calls.id), lt(cdr.createdAt, new Date(Date.now() - 60_000)))); .where(and(isNull(calls.id), lt(cdr.createdAt, new Date(Date.now() - 60_000))));
for (const o of orphans) for (const o of orphans)
await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir }); await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir, log: d.log });
}, },
stop: () => timers.forEach(clearTimeout), stop: () => timers.forEach(clearTimeout),
}; };
+209
View File
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The spam check (14 §5): the one way to ask what's known about a caller's number. Information
// only: nothing here changes a call, and a failure only means there's no answer (and one warning
// in the log). The contacts test (14 §1) is the caller's: it knows whose books to look in.
import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm';
import {
SpamStateSchema,
normalizeNumber,
type IpqsInfo,
type SpamCheck,
type SpamRisk,
type SpamSettings,
type SpamState,
} from '@selfphoneng/shared';
import { contactNames } from '../contacts/book.ts';
import type { Db } from '../db/client.ts';
import { settings, spamLookups } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { logEvent } from '../services/events.ts';
import { getSetting } from '../services/settings.ts';
import { ftcLookup } from './ftc.ts';
import { SpamError } from './http.ts';
import { ipqsCredits, ipqsLookup } from './ipqs.ts';
export type SpamDeps = { db: Db; log: { warn(o: object, msg: string): void } };
/** US numbers only (owner, 2026-10-27): 1NXXNXXXXXX after normalizeNumber (00 §8). */
export const isCheckable = (n: string) => /^1[2-9]\d{2}[2-9]\d{6}$/.test(n);
/** `n` as 1NXXNXXXXXX-style digits (00 §8), or '' when it isn't a number. */
export const usNumber = (n: string) => {
try {
return normalizeNumber(n);
} catch {
return '';
}
};
/** A check under way, by number: a ring group ringing five people costs one credit (14 §4). */
const pending = new Map<string, Promise<SpamCheck | null>>();
function warn(d: SpamDeps, provider: string, err: unknown) {
if (err instanceof SpamError) d.log.warn({ provider, reason: err.message }, 'spam check failed');
else d.log.warn({ provider, err }, 'spam check failed');
}
/**
* IPQualityScore first when it's set up, then the FTC list (owner, 2026-10-27). `cachedOnly`:
* the cache and the local FTC copy, never a request out (call history, 14 §6).
*/
export function checkNumber(
d: SpamDeps,
number: string,
opts: { cachedOnly?: boolean } = {},
): Promise<SpamCheck | null> {
if (opts.cachedOnly) return check(d, number, true);
// Shared from the start, before anything is awaited: else a check that finishes between
// another's cache read and its look here would cost a second request.
let p = pending.get(number);
if (!p) {
p = check(d, number, false).finally(() => pending.delete(number));
pending.set(number, p);
}
return p;
}
async function check(d: SpamDeps, number: string, cachedOnly: boolean): Promise<SpamCheck | null> {
if (!isCheckable(number)) return null;
const s = await getSetting(d.db, 'spam');
if (!s.enabled) return null;
if (s.ipqs) {
const [hit] = await d.db
.select()
.from(spamLookups)
.where(and(eq(spamLookups.number, number), gt(spamLookups.expiresAt, sql`now()`)));
if (hit) return { risk: hit.risk as SpamRisk, provider: 'ipqs', info: hit.info as IpqsInfo };
// Paused (14 §3): the cache still answers, IPQualityScore isn't asked.
if (!cachedOnly && !(await getSetting(d.db, 'spam.state')).ipqsPausedAt) {
const answer = await askIpqs(d, s, s.ipqs.keyEnc, number);
if (answer) return answer;
}
}
if (!s.ftc) return null;
try {
return { provider: 'ftc', ...(await ftcLookup(d.db, number, s.ftc.windowDays)) };
} catch (err) {
warn(d, 'ftc', err);
return null;
}
}
/**
* The check for a call ringing `userId` (14 §5): nothing for a number in their books (or when
* the books can't be read), else the answer if it comes within `waitMs`. A later answer still
* fills the cache.
*/
export async function checkCaller(
d: SpamDeps,
userId: string | null,
number: string,
waitMs: number,
): Promise<SpamCheck | null> {
if (!isCheckable(number)) return null;
const known = await contactNames(d.db, userId, [number], { wait: false }).catch(() => null);
if (!known || known.size) return null;
const check = checkNumber(d, number).catch((err: unknown) => {
warn(d, 'check', err);
return null;
});
let timer: NodeJS.Timeout | undefined;
const late = new Promise<null>((r) => (timer = setTimeout(r, waitMs, null)));
try {
return await Promise.race([check, late]);
} finally {
clearTimeout(timer);
}
}
async function askIpqs(
d: SpamDeps,
s: SpamSettings,
keyEnc: string,
number: string,
): Promise<SpamCheck | null> {
try {
const a = await ipqsLookup(decrypt(keyEnc), number);
if (a.kind === 'exhausted') {
await pauseIpqs(d, s);
return null;
}
const days = a.risk === 'low' ? s.cache.cleanDays : s.cache.flaggedDays;
const expiresAt = sql`now() + make_interval(days => ${days})`;
await d.db
.insert(spamLookups)
.values({ number, provider: 'ipqs', risk: a.risk, info: a.info, expiresAt })
.onConflictDoUpdate({
target: spamLookups.number,
set: { risk: a.risk, info: a.info, expiresAt, updatedAt: new Date() },
});
return { risk: a.risk, provider: 'ipqs', info: a.info };
} catch (err) {
warn(d, 'ipqs', err);
return null;
}
}
/**
* Merges `patch` into spam.state in one statement, so the jobs and checks don't overwrite each
* other's fields. `onlyIf`: the condition the stored state must meet; false when it didn't.
*/
export async function patchSpamState(
db: Db,
patch: Partial<SpamState>,
onlyIf?: SQL,
): Promise<boolean> {
await db
.insert(settings)
.values({ key: 'spam.state', value: SpamStateSchema.parse({}) })
.onConflictDoNothing();
const rows = await db
.update(settings)
.set({
value: sql`${settings.value} || ${JSON.stringify(patch)}::jsonb`,
updatedAt: new Date(),
})
.where(and(eq(settings.key, 'spam.state'), onlyIf))
.returning({ key: settings.key });
return rows.length > 0;
}
const paused = sql`${settings.value}->>'ipqsPausedAt'`;
/** Out of credits (14 §3): paused, and the event, warning and log line once per time. */
async function pauseIpqs(d: SpamDeps, s: SpamSettings) {
const first = await patchSpamState(
d.db,
{ ipqsPausedAt: new Date().toISOString(), warningDismissedAt: null },
isNull(paused),
);
if (!first) return;
d.log.warn({ provider: 'ipqs' }, 'spam check: IPQualityScore credits are used up, paused');
await logEvent(d.db, {
severity: 'warning',
code: 'spam.quotaExhausted',
source: 'Spam check',
message: `IPQualityScore's lookups are used up. ${s.ftc ? 'Spam checks use the FTC list' : 'Spam checks are off'} until they come back.`,
});
}
/** Unpaused, by the job or a new key (14 §3). */
export async function resumeIpqs(db: Db, why: string) {
const was = await patchSpamState(db, { ipqsPausedAt: null }, sql`${paused} is not null`);
if (was)
await logEvent(db, {
severity: 'info',
code: 'spam.quotaRestored',
source: 'Spam check',
message: `IPQualityScore lookups are back (${why}).`,
});
}
/** Job spamQuotaCheck (14 §3), every 6 hours: while paused, are credits back? A failure throws
* (the job's log line) and the next run tries again. */
export async function spamQuotaCheck(db: Db) {
const s = await getSetting(db, 'spam');
if (!s.ipqs || !(await getSetting(db, 'spam.state')).ipqsPausedAt) return;
const credits = await ipqsCredits(decrypt(s.ipqs.keyEnc));
if (credits > 0) await resumeIpqs(db, `${credits} credits left`);
}
+171
View File
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing
// out. spamFtcSync keeps the copy.
import { lt, sql } from 'drizzle-orm';
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { ftcComplaints } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getSetting } from '../services/settings.ts';
import { todayIn } from '../generator/load.ts';
import { isCheckable, patchSpamState } from './check.ts';
import { SpamError, getJson } from './http.ts';
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
/** Risk (owner, 2026-10-27): 3 or more complaints in the window high, 1–2 medium. */
export const ftcRisk = (complaints: number): SpamRisk =>
complaints >= 3 ? 'high' : complaints >= 1 ? 'medium' : 'low';
/** The complaints about `number` (1NXXNXXXXXX) in the last `windowDays`. */
export async function ftcLookup(
db: Db,
number: string,
windowDays: number,
): Promise<{ risk: SpamRisk; info: FtcInfo }> {
const r = await db.execute<{
complaints: number;
robocalls: number;
last: Date | string | null;
subject: string | null;
}>(sql`
select count(*)::int as complaints,
(count(*) filter (where robocall))::int as robocalls,
max(reported_at) as last,
mode() within group (order by subject) as subject
from ftc_complaints
where number = ${number}
and reported_at > now() - make_interval(days => ${windowDays})`);
const row = r.rows[0]!;
const info: FtcInfo = {
complaints: row.complaints,
robocalls: row.robocalls,
lastReportedAt: row.last === null ? null : new Date(row.last).toISOString(),
subject: row.subject,
};
return { risk: ftcRisk(info.complaints), info };
}
const OVER_LIMIT = 'api.data.gov says the key is over its hourly limit';
async function ftcGet(key: string, q: Record<string, string>) {
try {
return await getJson(`${FTC_API}?${new URLSearchParams(q)}`, { 'X-Api-Key': key });
} catch (err) {
const m = (err as Error).message;
if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key');
if (m === 'HTTP 429') throw new SpamError(OVER_LIMIT);
throw err;
}
}
/** Settings → Spam check → Test: one request with the api.data.gov key. */
export async function ftcTest(key: string): Promise<string> {
await ftcGet(key, { items_per_page: '1' });
return 'The FTC answered.';
}
// ponytail: the FTC's dates have no zone; taken as Eastern (it's in Washington). Hours off at
// worst, against a window of weeks.
const FTC_TZ = 'America/New_York';
const PAGE = 50;
const DATE_TIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const nextDay = (day: string) =>
new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
const daysBefore = (day: string, n: number) =>
new Date(Date.parse(`${day}T00:00:00Z`) - n * 86_400_000).toISOString().slice(0, 10);
/** One complaint as stored, or null for one without a usable US number or date. */
function complaint(c: unknown) {
const { id, attributes: a } = (c ?? {}) as { id?: unknown; attributes?: Record<string, unknown> };
const phone = `1${String(a?.['company-phone-number'] ?? '')}`;
const at = a?.['created-date'];
if (
typeof id !== 'string' ||
!isCheckable(phone) ||
typeof at !== 'string' ||
!DATE_TIME.test(at)
)
return null;
const robo = a?.['recorded-message-or-robocall'];
return {
id,
number: phone,
reportedAt: sql`${at}::timestamp at time zone ${FTC_TZ}`,
subject: typeof a?.subject === 'string' && a.subject ? a.subject : null,
robocall: robo === 'Y' ? true : robo === 'N' ? false : null,
};
}
/**
* Job spamFtcSync (14 §2.2), every minute: when a sync is due or under way, read up to `budget`
* pages (15 a minute is 900 an hour, under api.data.gov's 1,000) a day at a time, oldest first,
* saving where it got to. A failure (a 429 among them) is saved for the Settings tab and the next
* minute carries on from the same page. Returns the error, for the job's log line once.
*/
export async function ftcSync(
db: Db,
opts: { now?: Date; budget?: number } = {},
): Promise<string | null> {
const now = opts.now ?? new Date();
const s = await getSetting(db, 'spam');
if (!s.enabled || !s.ftc) return null;
const st = await getSetting(db, 'spam.state');
let cursor = st.ftcCursor;
if (!cursor) {
const due =
!st.ftcLastSyncAt ||
now.getTime() - Date.parse(st.ftcLastSyncAt) >= s.ftc.syncHours * 3_600_000;
if (!due) return null;
// From the newest complaint kept, less a day for ones posted late; at most the window.
const r = await db.execute<{ day: string | null }>(
sql`select to_char(max(reported_at) at time zone ${FTC_TZ}, 'YYYY-MM-DD') as day from ftc_complaints`,
);
const first = daysBefore(todayIn(FTC_TZ, now), s.ftc.windowDays);
const last = r.rows[0]?.day ? daysBefore(r.rows[0].day, 1) : first;
cursor = { day: last > first ? last : first, offset: 0 };
}
const key = decrypt(s.ftc.keyEnc);
const today = todayIn(FTC_TZ, now);
for (let n = 0; n < (opts.budget ?? 15); n++) {
let page: unknown[];
try {
const j = await ftcGet(key, {
created_date_from: `"${cursor.day} 00:00:00"`,
created_date_to: `"${cursor.day} 23:59:59"`,
sort_order: 'ASC',
items_per_page: String(PAGE),
offset: String(cursor.offset),
});
if (!Array.isArray(j.data)) throw new SpamError('the answer had no complaints list');
page = j.data;
} catch (err) {
const error = err instanceof SpamError ? err.message : 'the sync failed';
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: error });
if (!(err instanceof SpamError)) throw err;
return error === st.ftcLastError ? null : error;
}
const rows = page.map(complaint).filter((c) => c !== null);
if (rows.length) await db.insert(ftcComplaints).values(rows).onConflictDoNothing();
if (page.length === PAGE) cursor = { day: cursor.day, offset: cursor.offset + PAGE };
else if (cursor.day < today) cursor = { day: nextDay(cursor.day), offset: 0 };
else {
await db
.delete(ftcComplaints)
.where(
lt(
ftcComplaints.reportedAt,
sql`${now.toISOString()}::timestamptz - make_interval(days => ${s.ftc.windowDays})`,
),
);
await patchSpamState(db, {
ftcCursor: null,
ftcLastSyncAt: now.toISOString(),
ftcLastError: null,
});
return null;
}
}
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: null });
return null;
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The spam check's providers over HTTPS (14 §2). Errors carry only these texts: never the URL
// or fetch's own error, which can quote it (IPQualityScore's key is in the path).
/** A failure whose message is safe to log and show. */
export class SpamError extends Error {}
export async function getJson(
url: string,
headers: Record<string, string> = {},
): Promise<Record<string, unknown>> {
let r: Response;
try {
r = await fetch(url, { headers, signal: AbortSignal.timeout(4000) });
} catch (err) {
throw new SpamError(
(err as Error).name === 'TimeoutError' ? 'no answer in 4 s' : "couldn't connect",
);
}
if (!r.ok) throw new SpamError(`HTTP ${r.status}`);
try {
return (await r.json()) as Record<string, unknown>;
} catch {
throw new SpamError('the answer was not JSON');
}
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// IPQualityScore (14 §2.1). Its key goes in the URL's path: see http.ts.
import type { IpqsInfo, SpamRisk } from '@selfphoneng/shared';
import { SpamError, getJson } from './http.ts';
const BASE = 'https://www.ipqualityscore.com/api/json';
export type IpqsAnswer = { kind: 'ok'; risk: SpamRisk; info: IpqsInfo } | { kind: 'exhausted' };
const get = (path: string) => getJson(`${BASE}/${path}`);
const str = (v: unknown) => (typeof v === 'string' && v.trim() && v !== 'N/A' ? v.trim() : null);
const used = (msg: unknown) => typeof msg === 'string' && /insufficient credits/i.test(msg);
/** The provider's own message, cut short and without the key in case it quotes it. */
const said = (msg: unknown, key: string) =>
new SpamError(
typeof msg === 'string' && msg
? msg.replaceAll(key, '***').slice(0, 200)
: 'the request failed',
);
/** Risk (owner, 2026-10-27): IPQualityScore's own advice, 75 suspicious and 85 high risk. */
export function ipqsRisk(a: {
fraudScore: number;
spammer: boolean;
recentAbuse: boolean;
risky: boolean;
}): SpamRisk {
if (a.spammer || a.fraudScore >= 85) return 'high';
if (a.fraudScore >= 75 || a.recentAbuse || a.risky) return 'medium';
return 'low';
}
/** One phone lookup: `number` is 1NXXNXXXXXX. */
export async function ipqsLookup(key: string, number: string): Promise<IpqsAnswer> {
const j = await get(`phone/${encodeURIComponent(key)}/${number}?country[]=US`);
if (j.success !== true) {
if (used(j.message)) return { kind: 'exhausted' };
throw said(j.message, key);
}
const info: IpqsInfo = {
fraudScore: typeof j.fraud_score === 'number' ? j.fraud_score : 0,
spammer: j.spammer === true,
recentAbuse: j.recent_abuse === true,
lineType: str(j.line_type),
carrier: str(j.carrier),
name: str(j.name),
city: str(j.city),
region: str(j.region),
};
return { kind: 'ok', risk: ipqsRisk({ ...info, risky: j.risky === true }), info };
}
/** The Credit Usage API: credits left in this billing period (Test, and 14 §3). */
export async function ipqsCredits(key: string): Promise<number> {
const j = await get(`account/${encodeURIComponent(key)}`);
if (j.success === false) throw said(j.message, key);
const credits = Number(j.credits);
if (!Number.isFinite(credits)) throw new SpamError('the answer had no credit count');
return credits;
}
+177
View File
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data.
import { randomBytes } from 'node:crypto';
import { eq, sql } from 'drizzle-orm';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SPAM_DEFAULTS } from '@selfphoneng/shared';
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
import type { Db } from '../src/db/client.ts';
import { ftcComplaints, settings } from '../src/db/schema.ts';
import { ftcSync } from '../src/spam/ftc.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { createTestDb, hasTestDb } from './db.ts';
const KEY = 'datagov-secret-key-456';
const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington
const hour = 3_600_000;
const at = (day: string, i: number) =>
`${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`;
const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({
type: 'dnc_complaint',
id,
attributes: {
'company-phone-number': phone,
'created-date': created,
subject: 'Warranties',
'recorded-message-or-robocall': robo,
},
});
/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */
const DATA = [
...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))),
...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')),
ftc('c0', '3125550102', '2026-10-27 09:00:00', ''),
ftc('c1', '', '2026-10-27 09:01:00'), // no number
ftc('c2', '3125550102', '2026-10-27'), // no time
];
describe.skipIf(!hasTestDb)('FTC complaint download', () => {
let db: Db;
let drop: () => Promise<void>;
/** Each request's day and offset. */
const asked: string[] = [];
let fail: { at: number; status: number } | null = null;
const turnOn = (keyEnc = encrypt(KEY)) =>
setSetting(db, 'spam', {
...SPAM_DEFAULTS,
enabled: true,
ftc: { keyEnc, syncHours: 24, windowDays: 7 },
});
const stored = async () => (await db.select().from(ftcComplaints)).length;
const reset = async () => {
await db.delete(ftcComplaints);
await db.delete(settings).where(eq(settings.key, 'spam.state'));
};
beforeAll(async () => {
({ db, drop } = await createTestDb());
setSecretKey(randomBytes(32).toString('base64'));
});
afterAll(() => drop?.());
beforeEach(() => {
asked.length = 0;
fail = null;
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
if ((init?.headers as Record<string, string>)['X-Api-Key'] !== KEY) return json({}, 403);
const q = new URL(url).searchParams;
const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!;
expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`);
expect(q.get('sort_order')).toBe('ASC');
const offset = Number(q.get('offset'));
asked.push(`${day}+${offset}`);
if (fail && asked.length === fail.at) return json({}, fail.status);
const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day));
return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } });
});
});
afterEach(async () => {
vi.unstubAllGlobals();
await reset();
});
it('downloads nothing while spam check or the FTC list is off', async () => {
await setSetting(db, 'spam', SPAM_DEFAULTS);
expect(await ftcSync(db, { now: NOW })).toBeNull();
await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true });
expect(await ftcSync(db, { now: NOW })).toBeNull();
expect(asked).toEqual([]);
});
it('the first download fills the window a day at a time and drops old complaints', async () => {
await turnOn();
await db.insert(ftcComplaints).values({
id: 'old',
number: '13125550109',
reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`,
});
expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull();
expect(asked).toEqual([
'2026-10-20+0',
'2026-10-21+0',
'2026-10-22+0',
'2026-10-23+0',
'2026-10-24+0',
'2026-10-25+0',
'2026-10-25+50',
'2026-10-25+100',
'2026-10-26+0',
'2026-10-27+0',
]);
expect(await stored()).toBe(124);
const row = async (id: string) =>
(await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0];
expect(await row('b0')).toMatchObject({
number: '13125550101',
subject: 'Warranties',
robocall: false,
reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington
});
expect((await row('c0'))?.robocall).toBeNull();
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: null,
ftcLastSyncAt: NOW.toISOString(),
ftcLastError: null,
});
});
it('not again until syncHours; then from the newest day kept, less one', async () => {
await turnOn();
await ftcSync(db, { now: NOW, budget: 100 });
asked.length = 0;
await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) });
expect(asked).toEqual([]);
await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) });
expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']);
});
it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => {
await turnOn();
await ftcSync(db, { now: NOW });
expect(asked).toHaveLength(10);
await reset();
asked.length = 0;
await ftcSync(db, { now: NOW, budget: 2 });
expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({
day: '2026-10-22',
offset: 0,
});
fail = { at: 7, status: 429 }; // the 25th's second page
const said = 'api.data.gov says the key is over its hourly limit';
expect(await ftcSync(db, { now: NOW })).toBe(said);
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: { day: '2026-10-25', offset: 50 },
ftcLastError: said,
ftcLastSyncAt: null,
});
fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice
expect(await ftcSync(db, { now: NOW })).toBeNull();
fail = null;
expect(await ftcSync(db, { now: NOW })).toBeNull();
expect(await stored()).toBe(124);
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: null,
ftcLastError: null,
});
});
it('a refused key is saved for the Settings tab', async () => {
await turnOn(encrypt('wrong'));
expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key');
expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key');
});
});
+4 -2
View File
@@ -94,6 +94,8 @@ const EXPECTED: Record<string, Record<string, string>> = {
answered_by_extension_id: 'uuid|YES|', answered_by_extension_id: 'uuid|YES|',
talk_seconds: 'integer|NO|0', talk_seconds: 'integer|NO|0',
recording_id: 'uuid|YES|', recording_id: 'uuid|YES|',
spam_risk: 'text|YES|',
spam_info: 'jsonb|YES|',
...ts, ...ts,
}, },
audit_log: { audit_log: {
@@ -115,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb())); beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.()); afterAll(() => drop?.());
it('creates all 45 tables', async () => { it('creates all 47 tables', async () => {
const r = await db.execute( const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`, sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
); );
expect(r.rows[0]).toEqual({ n: 45 }); expect(r.rows[0]).toEqual({ n: 47 });
}); });
it.each(Object.keys(EXPECTED))('%s columns', async (table) => { it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+506
View File
@@ -0,0 +1,506 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SD1, SD2, SD4, SD5: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
import { readFileSync } from 'node:fs';
import { eq, like, sql } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared';
import { encrypt } from '../src/crypto/secrets.ts';
import {
auditLog,
cdr,
ftcComplaints,
settings as settingsTable,
spamLookups,
systemEvents,
users,
} from '../src/db/schema.ts';
import type { ActiveCall } from '@selfphoneng/shared';
import type { AmiMessage } from '../src/ami/events.ts';
import { callerNameToken } from '../src/contacts/callername.ts';
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const IPQS_KEY = 'ipqs-secret-key-123';
const FTC_KEY = 'datagov-secret-key-456';
const SPAMMER = '13125550001';
const SHADY = '13125550002';
const CLEAN = '13125550003';
const REPORTED = '13125550004';
/** IPQualityScore's phone answers by number; anything else is a valid, clean line. */
const ANSWERS: Record<string, Record<string, unknown>> = {
[SPAMMER]: { fraud_score: 40, spammer: true, line_type: 'VOIP', carrier: 'Acme Tel' },
[SHADY]: { fraud_score: 78, city: 'Austin', region: 'TX', name: 'N/A' },
[CLEAN]: { fraud_score: 10, line_type: 'Wireless', carrier: 'Verizon' },
};
describe.skipIf(!hasTestDb)('spam check', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let admin: Req;
const urls: string[] = [];
/** What the fake IPQualityScore does: answer, fail, or say the credits are used up. */
let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' | 'late' = 'ok';
let credits = 912;
const log = { warn: vi.fn() };
const d = () => ({ db: t.db, log });
const settings = (s: Partial<SpamSettings>) =>
setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, enabled: true, ...s });
// Getters: the secret key is only set once createTestApp has run.
const keys = {
get withIpqs() {
return { ipqs: { keyEnc: encrypt(IPQS_KEY) } };
},
get withFtc() {
return { ftc: { keyEnc: encrypt(FTC_KEY), syncHours: 24, windowDays: 30 } };
},
};
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
admin = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
const day = (n: number) => sql`now() - make_interval(days => ${n})`;
await t.db.insert(ftcComplaints).values([
{ id: 'a1', number: REPORTED, reportedAt: day(1), subject: 'Warranties', robocall: true },
{ id: 'a2', number: REPORTED, reportedAt: day(2), subject: 'Warranties', robocall: true },
{ id: 'a3', number: REPORTED, reportedAt: day(3), subject: 'Debt', robocall: false },
{ id: 'a4', number: REPORTED, reportedAt: day(40), subject: 'Debt', robocall: true },
{ id: 'b1', number: SHADY, reportedAt: day(5), subject: null, robocall: null },
]);
});
afterAll(() => t?.close());
beforeEach(() => {
urls.length = 0;
log.warn.mockClear();
ipqs = 'ok';
credits = 912;
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
urls.push(url);
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
if (url.startsWith('https://api.ftc.gov/')) {
const key = (init?.headers as Record<string, string>)['X-Api-Key'];
return key === FTC_KEY ? json({ data: [] }) : json({ error: {} }, 403);
}
const account = /\/api\/json\/account\/([^/]+)$/.exec(url);
if (account)
return account[1] === IPQS_KEY
? json({ success: true, credits, usage: 88 })
: json({ success: false, message: `Invalid or unauthorized key: ${account[1]}` });
const phone = /\/api\/json\/phone\/[^/]+\/(\d+)\?country\[\]=US$/.exec(url);
if (!phone) return json({}, 404);
if (ipqs === 'down') return json({}, 503);
if (ipqs === 'slow') throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
if (ipqs === 'exhausted')
return json({
success: false,
message: 'You have insufficient credits to make this query.',
});
await new Promise((r) => setTimeout(r, ipqs === 'late' ? 1500 : 20));
return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) });
});
});
afterEach(async () => {
vi.unstubAllGlobals();
await t.db.delete(spamLookups);
await t.db.delete(settingsTable).where(eq(settingsTable.key, 'spam.state'));
await t.db.delete(systemEvents).where(like(systemEvents.code, 'spam.%'));
});
it('checks nothing while off, and only US numbers', async () => {
await setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, ...keys.withIpqs });
expect(await checkNumber(d(), SPAMMER)).toBeNull();
await settings(keys.withIpqs);
for (const n of ['442079460958', '911', '101', '13125550', '10005550001'])
expect(await checkNumber(d(), n)).toBeNull();
expect(urls).toEqual([]);
});
it('IPQualityScore: risk from its flags and score, only the kept fields', async () => {
await settings(keys.withIpqs);
expect(await checkNumber(d(), SPAMMER)).toEqual({
risk: 'high',
provider: 'ipqs',
info: {
fraudScore: 40,
spammer: true,
recentAbuse: false,
lineType: 'VOIP',
carrier: 'Acme Tel',
name: null,
city: null,
region: null,
},
});
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
expect(await checkNumber(d(), CLEAN)).toMatchObject({
risk: 'low',
info: { carrier: 'Verizon' },
});
expect(urls).toHaveLength(3);
});
it('caches answers: flagged for flaggedDays, the rest for cleanDays', async () => {
await settings({ ...keys.withIpqs, cache: { flaggedDays: 14, cleanDays: 3 } });
await checkNumber(d(), SPAMMER);
await checkNumber(d(), CLEAN);
await checkNumber(d(), SPAMMER);
await checkNumber(d(), CLEAN);
expect(urls).toHaveLength(2);
const days = await t.db.execute<{ number: string; days: number }>(
sql`select number, round(extract(epoch from expires_at - now()) / 86400)::int as days from spam_lookups order by number`,
);
expect(days.rows).toEqual([
{ number: SPAMMER, days: 14 },
{ number: CLEAN, days: 3 },
]);
// An expired answer is asked again.
await t.db.update(spamLookups).set({ expiresAt: sql`now() - interval '1 second'` });
await checkNumber(d(), CLEAN);
expect(urls).toHaveLength(3);
});
it('a check under way is shared: five at once cost one request', async () => {
await settings(keys.withIpqs);
const all = await Promise.all([1, 2, 3, 4, 5].map(() => checkNumber(d(), SPAMMER)));
expect(new Set(all.map((a) => a?.risk))).toEqual(new Set(['high']));
expect(urls).toHaveLength(1);
});
it('IPQualityScore failing: one warning without the key, nothing cached, the FTC answers', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
for (const mode of ['down', 'slow'] as const) {
ipqs = mode;
expect(await checkNumber(d(), REPORTED)).toEqual({
risk: 'high',
provider: 'ftc',
info: {
complaints: 3,
robocalls: 2,
lastReportedAt: expect.any(String),
subject: 'Warranties',
},
});
}
expect(log.warn.mock.calls.map((c) => c[0])).toEqual([
{ provider: 'ipqs', reason: 'HTTP 503' },
{ provider: 'ipqs', reason: 'no answer in 4 s' },
]);
expect(JSON.stringify(log.warn.mock.calls)).not.toContain(IPQS_KEY);
expect(await t.db.select().from(spamLookups)).toEqual([]);
});
it('without IPQualityScore the FTC list answers; without either, nothing', async () => {
ipqs = 'down';
await settings(keys.withFtc);
expect((await checkNumber(d(), SHADY))?.info).toEqual({
complaints: 1,
robocalls: 0,
lastReportedAt: expect.any(String),
subject: null,
});
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
expect(await checkNumber(d(), CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
expect(urls).toEqual([]);
await settings({});
expect(await checkNumber(d(), REPORTED)).toBeNull();
});
it('out of credits: paused once, the FTC answers, the warning, Dismiss, and back', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
const warning = async () =>
(await admin('GET', '/api/system/warnings'))
.json()
.items.find((w: { code: string }) => w.code === 'spam_quota');
const events = async () =>
(
await t.db
.select({ code: systemEvents.code })
.from(systemEvents)
.where(like(systemEvents.code, 'spam.%'))
.orderBy(systemEvents.createdAt)
).map((e) => e.code);
ipqs = 'exhausted';
const both = await Promise.all([checkNumber(d(), REPORTED), checkNumber(d(), SHADY)]);
expect(both.map((a) => a?.provider)).toEqual(['ftc', 'ftc']);
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
expect(log.warn).toHaveBeenCalledTimes(1);
expect(await events()).toEqual(['spam.quotaExhausted']);
expect(await warning()).toEqual({
code: 'spam_quota',
message:
"IPQualityScore's lookups are used up. Spam checks use the FTC list until they come back.",
link: '/admin/settings/spam',
dismissable: true,
});
// Paused: IPQualityScore isn't asked and nothing is logged.
urls.length = 0;
expect((await checkNumber(d(), CLEAN))?.provider).toBe('ftc');
expect(urls).toEqual([]);
expect(log.warn).toHaveBeenCalledTimes(1);
expect((await admin('POST', '/api/system/warnings/spam_quota/dismiss')).statusCode).toBe(200);
expect(await warning()).toBeUndefined();
credits = 0;
await spamQuotaCheck(t.db);
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
credits = 5;
await spamQuotaCheck(t.db);
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
expect(await events()).toEqual(['spam.quotaExhausted', 'spam.quotaRestored']);
ipqs = 'ok';
expect((await checkNumber(d(), SPAMMER))?.provider).toBe('ipqs');
// Out again: the warning comes back though it was dismissed; without the FTC list, "off".
await settings(keys.withIpqs);
ipqs = 'exhausted';
expect(await checkNumber(d(), SHADY)).toBeNull();
expect((await warning())?.message).toBe(
"IPQualityScore's lookups are used up. Spam checks are off until they come back.",
);
// A new key doesn't wait for the job.
const r = await admin('PATCH', '/api/settings', {
spam: {
...SPAM_DEFAULTS,
enabled: true,
ipqs: { key: IPQS_KEY },
},
});
expect(r.statusCode).toBe(200);
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
expect(await warning()).toBeUndefined();
});
it('spamQuotaCheck asks nothing unless paused, and a failure throws without the key', async () => {
await settings(keys.withIpqs);
await spamQuotaCheck(t.db);
expect(urls).toEqual([]);
await setSetting(t.db, 'spam.state', {
...(await getSetting(t.db, 'spam.state')),
ipqsPausedAt: new Date().toISOString(),
});
await settings({ ipqs: { keyEnc: encrypt('wrong-key') } });
const err = await spamQuotaCheck(t.db).catch((e: unknown) => e as Error);
expect(err).toBeInstanceOf(Error);
expect(err!.message).toBe('Invalid or unauthorized key: ***');
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
});
it('cachedOnly never asks IPQualityScore', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
expect((await checkNumber(d(), REPORTED, { cachedOnly: true }))?.provider).toBe('ftc');
await checkNumber(d(), SPAMMER);
expect((await checkNumber(d(), SPAMMER, { cachedOnly: true }))?.provider).toBe('ipqs');
expect(urls).toHaveLength(1);
});
describe('while it rings', () => {
let ringing: ActiveCall[] = [];
const call = (from: string, ext: string): ActiveCall => ({
linkedid: `l-${from}`,
startedAt: new Date().toISOString(),
direction: 'inbound',
from: { number: from },
to: { number: '13125559999' },
state: 'ringing',
extensions: [ext],
channels: [],
recording: false,
});
const check = async (number: string) =>
(await admin('GET', `/api/me/caller-check?number=${number}`)).json().check;
const deskName = async (number: string, ext = '101') =>
(
await t.app.inject({
method: 'GET',
url: `/api/internal/caller-name?ext=${ext}&number=${number}&token=${callerNameToken(t.app.config.APP_SECRET)}`,
})
).body;
beforeAll(async () => {
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
expect(
(await admin('POST', '/api/extensions', { number: '101', name: 'Admin', userId: me!.id }))
.statusCode,
).toBe(201);
await admin('POST', '/api/me/contacts', {
name: 'Pizza Place',
numbers: [{ number: SHADY.slice(1) }],
});
t.app.live.calls.list = () => ringing;
});
beforeEach(() => settings({ ...keys.withIpqs, ...keys.withFtc }));
it('the softphone: only callers ringing me now, never my contacts', async () => {
ringing = [call(SPAMMER.slice(1), '101'), call(CLEAN, '102'), call(SHADY, '101')];
expect(await check(SPAMMER)).toMatchObject({ risk: 'high', provider: 'ipqs' });
expect(await check(`+1 ${SPAMMER.slice(1)}`)).toMatchObject({ risk: 'high' });
expect(await check(CLEAN)).toBeNull(); // ringing someone else
expect(await check(REPORTED)).toBeNull(); // not ringing
expect(await check(SHADY)).toBeNull(); // a contact
expect(urls).toHaveLength(1);
ringing = [call(CLEAN, '101')];
expect(await check(CLEAN)).toMatchObject({ risk: 'low' }); // low: the softphone hides it
expect((await admin('GET', '/api/me/caller-check?number=nonsense')).json().check).toBeNull();
});
it('desk phones: the label in time, nothing for a contact, low risk or a late answer', async () => {
expect(await deskName(SPAMMER)).toBe('SPAM LIKELY');
expect(await deskName(SHADY)).toBe('Pizza Place');
expect(await deskName(SHADY, '999')).toBe('Spam risk'); // nobody's contact there
expect(await deskName(CLEAN)).toBe('');
await t.db.delete(spamLookups);
ipqs = 'late';
expect(await deskName(SPAMMER)).toBe('');
await vi.waitFor(async () => expect(await t.db.select().from(spamLookups)).toHaveLength(1));
expect(await deskName(SPAMMER)).toBe('SPAM LIKELY'); // from the cache
await settings({ ...keys.withFtc, deskPhoneLabels: { high: 'JUNK', medium: 'Maybe junk' } });
expect(await deskName(REPORTED)).toBe('JUNK');
});
it('call history keeps what the check knew, after the cache expires; contacts first', async () => {
const fixture = readFileSync(
new URL('./fixtures/cdr/inbound-user.json', import.meta.url),
'utf8',
);
const callFrom = async (number: string) => {
const events = (JSON.parse(fixture.replaceAll('13125559999', number)) as AmiMessage[]).map(
(e) => ({ ...e, LinkedID: `${number}-${String(e.LinkedID)}` }),
);
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
await aggregateCall(t.db, String(events[0]!.LinkedID));
};
await checkNumber(d(), SPAMMER);
urls.length = 0;
for (const n of [SPAMMER, REPORTED, SHADY, CLEAN]) await callFrom(n);
expect(urls).toEqual([]); // cachedOnly: CLEAN isn't asked
await t.db.delete(spamLookups);
const spamOf = async (n: string) =>
(await admin('GET', '/api/calls?direction=inbound'))
.json()
.items.find((c: { fromNumber: string }) => c.fromNumber === n)?.spam;
expect(await spamOf(SPAMMER)).toMatchObject({
risk: 'high',
provider: 'ipqs',
info: { spammer: true },
});
expect(await spamOf(REPORTED)).toMatchObject({
risk: 'high',
provider: 'ftc',
info: { complaints: 3 },
});
expect(await spamOf(SHADY)).toBeNull(); // the admin's contact
// Not in the cache, never reported: low (the page shows nothing for it).
expect(await spamOf(CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
});
});
describe('Settings → Spam check', () => {
const body = (s: Record<string, unknown> = {}) => ({
spam: {
enabled: true,
ipqs: { key: IPQS_KEY },
ftc: null,
cache: { flaggedDays: 14, cleanDays: 3 },
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
...s,
},
});
it('Download now: due at the next minute; refused without the FTC list', async () => {
await settings(keys.withIpqs);
const r = await admin('POST', '/api/settings/spam/ftc/sync');
expect(r.statusCode).toBe(400);
expect(r.body).toMatch('Set up the FTC list first');
await settings(keys.withFtc);
await setSetting(t.db, 'spam.state', {
...(await getSetting(t.db, 'spam.state')),
ftcLastSyncAt: new Date().toISOString(),
});
expect((await admin('POST', '/api/settings/spam/ftc/sync')).statusCode).toBe(200);
expect((await getSetting(t.db, 'spam.state')).ftcLastSyncAt).toBeNull();
});
it('keys are write-only, and empty keeps the saved one', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const r = await admin('PATCH', '/api/settings', body());
expect(r.statusCode).toBe(200);
expect(r.json().spam).toEqual({
...SPAM_DEFAULTS,
enabled: true,
ipqs: { hasKey: true },
});
expect(r.body).not.toContain(IPQS_KEY);
const saved = (await getSetting(t.db, 'spam')).ipqs!.keyEnc;
expect((await admin('PATCH', '/api/settings', body({ ipqs: {} }))).statusCode).toBe(200);
expect((await getSetting(t.db, 'spam')).ipqs!.keyEnc).toBe(saved);
const [a] = await t.db
.select()
.from(auditLog)
.where(eq(auditLog.summary, 'Changed settings: spam'));
expect(JSON.stringify(a!.diff)).not.toContain(saved);
});
it('refuses a provider without a key, nothing to check with, and odd labels', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const no = async (b: unknown, error: RegExp) => {
const r = await admin('PATCH', '/api/settings', b);
expect(r.statusCode).toBe(400);
expect(r.body).toMatch(error);
};
await no(body({ ipqs: {} }), /Enter the IPQualityScore key/);
await no(
body({ ipqs: null, ftc: { syncHours: 24, windowDays: 30 } }),
/Enter the api.data.gov key/,
);
await no(body({ ipqs: null }), /Set up IPQualityScore or the FTC list/);
await no(body({ deskPhoneLabels: { high: 'Spam?', medium: 'Spam risk' } }), /Letters/);
await no(
body({ ftc: { key: FTC_KEY, syncHours: 0, windowDays: 30 } }),
/syncHours|too small|>=1/i,
);
await no({ 'spam.state': { ipqsPausedAt: null } }, /can't be changed/);
// Off with no provider is fine.
expect(
(await admin('PATCH', '/api/settings', body({ enabled: false, ipqs: null }))).statusCode,
).toBe(200);
});
it('Test uses the typed key, else the saved one, and never shows it', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const test = async (provider: string, key?: string) =>
(await admin('POST', `/api/settings/spam/${provider}/test`, { key })).json();
expect(await test('ipqs')).toEqual({ ok: false, error: 'Enter the key first' });
expect(await test('ipqs', IPQS_KEY)).toEqual({
ok: true,
detail: 'Works: 912 credits left this billing period.',
});
const bad = await test('ipqs', 'wrong-key-789');
expect(bad.ok).toBe(false);
expect(JSON.stringify(bad)).not.toContain('wrong-key-789');
await settings({ ...keys.withIpqs, ...keys.withFtc });
expect((await test('ipqs')).ok).toBe(true);
expect(await test('ftc')).toEqual({ ok: true, detail: 'The FTC answered.' });
expect(await test('ftc', 'nope')).toEqual({
ok: false,
error: 'api.data.gov refused the key',
});
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@selfphoneng/probe", "name": "@selfphoneng/probe",
"version": "0.0.9", "version": "0.0.11",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@selfphoneng/web", "name": "@selfphoneng/web",
"version": "0.0.9", "version": "0.0.11",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
@@ -13,6 +13,7 @@
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@selfphoneng/shared": "workspace:*", "@selfphoneng/shared": "workspace:*",
"@tanstack/react-query": "^5.104.3", "@tanstack/react-query": "^5.104.3",
"lucide-react": "^1.56.0",
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.90.0", "react-hook-form": "^7.90.0",
+14
View File
@@ -3,6 +3,8 @@
import type { import type {
LdapInput, LdapInput,
LdapTestResult, LdapTestResult,
SpamCheck,
SpamTestResult,
AuthMethods, AuthMethods,
OidcInput, OidcInput,
OidcTestResult, OidcTestResult,
@@ -255,10 +257,16 @@ export const settingsApi = {
'/api/settings/emergency/test', '/api/settings/emergency/test',
), ),
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items), warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
dismissWarning: (code: string) =>
api<{ ok: true }>('POST', `/api/system/warnings/${code}/dismiss`),
testOidc: (v: OidcInput) => api<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v), testOidc: (v: OidcInput) => api<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v),
/** 12 §4.1 */ /** 12 §4.1 */
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) => testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v), api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
/** 14 §7.1: the typed key, else the saved one. */
syncFtc: () => api<{ ok: true }>('POST', '/api/settings/spam/ftc/sync'),
testSpam: (provider: 'ipqs' | 'ftc', key?: string) =>
api<SpamTestResult>('POST', `/api/settings/spam/${provider}/test`, { key }),
}; };
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */ /** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
@@ -299,6 +307,12 @@ export const meApi = {
calls: (cursor?: string) => calls: (cursor?: string) =>
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`), api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
get: () => api<MeDto>('GET', '/api/me'), get: () => api<MeDto>('GET', '/api/me'),
/** 14 §5.1: only for a caller ringing me now. */
callerCheck: (number: string) =>
api<{ check: SpamCheck | null }>(
'GET',
`/api/me/caller-check?number=${encodeURIComponent(number)}`,
).then((r) => r.check),
softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'), softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'),
update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m), update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m),
connectContrack: (token: string) => api<MeDto>('PUT', '/api/me/contrack', { token }), connectContrack: (token: string) => api<MeDto>('PUT', '/api/me/contrack', { token }),
+1 -1
View File
@@ -26,7 +26,7 @@ export function RequireAuth({
}) { }) {
const me = useMe(); const me = useMe();
const loc = useLocation(); 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.isError) {
if (me.error instanceof ApiError && me.error.status === 401) if (me.error instanceof ApiError && me.error.status === 401)
return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname)}`} replace />; return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname)}`} replace />;
@@ -143,7 +143,7 @@ export function DestinationPicker({
</p> </p>
)} )}
{isIdKind(kind) && all[kind] && targets(kind).length === 0 && ( {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> </fieldset>
); );
+6 -5
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Native <dialog> (focus trap + Esc for free) as a centered modal or a right-hand drawer. // 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 { useEffect, useRef, type ReactNode } from 'react';
import { Button } from './ui.tsx'; import { Button } from './ui.tsx';
@@ -28,27 +29,27 @@ export function Dialog({
const shape = drawer const shape = drawer
? 'ml-auto mr-0 h-dvh max-h-dvh w-full max-w-md' ? '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 ( return (
<dialog <dialog
ref={ref} ref={ref}
onClose={onClose} onClose={onClose}
onClick={(e) => e.target === ref.current && onClose()} onClick={(e) => e.target === ref.current && onClose()}
aria-labelledby="dialog-title" 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"> <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"> <h2 id="dialog-title" className="text-base font-semibold">
{title} {title}
</h2> </h2>
<Button variant="ghost" onClick={onClose} aria-label="Close" className="px-2 py-1"> <Button variant="ghost" onClick={onClose} aria-label="Close" className="px-2 py-1">
✕ <X className="size-4" />
</Button> </Button>
</header> </header>
<div className="flex-1 overflow-y-auto p-4">{children}</div> <div className="flex-1 overflow-y-auto p-4">{children}</div>
{footer && ( {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}
</footer> </footer>
)} )}
+5 -5
View File
@@ -15,13 +15,13 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
Parked calls Parked calls
</h2> </h2>
{parked.length === 0 ? ( {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. None. Transfer a call to 700 to park it; dial the slot to pick it up.
</p> </p>
) : ( ) : (
<ul <ul
aria-label="Parked calls" 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) => ( {parked.map((p) => (
<li <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="w-10 font-mono font-semibold">{p.slot}</span>
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
{p.callerName ? `${p.callerName} ` : ''} {p.callerName ? `${p.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(p.callerNumber)}</span> <span className="text-muted">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-zinc-500"> · parked by {p.parkedBy}</span>} {p.parkedBy && <span className="text-muted"> · parked by {p.parkedBy}</span>}
</span> </span>
<span className="font-mono tabular-nums">{since(p.since, now)}</span> <span className="font-mono tabular-nums">{since(p.since, now)}</span>
{onPickUp && ( {onPickUp && (
<button <button
type="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)} onClick={() => onPickUp(p.slot)}
> >
Pick up {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 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. point the camera at the code below. On a computer, paste the link instead.
</p> </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 <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. hand: an admin can see the server, user name and password.
</p> </p>
@@ -32,14 +32,14 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
className="rounded bg-white" className="rounded bg-white"
/> />
<p className="font-mono text-xs break-all">{qr.data.url}</p> <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 Works until {new Date(qr.data.expiresAt).toLocaleString()}. It carries your phone
password: don't share it. password: don't share it.
</p> </p>
</div> </div>
)} )}
{qr.error && <Alert>{qr.error.message}</Alert>} {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 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 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. 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"> <div className="flex flex-col gap-1 text-sm">
<h3 className="font-medium">Password</h3> <h3 className="font-medium">Password</h3>
{ways.password ? ( {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 Change password
</Link> </Link>
) : ( ) : (
<p className="text-zinc-500"> <p className="text-muted">
You have no password here. An admin can give you one with Reset password. You have no password here. An admin can give you one with Reset password.
</p> </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. Connect your {oidc.label} account to sign in with it as well.
</p> </p>
<div> <div>
@@ -133,7 +133,7 @@ function DirectoryConnect() {
}} }}
> >
<h3 className="font-medium">Company directory</h3> <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. Connect your directory account to sign in with its password as well.
</p> </p>
<Field label="Directory username or email"> <Field label="Directory username or email">
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// SoundPicker (05 §3): pick a recorded prompt, play it, or upload a new one right here. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId, useRef } from 'react'; import { useId, useRef } from 'react';
import { soundsApi } from '../api/client.ts'; import { soundsApi } from '../api/client.ts';
@@ -63,7 +64,7 @@ export function SoundPicker({
void a.play().catch(() => undefined); void a.play().catch(() => undefined);
}} }}
> >
▶ <Play className="size-4" />
</Button> </Button>
<Button onClick={() => file.current?.click()} disabled={upload.isPending}> <Button onClick={() => file.current?.click()} disabled={upload.isPending}>
{upload.isPending ? 'Uploading…' : 'Upload new'} {upload.isPending ? 'Uploading…' : 'Upload new'}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { expect, it } from 'vitest';
import { spamLine } from './SpamNote.tsx';
it('says what the spam check knows, in one line (14 §5.1)', () => {
expect(
spamLine({
risk: 'high',
provider: 'ipqs',
info: {
fraudScore: 92,
spammer: true,
recentAbuse: false,
lineType: 'VOIP',
carrier: 'Verizon',
name: null,
city: 'Austin',
region: 'TX',
},
}),
).toBe('Fraud score 92 · spammer · VOIP · Verizon · Austin, TX');
const ftc = (complaints: number, robocalls: number, subject: string | null) =>
spamLine({
risk: 'high',
provider: 'ftc',
info: { complaints, robocalls, lastReportedAt: '2026-10-20T15:00:00Z', subject },
});
expect(ftc(4, 3, 'Warranties')).toMatch(
/^Reported to the FTC 4 times \(3 robocalls\), last on .+: Warranties$/,
);
expect(ftc(1, 0, null)).toMatch(/^Reported to the FTC once, last on [^:]+$/);
});
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// What the spam check said about a caller (14 §5.1): a badge and a line, for high and medium
// risk only. Shown in the softphone and call history.
import type { SpamCheck } from '@selfphoneng/shared';
const day = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
/** "Fraud score 92 · spammer · VoIP · Verizon · Austin, TX", or "Reported to the FTC 4 times…". */
export function spamLine(c: SpamCheck): string {
if (c.provider === 'ftc') {
const i = c.info;
const times = i.complaints === 1 ? 'once' : `${i.complaints} times`;
const robo = i.robocalls ? ` (${i.robocalls} robocall${i.robocalls === 1 ? '' : 's'})` : '';
const last = i.lastReportedAt ? `, last on ${day(i.lastReportedAt)}` : '';
return `Reported to the FTC ${times}${robo}${last}${i.subject ? `: ${i.subject}` : ''}`;
}
const i = c.info;
const place = [i.city, i.region].filter(Boolean).join(', ');
return [
i.name,
`Fraud score ${i.fraudScore}`,
i.spammer && 'spammer',
i.recentAbuse && 'recent abuse',
i.lineType,
i.carrier,
place,
]
.filter(Boolean)
.join(' · ');
}
export function SpamNote({ check }: { check: SpamCheck | null | undefined }) {
if (!check || check.risk === 'low') return null;
const high = check.risk === 'high';
return (
<span className="flex flex-wrap items-center gap-x-2 text-sm">
<span
className={`rounded px-1.5 py-0.5 text-xs font-semibold ${
high
? 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200'
}`}
>
{high ? 'Likely spam' : 'Possible spam'}
</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 // SPDX-License-Identifier: AGPL-3.0-or-later
import { Moon, Sun } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { toggleTheme } from '../theme.ts'; import { toggleTheme } from '../theme.ts';
import { Button } from './ui.tsx'; import { Button } from './ui.tsx';
@@ -12,7 +13,7 @@ export function ThemeToggle() {
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'} aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
onClick={() => setDark(toggleTheme())} onClick={() => setDark(toggleTheme())}
> >
{dark ? '☀' : '☾'} {dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button> </Button>
); );
} }
+35 -9
View File
@@ -3,15 +3,14 @@
import { useId, type ComponentProps, type ReactNode } from 'react'; import { useId, type ComponentProps, type ReactNode } from 'react';
import { APP_NAME } from '@selfphoneng/shared'; import { APP_NAME } from '@selfphoneng/shared';
const focus = export const focusRing =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500'; 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus';
const variants = { const variants = {
primary: 'bg-sky-600 text-white hover:bg-sky-700 disabled:bg-sky-600/50', primary: 'bg-primary text-white hover:bg-primary-hover disabled:bg-primary/50',
secondary: secondary: 'border border-line-strong bg-surface hover:bg-subtle',
'border border-zinc-300 bg-white hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800',
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-red-600/50', 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. */ /** The icon (public/icon-192.png, cut from public/logo.png) and the product name. */
@@ -32,13 +31,40 @@ export function Button({
return ( return (
<button <button
type="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} {...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. */ /** Label + control + error text. `children` gets the id and aria props to spread on the control. */
export function Field({ export function Field({
@@ -67,7 +93,7 @@ export function Field({
{(error || hint) && ( {(error || hint) && (
<p <p
id={msgId} 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} {error ?? hint}
</p> </p>
+64
View File
@@ -1,2 +1,66 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *)); @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(); 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+4 -4
View File
@@ -6,7 +6,7 @@ import { Link } from 'react-router';
import { aboutApi } from '../api/client.ts'; import { aboutApi } from '../api/client.ts';
import { Alert, Brand } from '../components/ui.tsx'; 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() { export function AboutPage() {
const about = useQuery({ queryKey: ['about'], queryFn: aboutApi.get }); const about = useQuery({ queryKey: ['about'], queryFn: aboutApi.get });
@@ -44,18 +44,18 @@ export function AboutPage() {
<a <a
href="/api/about/source" href="/api/about/source"
download="selfphone.tar.gz" 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} Code v{a.version}
</a> </a>
<span className="text-zinc-500"> <span className="text-muted">
selfphone.tar.gz, {Math.max(1, Math.round(a.source.bytes / 1e6))} MB, built{' '} selfphone.tar.gz, {Math.max(1, Math.round(a.source.bytes / 1e6))} MB, built{' '}
{new Date(a.source.builtAt).toLocaleString()} {new Date(a.source.builtAt).toLocaleString()}
</span> </span>
</div> </div>
) : ( ) : (
a && ( 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. This is a development build: the source archive is only in builds made with Docker.
The project&apos;s repository is below. The project&apos;s repository is below.
</p> </p>
+1 -1
View File
@@ -44,7 +44,7 @@ export function ChangePasswordPage() {
return ( return (
<AuthCard title="Change your password"> <AuthCard title="Change your password">
{forced && ( {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. You signed in with a temporary password. Choose a new one to continue.
</p> </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 }) { export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<main className="flex min-h-dvh flex-col items-center justify-center p-4"> <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" /> <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> <h1 className="mb-5 text-xl font-semibold">{title}</h1>
{children} {children}
</div> </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 About SelfPhoneNG · source code
</Link> </Link>
</main> </main>
@@ -73,12 +73,12 @@ export function LoginPage() {
{/* A full page load: the browser goes to the provider and comes back signed in. */} {/* A full page load: the browser goes to the provider and comes back signed in. */}
<a <a
href={`/api/auth/oidc/start${next ? `?next=${encodeURIComponent(next)}` : ''}`} 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} Sign in with {oidc.label}
</a> </a>
{showForm && ( {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 or with your password
</p> </p>
)} )}
@@ -87,7 +87,7 @@ export function LoginPage() {
{!showForm && ( {!showForm && (
<button <button
type="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)} onClick={() => setAdminForm(true)}
> >
Admin sign-in 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. // 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 // 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). // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
@@ -70,7 +71,7 @@ export function SetupPage() {
type="button" type="button"
onClick={() => go(i + 1)} onClick={() => go(i + 1)}
aria-current={step === i + 1 ? 'step' : undefined} 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} {i + 1}. {s}
</button> </button>
@@ -109,7 +110,7 @@ function Nav({
hint?: string; hint?: string;
}) { }) {
return ( 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}> <Button variant="primary" onClick={next} disabled={!canNext}>
Next Next
</Button> </Button>
@@ -118,7 +119,7 @@ function Nav({
{skip} {skip}
</Button> </Button>
)} )}
{hint && <span className="text-sm text-zinc-500">{hint}</span>} {hint && <span className="text-sm text-muted">{hint}</span>}
</div> </div>
); );
} }
@@ -127,7 +128,10 @@ function PasswordStep({ next }: { next: () => void }) {
// Signing in with the initial password already forced a change (RequireAuth). // Signing in with the initial password already forced a change (RequireAuth).
return ( 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} /> <Nav next={next} />
</> </>
); );
@@ -196,7 +200,7 @@ function CompanyForm({
</select> </select>
)} )}
</Field> </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()}> <Button variant="primary" type="submit" disabled={save.isPending || !name.trim()}>
Save and continue Save and continue
</Button> </Button>
@@ -343,7 +347,7 @@ function NetworkForm(p: {
I&apos;ve done this I&apos;ve done this
</label> </label>
</div> </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}> <Button variant="primary" type="submit" disabled={save.isPending || !done}>
{save.isPending ? 'Saving…' : 'Save and continue'} {save.isPending ? 'Saving…' : 'Save and continue'}
</Button> </Button>
@@ -399,7 +403,7 @@ function TrunkStep({ next }: { next: () => void }) {
const ok = status?.label === 'Registered' || status?.label === 'Reachable'; const ok = status?.label === 'Registered' || status?.label === 'Reachable';
return ( 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{' '} Connect your voip.ms sub-account. Follow the checklist, save, and wait for{' '}
<strong>Registered</strong>. <strong>Registered</strong>.
</p> </p>
@@ -457,7 +461,7 @@ function NumbersStep({ next }: { next: () => void }) {
); );
return ( 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. Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 9.
</p> </p>
{trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />} {trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />}
@@ -465,8 +469,8 @@ function NumbersStep({ next }: { next: () => void }) {
<ul className="text-sm"> <ul className="text-sm">
{dids.data.map((d) => ( {dids.data.map((d) => (
<li key={d.id}> <li key={d.id}>
✓ {formatNumber(d.number)}{' '} <Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
{d.label && <span className="text-zinc-500">{d.label}</span>} {formatNumber(d.number)} {d.label && <span className="text-muted">{d.label}</span>}
</li> </li>
))} ))}
</ul> </ul>
@@ -554,7 +558,7 @@ function UsersStep({ next }: { next: () => void }) {
return ( 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). 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 Each gets the extension you type (any free number, like 1), or the next free one, and a
temporary password, shown once. temporary password, shown once.
@@ -563,7 +567,8 @@ function UsersStep({ next }: { next: () => void }) {
<ul className="text-sm"> <ul className="text-sm">
{withExt.map((u) => ( {withExt.map((u) => (
<li key={u.id}> <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> </li>
))} ))}
</ul> </ul>
@@ -614,7 +619,7 @@ function UsersStep({ next }: { next: () => void }) {
</form> </form>
{save.isError && <Alert>{save.error.message}</Alert>} {save.isError && <Alert>{save.error.message}</Alert>}
{created.length > 0 && ( {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"> <p className="mb-2 font-medium">
Give each person their temporary password (shown only now): Give each person their temporary password (shown only now):
</p> </p>
@@ -624,7 +629,7 @@ function UsersStep({ next }: { next: () => void }) {
<tr key={c.email}> <tr key={c.email}>
<td>{c.number}</td> <td>{c.number}</td>
<td>{c.name}</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> <td className="font-mono">{c.password}</td>
</tr> </tr>
))} ))}
@@ -682,7 +687,7 @@ function RoutingStep({ next }: { next: () => void }) {
const incomplete = Object.values(dest).some((d) => d === null); const incomplete = Object.values(dest).some((d) => d === null);
return ( 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 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. Digital receptionist later and pick it here or on the Phone numbers page.
</p> </p>
@@ -715,7 +720,7 @@ function RoutingStep({ next }: { next: () => void }) {
})} })}
{everyone.isError && <Alert>{everyone.error.message}</Alert>} {everyone.isError && <Alert>{everyone.error.message}</Alert>}
{save.isError && <Alert>{save.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 <Button
variant="primary" variant="primary"
onClick={() => save.mutate()} 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>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> <li>Everything here can be changed later in the admin pages.</li>
</ul> </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}> <Button variant="primary" onClick={onFinish} disabled={busy}>
Finish Finish
</Button> </Button>
@@ -753,6 +758,6 @@ function Loading({ q }: { q: { isError: boolean; error: Error | null } }): React
return q.isError ? ( return q.isError ? (
<Alert>{q.error!.message}</Alert> <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"> <h2 id="two-step-h" className="font-medium">
Two-step sign-in Two-step sign-in
</h2> </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 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). (any app that does TOTP, such as Aegis, 2FAS, Google or Microsoft Authenticator).
</p> </p>
@@ -115,7 +115,7 @@ function TwoStep() {
height={240} height={240}
className="rounded bg-white" 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> Or type this key: <span className="font-mono break-all">{setup.data.secret}</span>
</p> </p>
<Field label="2. Enter the 6-digit code it shows"> <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 // 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 { 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 { useEffect, useState } from 'react';
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router'; import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared'; import type { AuthUser } from '@selfphoneng/shared';
@@ -9,28 +33,65 @@ import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Brand, Button } from '../../components/ui.tsx'; import { Brand, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts'; 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 = [ export const ADMIN_PAGES = [
{ path: '', label: 'Dashboard', milestone: null }, { path: '', label: 'Dashboard', milestone: null, section: '', icon: LayoutDashboard },
{ path: 'users', label: 'Users & Extensions', milestone: null }, { path: 'trunks', label: 'Trunks', milestone: null, section: 'Call routing', icon: Cable },
{ path: 'trunks', label: 'Trunks', milestone: null }, { path: 'numbers', label: 'Phone numbers', milestone: null, section: 'Call routing', icon: Hash },
{ path: 'numbers', label: 'Phone numbers', milestone: null }, {
{ path: 'outbound', label: 'Outbound rules', milestone: null }, path: 'outbound',
{ path: 'ring-groups', label: 'Ring groups', milestone: null }, label: 'Outbound rules',
{ path: 'queues', label: 'Queues', milestone: null }, milestone: null,
{ path: 'ivrs', label: 'Digital receptionist', milestone: null }, section: 'Call routing',
{ path: 'hours', label: 'Office hours', milestone: null }, icon: PhoneOutgoing,
{ path: 'conferences', label: 'Conferences', milestone: null }, },
{ path: 'paging', label: 'Paging', milestone: null }, {
{ path: 'sounds', label: 'Sounds & music', milestone: null }, path: 'ring-groups',
{ path: 'devices', label: 'Phones', milestone: null }, label: 'Ring groups',
{ path: 'contacts', label: 'Contacts', milestone: null }, milestone: null,
{ path: 'calls', label: 'Call history', milestone: null }, section: 'Call routing',
{ path: 'recordings', label: 'Recordings', milestone: null }, icon: BellRing,
{ path: 'reports', label: 'Reports', milestone: null }, },
{ path: 'sms', label: 'SMS', milestone: 'M9' }, { path: 'queues', label: 'Queues', milestone: null, section: 'Call routing', icon: ListOrdered },
{ path: 'blocked', label: 'Blocked numbers', milestone: null }, {
{ path: 'settings', label: 'Settings', milestone: null }, path: 'ivrs',
{ path: 'system', label: 'System', milestone: null }, 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; ] as const;
export function AdminLayout({ user }: { user: AuthUser }) { export function AdminLayout({ user }: { user: AuthUser }) {
@@ -52,29 +113,39 @@ export function AdminLayout({ user }: { user: AuthUser }) {
const links = ( const links = (
<nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4"> <nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4">
<ul className="flex flex-col gap-0.5"> {[...new Set(ADMIN_PAGES.map((p) => p.section))].map((section) => (
{ADMIN_PAGES.map((p) => ( <div key={section} className="mt-3 first:mt-0">
<li key={p.path}> {section && (
<NavLink <h2 className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted uppercase">
to={`/admin${p.path ? `/${p.path}` : ''}`} {section}
end={p.path === ''} </h2>
onClick={() => setNavOpen(false)} )}
className={({ isActive }) => <ul className="flex flex-col gap-0.5">
`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' : ''}` {ADMIN_PAGES.filter((p) => p.section === section).map((p) => (
} <li key={p.path}>
> <NavLink
{p.label} to={`/admin${p.path ? `/${p.path}` : ''}`}
{p.milestone && <span className="text-[10px] text-zinc-400">{p.milestone}</span>} end={p.path === ''}
</NavLink> onClick={() => setNavOpen(false)}
</li> 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' : ''}`
</ul> }
>
<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> </nav>
); );
return ( return (
<div className="flex min-h-dvh"> <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"> <p className="px-4 py-4 text-lg">
<Brand /> <Brand />
</p> </p>
@@ -82,7 +153,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</aside> </aside>
<div className="flex min-w-0 flex-1 flex-col"> <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 <Button
variant="ghost" variant="ghost"
className="md:hidden" className="md:hidden"
@@ -91,13 +162,11 @@ export function AdminLayout({ user }: { user: AuthUser }) {
aria-label="Menu" aria-label="Menu"
onClick={() => setNavOpen((o) => !o)} onClick={() => setNavOpen((o) => !o)}
> >
☰ <Menu className="size-5" />
</Button> </Button>
<Brand className="md:hidden" /> <Brand className="md:hidden" />
<div className="ml-auto flex items-center gap-1 whitespace-nowrap sm:gap-2"> <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"> <span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
{user.displayName}
</span>
<ThemeToggle /> <ThemeToggle />
{/* On phones this lives in the menu instead. */} {/* On phones this lives in the menu instead. */}
<span className="hidden sm:contents"> <span className="hidden sm:contents">
@@ -111,12 +180,9 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</div> </div>
</header> </header>
{navOpen && ( {navOpen && (
<div <div id="mobile-nav" className="border-b border-line bg-surface pt-2 md:hidden">
id="mobile-nav"
className="border-b border-zinc-200 bg-zinc-50 pt-2 md:hidden dark:border-zinc-800 dark:bg-zinc-900"
>
{links} {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 <Button
variant="ghost" variant="ghost"
className="w-full justify-start" className="w-full justify-start"
@@ -148,7 +214,7 @@ export function ComingSoon({ label, milestone }: { label: string; milestone: str
return ( return (
<section> <section>
<h1 className="mb-2 text-xl font-semibold">{label}</h1> <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> </section>
); );
} }
+4 -4
View File
@@ -28,7 +28,7 @@ export function BlockedPage() {
return ( return (
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Blocked numbers</h1> <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 Calls from these numbers are rejected before any phone rings. Emergency calls you make are
never affected. never affected.
</p> </p>
@@ -67,13 +67,13 @@ export function BlockedPage() {
</Button> </Button>
</form> </form>
{(add.error ?? remove.error) && <Alert>{(add.error ?? remove.error)!.message}</Alert>} {(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>} {list.data?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>}
{list.data && list.data.length > 0 && ( {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) => ( {list.data.map((b) => (
<li key={b.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm"> <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="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 <Button
variant="ghost" variant="ghost"
aria-label={`Unblock ${formatNumber(b.number)}`} aria-label={`Unblock ${formatNumber(b.number)}`}
+13 -11
View File
@@ -11,6 +11,7 @@ import {
type CallFilter, type CallFilter,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import { callsApi, extensionsApi, recordingsApi } from '../../api/client.ts'; import { callsApi, extensionsApi, recordingsApi } from '../../api/client.ts';
import { SpamNote } from '../../components/SpamNote.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
export const fmtNumber = (n: string | null) => export const fmtNumber = (n: string | null) =>
@@ -20,10 +21,10 @@ export const fmtDuration = (s: number) =>
export const STATUS_LOOK: Record<CallDto['status'], string> = { export const STATUS_LOOK: Record<CallDto['status'], string> = {
answered: 'bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100', 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', 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', 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', busy: 'bg-line text-fg',
failed: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200', failed: 'bg-line text-fg',
}; };
export const DIRECTION_TEXT = { inbound: 'In', outbound: 'Out', internal: 'Internal' }; export const DIRECTION_TEXT = { inbound: 'In', outbound: 'Out', internal: 'Internal' };
@@ -70,7 +71,7 @@ export function CallsPage() {
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Call history</h1> <h1 className="text-xl font-semibold">Call history</h1>
<a <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)} href={callsApi.csvUrl(filter)}
> >
Export CSV Export CSV
@@ -147,10 +148,10 @@ export function CallsPage() {
</div> </div>
</form> </form>
{list.isError && <Alert>{list.error.message}</Alert>} {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 <ul
aria-label="Calls" 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) => ( {calls.map((c) => (
<li key={c.id} className="flex flex-col gap-2 px-3 py-2 text-sm"> <li key={c.id} className="flex flex-col gap-2 px-3 py-2 text-sm">
@@ -160,16 +161,17 @@ export function CallsPage() {
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-left" className="flex flex-wrap items-center gap-x-3 gap-y-1 text-left"
onClick={() => setOpen(open === c.id ? null : c.id)} 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="w-14">{DIRECTION_TEXT[c.direction]}</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="font-medium">{c.fromName ?? fmtNumber(c.fromNumber)}</span> <span className="font-medium">{c.fromName ?? fmtNumber(c.fromNumber)}</span>
{c.fromName && ( {c.fromName && (
<span className="text-zinc-500"> {fmtNumber(c.fromNumber)}</span> <span className="text-muted"> {fmtNumber(c.fromNumber)}</span>
)} → {fmtNumber(c.toNumber)} )} → {fmtNumber(c.toNumber)}
{c.answeredBy && ( {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> </span>
<StatusBadge status={c.status} /> <StatusBadge status={c.status} />
<span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span> <span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span>
@@ -200,12 +202,12 @@ export function CallsPage() {
function Legs({ id }: { id: string }) { function Legs({ id }: { id: string }) {
const d = useQuery({ queryKey: ['call', id], queryFn: () => callsApi.get(id) }); 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 ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<caption className="sr-only">Call legs</caption> <caption className="sr-only">Call legs</caption>
<thead className="text-left text-zinc-500"> <thead className="text-left text-muted">
<tr> <tr>
<th className="pr-3 font-normal">Time</th> <th className="pr-3 font-normal">Time</th>
<th className="pr-3 font-normal">From channel</th> <th className="pr-3 font-normal">From channel</th>
+3 -3
View File
@@ -35,13 +35,13 @@ export function ConferencesPage() {
</div> </div>
{rooms.isError && <Alert>{rooms.error.message}</Alert>} {rooms.isError && <Alert>{rooms.error.message}</Alert>}
{rooms.data?.length === 0 && ( {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 No conference rooms yet. A room is a number (like 800) that several people dial to talk
together, with an optional PIN. together, with an optional PIN.
</p> </p>
)} )}
{rooms.data && rooms.data.length > 0 && ( {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) => ( {rooms.data.map((c) => (
<li <li
key={c.id} key={c.id}
@@ -49,7 +49,7 @@ export function ConferencesPage() {
> >
<span className="font-mono">{c.number}</span> <span className="font-mono">{c.number}</span>
<span className="font-medium">{c.name}</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.pin ? `PIN ${c.pin}` : 'No PIN'}
{c.adminPin ? ` · admin PIN ${c.adminPin}` : ''} · up to {c.maxMembers} {c.adminPin ? ` · admin PIN ${c.adminPin}` : ''} · up to {c.maxMembers}
{c.record ? ' · recorded' : ''} {c.record ? ' · recorded' : ''}
+36 -21
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer) // 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.
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall, QueueLive } from '@selfphoneng/shared'; import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts'; import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx'; import { ConfirmDialog } from '../../components/Dialog.tsx';
@@ -15,8 +15,8 @@ import { ParkedCalls } from '../../components/ParkedCalls.tsx';
function Card({ title, children }: { title: string; children: ReactNode }) { function Card({ title, children }: { title: string; children: ReactNode }) {
return ( return (
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"> <div className="rounded-xl border border-line bg-surface shadow-sm p-4">
<h2 className="text-sm text-zinc-500">{title}</h2> <h2 className="text-sm text-muted">{title}</h2>
<div className="mt-1 text-2xl font-semibold">{children}</div> <div className="mt-1 text-2xl font-semibold">{children}</div>
</div> </div>
); );
@@ -55,7 +55,7 @@ export function DashboardPage() {
<Card title="Telephony engine"> <Card title="Telephony engine">
<span <span
className={ 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'} {status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
@@ -72,19 +72,19 @@ export function DashboardPage() {
<div> <div>
<h2 className="mb-2 font-medium">Extensions</h2> <h2 className="mb-2 font-medium">Extensions</h2>
{list.length === 0 ? ( {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. No extensions yet. Give a user a number under Users & Extensions.
</p> </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) => ( {list.map((e) => (
<li key={e.number} className="flex items-center gap-3 px-3 py-2 text-sm"> <li key={e.number} className="flex items-center gap-3 px-3 py-2 text-sm">
<StatusDot ext={e} /> <StatusDot ext={e} />
<span className="w-14 font-mono">{e.number}</span> <span className="w-14 font-mono">{e.number}</span>
<span className="flex-1 truncate">{e.name}</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 && ( {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'} {e.contacts.length} device{e.contacts.length === 1 ? '' : 's'}
</span> </span>
)} )}
@@ -118,15 +118,12 @@ function QueueTiles() {
null, null,
); );
return ( return (
<li <li key={q.number} className="rounded-xl border border-line bg-surface shadow-sm p-4">
key={q.number} <Link className="text-sm text-muted hover:underline" to={`queues/${q.id}`}>
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}`}>
{q.number} {q.name} {q.number} {q.name}
</Link> </Link>
<div className="mt-1 text-2xl font-semibold">{q.waiting.length} waiting</div> <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}{' '} Longest {oldest ? since(oldest, now) : '—'} · {available(q)} of {q.members.length}{' '}
agents free agents free
</div> </div>
@@ -144,7 +141,12 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
queryFn: settingsApi.warnings, queryFn: settingsApi.warnings,
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
const items = [ const qc = useQueryClient();
const dismiss = useMutation({
mutationFn: settingsApi.dismissWarning,
onSuccess: () => qc.invalidateQueries({ queryKey: ['warnings'] }),
});
const items: Warning[] = [
...(trunkDown ...(trunkDown
? [ ? [
{ {
@@ -168,6 +170,19 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
<Link to={w.link} className="font-medium underline"> <Link to={w.link} className="font-medium underline">
Fix Fix
</Link> </Link>
{w.dismissable && (
<>
{' '}
<button
type="button"
className="font-medium underline"
disabled={dismiss.isPending}
onClick={() => dismiss.mutate(w.code)}
>
Dismiss
</button>
</>
)}
</li> </li>
))} ))}
</ul> </ul>
@@ -240,11 +255,11 @@ function LiveCalls() {
</p> </p>
)} )}
{calls.length === 0 ? ( {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 <ul
aria-label="Calls now" 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) => ( {calls.map((c) => (
<li <li
@@ -254,15 +269,15 @@ function LiveCalls() {
> >
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="font-medium">{c.from.name ?? fmtNumber(c.from.number)}</span> <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)} {fmtNumber(c.to.number)}
<span className="text-zinc-500"> <span className="text-muted">
{' '} {' '}
· {STATE_TEXT[c.state]} {since(c.answeredAt ?? c.startedAt, now)} · {STATE_TEXT[c.state]} {since(c.answeredAt ?? c.startedAt, now)}
{c.recording && ' · recording'} {c.recording && ' · recording'}
</span> </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(' ')} {c.channels.join(' ')}
</span> </span>
</span> </span>
+7 -7
View File
@@ -42,14 +42,14 @@ export function IvrsPage() {
</div> </div>
{list.isError && <Alert>{list.error.message}</Alert>} {list.isError && <Alert>{list.error.message}</Alert>}
{list.data?.length === 0 && ( {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 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 them where they choose. Upload the greeting under Sounds &amp; music, or right in the
form. form.
</p> </p>
)} )}
{list.data && list.data.length > 0 && ( {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) => ( {list.data.map((r) => (
<li <li
key={r.id} key={r.id}
@@ -57,7 +57,7 @@ export function IvrsPage() {
> >
<span className="font-mono">{r.number}</span> <span className="font-mono">{r.number}</span>
<span className="font-medium">{r.name}</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'} {r.options.map((o) => `${o.digit}: ${o.summary}`).join(' · ') || 'No keys yet'}
</span> </span>
<Button onClick={() => setEditing(r)}>Edit</Button> <Button onClick={() => setEditing(r)}>Edit</Button>
@@ -230,10 +230,10 @@ function IvrForm({
aria-label={`Key ${d}`} aria-label={`Key ${d}`}
aria-pressed={selected === d} aria-pressed={selected === d}
onClick={() => setSelected(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="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'} {dest ? describe(dest) : dest === null ? 'Not finished' : 'Unused'}
</span> </span>
</button> </button>
@@ -242,7 +242,7 @@ function IvrForm({
</div> </div>
{errors.options && <Alert>{errors.options}</Alert>} {errors.options && <Alert>{errors.options}</Alert>}
{selected && ( {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 ? ( {keys[selected] === undefined ? (
<Button onClick={() => setKeys((k) => ({ ...k, [selected]: { type: 'hangup' } }))}> <Button onClick={() => setKeys((k) => ({ ...k, [selected]: { type: 'hangup' } }))}>
Use key {selected} Use key {selected}
@@ -281,7 +281,7 @@ function IvrForm({
/> />
<span> <span>
Callers may dial an extension directly (e.g. 101) 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. A key that is also the first digit of an extension waits 3 seconds for more digits.
</span> </span>
</span> </span>
+7 -7
View File
@@ -37,26 +37,26 @@ export function NumbersPage() {
</Button> </Button>
</div> </div>
{noTrunk && ( {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.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 && ( {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. No numbers yet. Add the numbers (DIDs) you bought at voip.ms.
</p> </p>
)} )}
{dids.data && dids.data.length > 0 && ( {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) => ( {dids.data.map((d) => (
<li <li
key={d.id} key={d.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm" 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> <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="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 onClick={() => setEditing(d)}>Edit</Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -235,7 +235,7 @@ function DidDrawer({
<input type="checkbox" className="mt-1" {...form.register('recordInbound')} /> <input type="checkbox" className="mt-1" {...form.register('recordInbound')} />
<span> <span>
Record every call to this number 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. Callers hear the recording notice from Settings → Recording first, if you set one.
Check the consent rules where you and your callers are. Check the consent rules where you and your callers are.
</span> </span>
+17 -16
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 // 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. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { import {
@@ -36,11 +37,11 @@ const rangeText = (r: OpenRange) =>
export function OfficeHoursPage() { export function OfficeHoursPage() {
const [tab, setTab] = useState<'hours' | 'holidays'>('hours'); const [tab, setTab] = useState<'hours' | 'holidays'>('hours');
const tabClass = (on: boolean) => 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 ( return (
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Office hours</h1> <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 <button
role="tab" role="tab"
aria-selected={tab === 'hours'} aria-selected={tab === 'hours'}
@@ -108,7 +109,7 @@ function HoursTab() {
{list.isError && <Alert>{list.error.message}</Alert>} {list.isError && <Alert>{list.error.message}</Alert>}
{override.error && <Alert>{override.error.message}</Alert>} {override.error && <Alert>{override.error.message}</Alert>}
{list.data?.length === 0 && ( {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 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. go one way when you're open and another when you're closed.
</p> </p>
@@ -117,13 +118,13 @@ function HoursTab() {
<article <article
key={tc.id} key={tc.id}
aria-label={tc.name} 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"> <div className="flex flex-wrap items-center gap-3">
<h2 className="flex-1 font-semibold">{tc.name}</h2> <h2 className="flex-1 font-semibold">{tc.name}</h2>
<StatusChip tc={tc} /> <StatusChip tc={tc} />
</div> </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.openRanges.map(rangeText).join('; ') || 'Never open'} · {tc.timezone}
{tc.useHolidays ? ' · closed on holidays' : ''} {tc.useHolidays ? ' · closed on holidays' : ''}
{tc.overrideDigit !== null && {tc.overrideDigit !== null &&
@@ -199,7 +200,7 @@ function WeekGrid({
onPointerUp={() => setPaint(null)} onPointerUp={() => setPaint(null)}
onPointerLeave={() => 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 /> <span />
{Array.from({ length: SLOTS }, (_, i) => ( {Array.from({ length: SLOTS }, (_, i) => (
<span key={i} className="text-center"> <span key={i} className="text-center">
@@ -213,7 +214,7 @@ function WeekGrid({
<span <span
key={i} key={i}
data-cell={`${d}-${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) => { onPointerDown={(e) => {
// Touch captures the pointer to this cell; let go so dragging paints the others. // Touch captures the pointer to this cell; let go so dragging paints the others.
const el = e.target as Element; const el = e.target as Element;
@@ -254,14 +255,14 @@ function RangeList({
aria-label={`Remove ${rangeText(r)}`} aria-label={`Remove ${rangeText(r)}`}
onClick={() => onChange(ranges.filter((_, j) => j !== i))} onClick={() => onChange(ranges.filter((_, j) => j !== i))}
> >
✕ <X className="size-4" />
</Button> </Button>
</li> </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> </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"> <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-zinc-500">Add hours</legend> <legend className="px-1 text-xs text-muted">Add hours</legend>
{DAYS.map((d) => ( {DAYS.map((d) => (
<label key={d} className="flex items-center gap-1 text-sm"> <label key={d} className="flex items-center gap-1 text-sm">
<input <input
@@ -417,7 +418,7 @@ function HoursForm({
</Field> </Field>
<fieldset className="flex min-w-0 flex-col gap-2"> <fieldset className="flex min-w-0 flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Open hours</legend> <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} /> <WeekGrid ranges={ranges} onChange={setRanges} />
{errors.openRanges && <Alert>{errors.openRanges}</Alert>} {errors.openRanges && <Alert>{errors.openRanges}</Alert>}
<RangeList ranges={ranges} onChange={setRanges} /> <RangeList ranges={ranges} onChange={setRanges} />
@@ -544,18 +545,18 @@ function HolidaysTab() {
Add US federal holidays for {year} Add US federal holidays for {year}
</Button> </Button>
{importUs.data && ( {importUs.data && (
<span role="status" className="text-sm text-zinc-500"> <span role="status" className="text-sm text-muted">
Added {importUs.data.added}. Added {importUs.data.added}.
</span> </span>
)} )}
</div> </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 && ( {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) => ( {list.data.map((h) => (
<li key={h.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm"> <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="flex-1 font-medium">{h.name}</span>
<span className="text-zinc-500"> <span className="text-muted">
{fmt(h.startDate)} {fmt(h.startDate)}
{h.endDate !== h.startDate && ` – ${fmt(h.endDate)}`} {h.endDate !== h.startDate && ` – ${fmt(h.endDate)}`}
{h.recurringYearly && ' · every year'} {h.recurringYearly && ' · every year'}
+17 -18
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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. // 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. // 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 { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
@@ -56,19 +57,17 @@ export function OutboundPage() {
New rule New rule
</Button> </Button>
</div> </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 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. call goes out. Put more specific rules (like toll-free) above general ones.
</p> </p>
{routes.isError && <Alert>{routes.error.message}</Alert>} {routes.isError && <Alert>{routes.error.message}</Alert>}
{move.isError && <Alert>{move.error.message}</Alert>} {move.isError && <Alert>{move.error.message}</Alert>}
{routes.data?.length === 0 && ( {routes.data?.length === 0 && (
<p className="text-sm text-zinc-500"> <p className="text-sm text-muted">No rules yet. They are created with your first trunk.</p>
No rules yet. They are created with your first trunk.
</p>
)} )}
{routes.data && routes.data.length > 0 && ( {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) => ( {routes.data.map((r, i) => (
<li <li
key={r.id} key={r.id}
@@ -82,7 +81,7 @@ export function OutboundPage() {
disabled={i === 0 || move.isPending} disabled={i === 0 || move.isPending}
onClick={() => move.mutate({ from: i, to: i - 1 })} onClick={() => move.mutate({ from: i, to: i - 1 })}
> >
↑ <ArrowUp className="size-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -91,18 +90,18 @@ export function OutboundPage() {
disabled={i === routes.data.length - 1 || move.isPending} disabled={i === routes.data.length - 1 || move.isPending}
onClick={() => move.mutate({ from: i, to: i + 1 })} onClick={() => move.mutate({ from: i, to: i + 1 })}
> >
↓ <ArrowDown className="size-4" />
</Button> </Button>
</span> </span>
<span className="w-5 text-zinc-500">{i + 1}.</span> <span className="w-5 text-muted">{i + 1}.</span>
<span className={`font-medium ${r.enabled ? '' : 'text-zinc-400 line-through'}`}> <span className={`font-medium ${r.enabled ? '' : 'text-muted line-through'}`}>
{r.name} {r.name}
</span> </span>
{!r.enabled && <span className="text-xs text-zinc-500">off</span>} {!r.enabled && <span className="text-xs text-muted">off</span>}
<span className="flex-1 truncate font-mono text-xs text-zinc-500"> <span className="flex-1 truncate font-mono text-xs text-muted">
{r.patterns.map((p) => p.match).join(' ')} {r.patterns.map((p) => p.match).join(' ')}
</span> </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(' → ')} {CLASS_LABEL[r.requiredClass]} · {r.trunkIds.map(trunkName).join(' → ')}
</span> </span>
<Button onClick={() => setEditing(r)}>Edit</Button> <Button onClick={() => setEditing(r)}>Edit</Button>
@@ -162,7 +161,7 @@ function TestBox() {
if (!exts.data?.length) return null; if (!exts.data?.length) return null;
return ( return (
<form <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) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (number.trim()) test.mutate(); if (number.trim()) test.mutate();
@@ -303,7 +302,7 @@ function RouteDrawer({
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Patterns</legend> <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> <summary className="cursor-pointer">Pattern help</summary>
<p className="mt-1"> <p className="mt-1">
<code>X</code> any digit, <code>Z</code> 1–9, <code>N</code> 2–9, <code>[1-5]</code>{' '} <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)} onClick={() => patterns.remove(i)}
disabled={patterns.fields.length === 1} disabled={patterns.fields.length === 1}
> >
✕ <X className="size-4" />
</Button> </Button>
</div> </div>
))} ))}
@@ -375,7 +374,7 @@ function RouteDrawer({
<ol className="flex flex-col gap-1"> <ol className="flex flex-col gap-1">
{chosen.map((id, i) => ( {chosen.map((id, i) => (
<li key={id} className="flex items-center gap-2 text-sm"> <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> <span className="flex-1">{trunks.find((t) => t.id === id)?.name ?? id}</span>
<Button <Button
variant="ghost" variant="ghost"
@@ -387,14 +386,14 @@ function RouteDrawer({
setTrunks(ids); setTrunks(ids);
}} }}
> >
↑ <ArrowUp className="size-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
aria-label="Remove trunk" aria-label="Remove trunk"
onClick={() => setTrunks(chosen.filter((x) => x !== id))} onClick={() => setTrunks(chosen.filter((x) => x !== id))}
> >
✕ <X className="size-4" />
</Button> </Button>
</li> </li>
))} ))}
+4 -4
View File
@@ -38,14 +38,14 @@ export function PagingPage() {
</div> </div>
{groups.isError && <Alert>{groups.error.message}</Alert>} {groups.isError && <Alert>{groups.error.message}</Alert>}
{groups.data?.length === 0 && ( {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 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, answer by themselves, so everyone near them hears you. To talk to one desk phone this way,
dial *80 and its extension (like *80101). dial *80 and its extension (like *80101).
</p> </p>
)} )}
{groups.data && groups.data.length > 0 && ( {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) => ( {groups.data.map((g) => (
<li <li
key={g.id} key={g.id}
@@ -53,7 +53,7 @@ export function PagingPage() {
> >
<span className="font-mono">{g.number}</span> <span className="font-mono">{g.number}</span>
<span className="font-medium">{g.name}</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.duplex ? 'Two-way' : 'One-way'}:{' '}
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'} {g.memberIds.map(nameOf).join(', ') || 'nobody yet'}
</span> </span>
@@ -193,7 +193,7 @@ function PagingForm({
{x.number} {x.name} {x.number} {x.name}
</label> </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 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. skipped. Most Yealink, Grandstream, Polycom and Snom phones answer pages by themselves.
</p> </p>
+13 -19
View File
@@ -47,7 +47,7 @@ export function PhonesPage() {
Add phone Add phone
</Button> </Button>
</div> </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: Add a desk phone by its MAC address (on a sticker under the phone) and it sets itself up:
account, voicemail and pickup keys. account, voicemail and pickup keys.
</p> </p>
@@ -58,11 +58,11 @@ export function PhonesPage() {
</Alert> </Alert>
)} )}
{phones.isError && <Alert>{phones.error.message}</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 && ( {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"> <table className="w-full text-left text-sm">
<thead className="text-zinc-500"> <thead className="text-muted">
<tr> <tr>
<th className="px-3 py-2 font-medium">MAC</th> <th className="px-3 py-2 font-medium">MAC</th>
<th className="px-3 py-2 font-medium">Phone</th> <th className="px-3 py-2 font-medium">Phone</th>
@@ -71,18 +71,18 @@ export function PhonesPage() {
<th className="px-3 py-2" /> <th className="px-3 py-2" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody className="divide-y divide-line">
{phones.data.map((d) => ( {phones.data.map((d) => (
<tr key={d.id}> <tr key={d.id}>
<td className="px-3 py-2 font-mono">{fmtMac(d.mac)}</td> <td className="px-3 py-2 font-mono">{fmtMac(d.mac)}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{VENDOR[d.vendor].replace(' (set up by hand)', '')} {d.model} {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>
<td className="px-3 py-2"> <td className="px-3 py-2">
{d.extensionNumber} {d.extensionName} {d.extensionNumber} {d.extensionName}
</td> </td>
<td className="px-3 py-2 text-zinc-500"> <td className="px-3 py-2 text-muted">
{d.lastProvisionedAt {d.lastProvisionedAt
? `${new Date(d.lastProvisionedAt).toLocaleString()} from ${d.lastProvisionedIp}` ? `${new Date(d.lastProvisionedAt).toLocaleString()} from ${d.lastProvisionedIp}`
: 'Never'} : 'Never'}
@@ -248,7 +248,7 @@ function PhoneDrawer({
)} )}
</Field> </Field>
{form.vendor === 'grandstream' && ( {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 Grandstream line keys differ by model, so busy-lamp keys aren't set: add them on the
phone. phone.
</p> </p>
@@ -273,16 +273,13 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
}); });
const row = (k: string, v: string) => ( const row = (k: string, v: string) => (
<div className="contents"> <div className="contents">
<dt className="text-zinc-500">{k}</dt> <dt className="text-muted">{k}</dt>
<dd className="font-mono break-all">{v}</dd> <dd className="font-mono break-all">{v}</dd>
</div> </div>
); );
if (phone.vendor === 'generic') if (phone.vendor === 'generic')
return ( return (
<section <section aria-label="Manual settings" className="rounded-md bg-subtle p-3 text-sm">
aria-label="Manual settings"
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
>
<p className="mb-2">Enter these on the phone:</p> <p className="mb-2">Enter these on the phone:</p>
{creds.data && ( {creds.data && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1"> <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
@@ -298,10 +295,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
</section> </section>
); );
return ( return (
<section <section aria-label="Set up the phone" className="rounded-md bg-subtle p-3 text-sm">
aria-label="Set up the phone"
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
>
<p className="mb-2"> <p className="mb-2">
In the phone's web page (Settings → Auto Provision), or with DHCP option 66, set: In the phone's web page (Settings → Auto Provision), or with DHCP option 66, set:
</p> </p>
@@ -310,7 +304,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
{row('Username', phone.mac)} {row('Username', phone.mac)}
{row('Password', phone.provisioningPassword)} {row('Password', phone.provisioningPassword)}
</dl> </dl>
<p className="mt-2 text-zinc-500"> <p className="mt-2 text-muted">
Then reboot the phone.{' '} Then reboot the phone.{' '}
{phone.lastProvisionedAt {phone.lastProvisionedAt
? 'It last fetched its settings ' + ? 'It last fetched its settings ' +
@@ -327,7 +321,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
{preview && ( {preview && (
<pre <pre
aria-label="Config preview" 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} {preview}
</pre> </pre>
+21 -19
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 // 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. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
@@ -59,13 +60,13 @@ function QueueList() {
</div> </div>
{queues.isError && <Alert>{queues.error.message}</Alert>} {queues.isError && <Alert>{queues.error.message}</Alert>}
{queues.data?.length === 0 && ( {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 No queues yet. In a queue (like 550 for Support) callers wait with music until one of the
agents is free. agents is free.
</p> </p>
)} )}
{queues.data && queues.data.length > 0 && ( {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) => { {queues.data.map((q) => {
const l = live[q.number]; const l = live[q.number];
return ( return (
@@ -77,7 +78,7 @@ function QueueList() {
<Link className="font-medium underline-offset-2 hover:underline" to={q.id}> <Link className="font-medium underline-offset-2 hover:underline" to={q.id}>
{q.name} {q.name}
</Link> </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'} {q.members.length} agent{q.members.length === 1 ? '' : 's'}
{l ? ` · ${available(l)} available · ${l.waiting.length} waiting` : ''} {l ? ` · ${available(l)} available · ${l.waiting.length} waiting` : ''}
</span> </span>
@@ -152,13 +153,14 @@ function QueueDetail({ id }: { id: string }) {
return ( return (
<section className="flex flex-col gap-6"> <section className="flex flex-col gap-6">
<div> <div>
<Link className="text-sm text-zinc-500 hover:underline" to=".."> <Link className="inline-flex items-center gap-1 text-sm text-muted hover:underline" to="..">
← Queues <ArrowLeft className="size-4" />
Queues
</Link> </Link>
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
<span className="font-mono">{q.number}</span> {q.name} <span className="font-mono">{q.number}</span> {q.name}
</h1> </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} {STRATEGY_LABEL[q.strategy]} · waits up to {q.maxWaitSeconds} s, then {q.timeoutSummary}
</p> </p>
</div> </div>
@@ -166,18 +168,18 @@ function QueueDetail({ id }: { id: string }) {
<div> <div>
<h2 className="mb-2 font-medium">Waiting</h2> <h2 className="mb-2 font-medium">Waiting</h2>
{!live?.waiting.length ? ( {!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 <ol
aria-label="Waiting callers" 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) => ( {live.waiting.map((w, i) => (
<li key={w.linkedid} className="flex items-center gap-3 px-3 py-2 text-sm"> <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"> <span className="flex-1">
{w.callerName ? `${w.callerName} ` : ''} {w.callerName ? `${w.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(w.callerNumber)}</span> <span className="text-muted">{fmtNumber(w.callerNumber)}</span>
</span> </span>
<span className="font-mono tabular-nums">{since(w.since, now)}</span> <span className="font-mono tabular-nums">{since(w.since, now)}</span>
</li> </li>
@@ -190,11 +192,11 @@ function QueueDetail({ id }: { id: string }) {
<h2 className="mb-2 font-medium">Agents</h2> <h2 className="mb-2 font-medium">Agents</h2>
{toggle.error && <Alert>{toggle.error.message}</Alert>} {toggle.error && <Alert>{toggle.error.message}</Alert>}
{q.members.length === 0 ? ( {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 <ul
aria-label="Agents" 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) => { {q.members.map((m) => {
const e = exts.data?.find((x) => x.id === m.extensionId); 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="w-12 font-mono">{e?.number}</span>
<span className="min-w-0 flex-1 truncate">{e?.name}</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']} {paused ? 'Paused' : STATE_LABEL[l?.state ?? 'unavailable']}
{l ? ` · ${l.callsTaken} call${l.callsTaken === 1 ? '' : 's'}` : ''} {l ? ` · ${l.callsTaken} call${l.callsTaken === 1 ? '' : 's'}` : ''}
</span> </span>
@@ -222,7 +224,7 @@ function QueueDetail({ id }: { id: string }) {
})} })}
</ul> </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}. Agents can also log in and out from a phone with *45{q.number}.
</p> </p>
</div> </div>
@@ -246,8 +248,8 @@ function QueueDetail({ id }: { id: string }) {
], ],
] as const ] as const
).map(([k, v]) => ( ).map(([k, v]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"> <div key={k} className="rounded-xl border border-line bg-surface shadow-sm p-3">
<dt className="text-zinc-500">{k}</dt> <dt className="text-muted">{k}</dt>
<dd className="text-lg font-semibold">{v ?? '…'}</dd> <dd className="text-lg font-semibold">{v ?? '…'}</dd>
</div> </div>
))} ))}
@@ -418,7 +420,7 @@ function QueueForm({
{members.map((m) => ( {members.map((m) => (
<li key={m.extensionId} className="flex items-center gap-2 text-sm"> <li key={m.extensionId} className="flex items-center gap-2 text-sm">
<span className="flex-1">{label(m.extensionId)}</span> <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 Priority
<select <select
aria-label={`Priority of ${label(m.extensionId)}`} aria-label={`Priority of ${label(m.extensionId)}`}
@@ -449,7 +451,7 @@ function QueueForm({
setMembers((ms) => ms.filter((x) => x.extensionId !== m.extensionId)) setMembers((ms) => ms.filter((x) => x.extensionId !== m.extensionId))
} }
> >
✕ <X className="size-4" />
</Button> </Button>
</li> </li>
))} ))}
@@ -472,7 +474,7 @@ function QueueForm({
))} ))}
</select> </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. Priority 0 gets calls first; higher numbers only when the lower ones are all busy.
</p> </p>
</fieldset> </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 }) { function Transcript({ id }: { id: string }) {
const t = useQuery({ queryKey: ['transcript', id], queryFn: () => recordingsApi.transcript(id) }); const t = useQuery({ queryKey: ['transcript', id], queryFn: () => recordingsApi.transcript(id) });
if (t.error) return <Alert>{t.error.message}</Alert>; 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 ( return (
<ol aria-label="Transcript" className="mt-1 flex flex-col gap-1"> <ol aria-label="Transcript" className="mt-1 flex flex-col gap-1">
{t.data.lines.map((l, i) => ( {t.data.lines.map((l, i) => (
<li key={i} className="flex gap-2"> <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"> <span className="w-20 shrink-0 font-medium">
{l.side === 'caller' ? 'Caller' : 'Other side'} {l.side === 'caller' ? 'Caller' : 'Other side'}
</span> </span>
<span>{l.text}</span> <span>{l.text}</span>
</li> </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> </ol>
); );
} }
@@ -55,7 +55,7 @@ export function RecordingsPage() {
return ( return (
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Recordings</h1> <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 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 kept is under Settings → Recording. With call transcripts on (Settings → AI), each call is
written out a minute or two after it ends. written out a minute or two after it ends.
@@ -90,21 +90,21 @@ export function RecordingsPage() {
</form> </form>
{list.isError && <Alert>{list.error.message}</Alert>} {list.isError && <Alert>{list.error.message}</Alert>}
{list.data && items.length === 0 && ( {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 <ul
aria-label="Recordings" 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) => ( {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"> <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"> <span className="min-w-0 flex-1">
{r.call {r.call
? `${r.call.fromName ?? fmtNumber(r.call.fromNumber)} → ${fmtNumber(r.call.toNumber)}` ? `${r.call.fromName ?? fmtNumber(r.call.fromNumber)} → ${fmtNumber(r.call.toNumber)}`
: 'Call not found'} : 'Call not found'}
{r.durationMs !== null && ( {r.durationMs !== null && (
<span className="text-zinc-500"> <span className="text-muted">
{' '} {' '}
· {fmtDuration(Math.round(r.durationMs / 1000))} · {fmtDuration(Math.round(r.durationMs / 1000))}
</span> </span>
@@ -119,7 +119,7 @@ export function RecordingsPage() {
/> />
<a <a
href={recordingsApi.audioUrl(r.id, true)} 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 Download
</a> </a>
@@ -127,15 +127,12 @@ export function RecordingsPage() {
Delete Delete
</Button> </Button>
{r.summary && ( {r.summary && (
<p <p aria-label="Summary" className="w-full whitespace-pre-line text-fg">
aria-label="Summary"
className="w-full whitespace-pre-line text-zinc-700 dark:text-zinc-300"
>
{r.summary} {r.summary}
</p> </p>
)} )}
{r.aiError && ( {r.aiError && (
<p className="w-full text-xs text-zinc-500"> <p className="w-full text-xs text-muted">
{r.hasTranscript ? '' : 'No transcript: '} {r.hasTranscript ? '' : 'No transcript: '}
{r.aiError} {r.aiError}
</p> </p>
+14 -14
View File
@@ -45,14 +45,14 @@ function Bars({
{label} {label}
{segments.length > 1 && {segments.length > 1 &&
segments.map(([k, text, color]) => ( 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}`} /> <span className={`inline-block size-2.5 rounded-sm ${color}`} />
{text} {text}
</span> </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> </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) => ( {days.map((d) => (
<div <div
key={d.date} key={d.date}
@@ -67,7 +67,7 @@ function Bars({
</div> </div>
))} ))}
</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[0]!.date)}</span>
<span>{shortDate(days.at(-1)!.date)}</span> <span>{shortDate(days.at(-1)!.date)}</span>
</div> </div>
@@ -84,7 +84,7 @@ function Heatmap({ hours }: { hours: number[][] }) {
<tr> <tr>
<th /> <th />
{hours[0]!.map((_, h) => ( {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>} {h % 3 === 0 ? h : <span className="sr-only">{h}</span>}
</th> </th>
))} ))}
@@ -93,14 +93,14 @@ function Heatmap({ hours }: { hours: number[][] }) {
<tbody> <tbody>
{hours.map((row, d) => ( {hours.map((row, d) => (
<tr key={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]} {WEEKDAYS[d]}
</th> </th>
{row.map((n, h) => ( {row.map((n, h) => (
<td <td
key={h} key={h}
title={`${WEEKDAYS[d]} ${h}:00: ${n} calls`} 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={ style={
n ? { backgroundColor: `rgb(14 165 233 / ${0.15 + (0.85 * n) / max})` } : {} n ? { backgroundColor: `rgb(14 165 233 / ${0.15 + (0.85 * n) / max})` } : {}
} }
@@ -160,7 +160,7 @@ export function ReportsPage() {
Show Show
</Button> </Button>
{s && ( {s && (
<p className="text-sm text-zinc-500"> <p className="text-sm text-muted">
{shortDate(s.from)} – {shortDate(s.to)} {shortDate(s.from)} – {shortDate(s.to)}
{!range.from && !range.to && ' (last 30 days)'} {!range.from && !range.to && ' (last 30 days)'}
</p> </p>
@@ -197,10 +197,10 @@ export function ReportsPage() {
), ),
] as const ] as const
).map(([k, v, note]) => ( ).map(([k, v, note]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"> <div key={k} className="rounded-xl border border-line bg-surface shadow-sm p-3">
<dt className="text-zinc-500">{k}</dt> <dt className="text-muted">{k}</dt>
<dd className="text-lg font-semibold tabular-nums">{v}</dd> <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> </div>
))} ))}
</dl> </dl>
@@ -225,11 +225,11 @@ export function ReportsPage() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2> <h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>} {queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-zinc-500">No queues.</p>} {queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
{!!queues.data?.length && ( {!!queues.data?.length && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm"> <table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-zinc-500"> <thead className="text-muted">
<tr> <tr>
{[ {[
'Queue', 'Queue',
@@ -246,7 +246,7 @@ export function ReportsPage() {
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody className="divide-y divide-line">
{queues.data.map((q) => ( {queues.data.map((q) => (
<tr key={q.id} className="tabular-nums"> <tr key={q.id} className="tabular-nums">
<td className="px-2 py-1"> <td className="px-2 py-1">
+9 -8
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 // 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. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { import {
@@ -40,13 +41,13 @@ export function RingGroupsPage() {
</div> </div>
{groups.isError && <Alert>{groups.error.message}</Alert>} {groups.isError && <Alert>{groups.error.message}</Alert>}
{groups.data?.length === 0 && ( {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 No ring groups yet. A ring group is one number (like 500 for Sales) that rings several
people. people.
</p> </p>
)} )}
{groups.data && groups.data.length > 0 && ( {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) => ( {groups.data.map((g) => (
<li <li
key={g.id} key={g.id}
@@ -54,7 +55,7 @@ export function RingGroupsPage() {
> >
<span className="font-mono">{g.number}</span> <span className="font-mono">{g.number}</span>
<span className="font-medium">{g.name}</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.strategy === 'ringall' ? 'Everyone at once' : 'One at a time'}:{' '}
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'} · then {g.noAnswerSummary} {g.memberIds.map(nameOf).join(', ') || 'nobody yet'} · then {g.noAnswerSummary}
</span> </span>
@@ -238,7 +239,7 @@ function RingGroupForm({
<ol className="flex flex-col gap-1"> <ol className="flex flex-col gap-1">
{members.map((m, i) => ( {members.map((m, i) => (
<li key={m} className="flex items-center gap-2 text-sm"> <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> <span className="flex-1">{label(m)}</span>
<Button <Button
className="px-2 py-1" className="px-2 py-1"
@@ -246,7 +247,7 @@ function RingGroupForm({
disabled={i === 0} disabled={i === 0}
onClick={() => move(i, -1)} onClick={() => move(i, -1)}
> >
↑ <ArrowUp className="size-4" />
</Button> </Button>
<Button <Button
className="px-2 py-1" className="px-2 py-1"
@@ -254,7 +255,7 @@ function RingGroupForm({
disabled={i === members.length - 1} disabled={i === members.length - 1}
onClick={() => move(i, 1)} onClick={() => move(i, 1)}
> >
↓ <ArrowDown className="size-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -262,7 +263,7 @@ function RingGroupForm({
aria-label={`Remove ${label(m)}`} aria-label={`Remove ${label(m)}`}
onClick={() => setMembers((ms) => ms.filter((x) => x !== m))} onClick={() => setMembers((ms) => ms.filter((x) => x !== m))}
> >
✕ <X className="size-4" />
</Button> </Button>
</li> </li>
))} ))}
@@ -282,7 +283,7 @@ function RingGroupForm({
))} ))}
</select> </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. Members' call forwarding and voicemail don't apply here; Do not disturb does.
</p> </p>
</fieldset> </fieldset>
+324 -52
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Network (public IP watch), Security (phone ACL, provisioning, SIP TLS), Backups, // Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Network (public IP watch), Security (phone ACL, provisioning, SIP TLS), Backups,
// Webhooks, API tokens; AI (spec 10). // Webhooks, API tokens; AI (spec 10); Spam check (spec 14).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { NavLink, useParams } from 'react-router'; import { NavLink, useParams } from 'react-router';
@@ -43,6 +43,7 @@ const TABS = [
{ id: 'ai', label: 'AI' }, { id: 'ai', label: 'AI' },
{ id: 'contacts', label: 'Contacts' }, { id: 'contacts', label: 'Contacts' },
{ id: 'sign-in', label: 'Sign-in' }, { id: 'sign-in', label: 'Sign-in' },
{ id: 'spam', label: 'Spam check' },
] as const; ] as const;
export function SettingsPage() { export function SettingsPage() {
@@ -51,16 +52,13 @@ export function SettingsPage() {
return ( return (
<section className="flex max-w-2xl flex-col gap-4"> <section className="flex max-w-2xl flex-col gap-4">
<h1 className="text-xl font-semibold">Settings</h1> <h1 className="text-xl font-semibold">Settings</h1>
<nav <nav aria-label="Settings" className="flex flex-wrap gap-1 border-b border-line">
aria-label="Settings"
className="flex flex-wrap gap-1 border-b border-zinc-200 dark:border-zinc-800"
>
{TABS.map((t) => ( {TABS.map((t) => (
<NavLink <NavLink
key={t.id} key={t.id}
to={`/admin/settings/${t.id}`} to={`/admin/settings/${t.id}`}
className={({ isActive }) => 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} {t.label}
@@ -79,6 +77,7 @@ export function SettingsPage() {
{tab === 'tokens' && <TokensTab />} {tab === 'tokens' && <TokensTab />}
{settings.data && tab === 'ai' && <AiTab s={settings.data} />} {settings.data && tab === 'ai' && <AiTab s={settings.data} />}
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />} {settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
{settings.data && tab === 'spam' && <SpamTab s={settings.data} />}
{settings.data && tab === 'sign-in' && ( {settings.data && tab === 'sign-in' && (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<SignInTab s={settings.data} /> <SignInTab s={settings.data} />
@@ -126,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 The mail server used for emergency alerts and voicemail emails. Leave the server empty to
turn email off. turn email off.
</p> </p>
@@ -207,7 +206,7 @@ export function EmailTab({ s }: { s: SettingsDto }) {
</div> </div>
{s.smtp && ( {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"> <div className="flex items-end gap-2">
<Field label="Send a test email to"> <Field label="Send a test email to">
{(a) => ( {(a) => (
@@ -315,7 +314,7 @@ export function EmergencyTab({ s }: { s: SettingsDto }) {
</Field> </Field>
<fieldset className="flex flex-col gap-1"> <fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-sm font-medium">Ring these phones</legend> <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) => ( {exts.data?.map((x) => (
<label key={x.id} className="flex items-center gap-2 text-sm"> <label key={x.id} className="flex items-center gap-2 text-sm">
<input <input
@@ -338,7 +337,7 @@ export function EmergencyTab({ s }: { s: SettingsDto }) {
{test.isPending ? 'Testing…' : 'Test notifications'} {test.isPending ? 'Testing…' : 'Test notifications'}
</Button> </Button>
</div> </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 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. dials 911. Never test by calling 911.
</p> </p>
@@ -396,7 +395,7 @@ function RecordingTab({ s }: { s: SettingsDto }) {
onChange={setNotice} onChange={setNotice}
noneLabel="No notice" 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). Played to callers of phone numbers set to record (Phone numbers → Record every call).
</p> </p>
<Field label="Keep recordings for (days)" hint="0 keeps them forever"> <Field label="Keep recordings for (days)" hint="0 keeps them forever">
@@ -425,7 +424,7 @@ function RecordingTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save Save
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{save.error && <Alert>{save.error.message}</Alert>} {save.error && <Alert>{save.error.message}</Alert>}
</form> </form>
@@ -460,7 +459,7 @@ function NumberingTab({ s }: { s: SettingsDto }) {
save.mutate(Object.fromEntries(RANGES.map(([k]) => [k, v[k].map(Number)]))); 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 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 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 1 to 6 digits instead, for example <strong>1</strong> for the first user: SelfPhoneNG checks
@@ -469,8 +468,8 @@ function NumberingTab({ s }: { s: SettingsDto }) {
</p> </p>
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-2 text-sm"> <div className="grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-2 text-sm">
<span /> <span />
<span className="text-zinc-500">From</span> <span className="text-muted">From</span>
<span className="text-zinc-500">To</span> <span className="text-muted">To</span>
{RANGES.map(([k, label]) => ( {RANGES.map(([k, label]) => (
<Fragment key={k}> <Fragment key={k}>
<span>{label}</span> <span>{label}</span>
@@ -493,7 +492,7 @@ function NumberingTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save Save
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{save.error && <Alert>{save.error.message}</Alert>} {save.error && <Alert>{save.error.message}</Alert>}
</form> </form>
@@ -537,7 +536,7 @@ function NetworkTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={watch} onChange={(e) => setWatch(e.target.checked)} /> <input type="checkbox" checked={watch} onChange={(e) => setWatch(e.target.checked)} />
Re-check the public IP every 5 minutes (dynamic IP) Re-check the public IP every 5 minutes (dynamic IP)
</label> </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. 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. Asks api.ipify.org (or ifconfig.me) for the address.
</p> </p>
@@ -560,7 +559,7 @@ function NetworkTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save Save
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{(save.error ?? detect.error) && <Alert>{(save.error ?? detect.error)!.message}</Alert>} {(save.error ?? detect.error) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
</form> </form>
@@ -636,7 +635,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 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 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. address empty to turn that part off.
@@ -646,7 +645,7 @@ function AiTab({ s }: { s: SettingsDto }) {
return ( return (
<fieldset key={kind} className="flex flex-col gap-2"> <fieldset key={kind} className="flex flex-col gap-2">
<legend className="mb-1 font-medium">{title}</legend> <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"> <div className="flex flex-wrap gap-2">
<input <input
aria-label={`${title} address`} aria-label={`${title} address`}
@@ -698,7 +697,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={vmText} onChange={(e) => setVmText(e.target.checked)} /> <input type="checkbox" checked={vmText} onChange={(e) => setVmText(e.target.checked)} />
Voicemail transcripts (speech to text) Voicemail transcripts (speech to text)
</label> </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 New messages are written out before the voicemail email goes, and the text is in the email
and on the voicemail page. and on the voicemail page.
</p> </p>
@@ -706,7 +705,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={callText} onChange={(e) => setCallText(e.target.checked)} /> <input type="checkbox" checked={callText} onChange={(e) => setCallText(e.target.checked)} />
Call transcripts (speech to text) Call transcripts (speech to text)
</label> </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 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. apart, and can be searched on the Recordings page. Only calls recorded while this is on.
</p> </p>
@@ -723,7 +722,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save Save
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{save.error && <Alert>{save.error.message}</Alert>} {save.error && <Alert>{save.error.message}</Alert>}
</form> </form>
@@ -771,7 +770,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
> >
<h2 className="font-medium">Desk phones</h2> <h2 className="font-medium">Desk phones</h2>
{box(restrict, setRestrict, 'Only accept desk phones from the networks below')} {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 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: mobile network) can't sign in while this is on. Browser phones still work from anywhere:
they come through this website. they come through this website.
@@ -790,7 +789,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
)} )}
</Field> </Field>
<h2 className="font-medium">Outside calls at once</h2> <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 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 who set the forward. 911 is never limited. Callers over the limit hear "all circuits are
busy". 0 = no limit. busy". 0 = no limit.
@@ -840,7 +839,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
)} )}
</Field> </Field>
{box(tcpOn, setTcpOn, 'Accept SIP over TCP')} {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. For phones and trunks set to TCP. Phones set up by this server use UDP.
</p> </p>
<h2 className="font-medium">Encrypted SIP (TLS)</h2> <h2 className="font-medium">Encrypted SIP (TLS)</h2>
@@ -850,7 +849,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
: (cert.data?.error ?? '…')} : (cert.data?.error ?? '…')}
</p> </p>
{box(tlsOn, setTlsOn, 'Accept SIP over TLS')} {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 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. restart. After renewing the certificate, run scripts/tls-cert.sh.
</p> </p>
@@ -869,7 +868,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save Save
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{save.error && <Alert>{save.error.message}</Alert>} {save.error && <Alert>{save.error.message}</Alert>}
</form> </form>
@@ -936,7 +935,7 @@ function BackupsTab({ s }: { s: SettingsDto }) {
)} )}
</Field> </Field>
{box(recs, setRecs, 'Include call recordings (they can be large)')} {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 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 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 .env: keep a copy of .env, or a backup can't be restored. To restore, run
@@ -946,7 +945,7 @@ function BackupsTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save Save
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{save.error && <Alert>{save.error.message}</Alert>} {save.error && <Alert>{save.error.message}</Alert>}
</form> </form>
@@ -958,16 +957,16 @@ function BackupsTab({ s }: { s: SettingsDto }) {
</Button> </Button>
</div> </div>
{now.error && <Alert>{now.error.message}</Alert>} {now.error && <Alert>{now.error.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No backups yet.</p>} {list.data?.length === 0 && <p className="text-sm text-muted">No backups yet.</p>}
<ul aria-label="Backups" className="divide-y divide-zinc-200 dark:divide-zinc-800"> <ul aria-label="Backups" className="divide-y divide-line">
{list.data?.map((b) => ( {list.data?.map((b) => (
<li key={b.name} className="flex items-center justify-between gap-3 py-2 text-sm"> <li key={b.name} className="flex items-center justify-between gap-3 py-2 text-sm">
<span> <span>
{new Date(b.createdAt).toLocaleString()}{' '} {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> </span>
<a <a
className="text-sky-700 hover:underline dark:text-sky-400" className="text-link hover:underline"
href={`/api/system/backups/${b.name}/download`} href={`/api/system/backups/${b.name}/download`}
download download
> >
@@ -1052,12 +1051,12 @@ function WebhooksTab({ s }: { s: SettingsDto }) {
/> />
Allow addresses on private networks (10.x, 192.168.x, localhost …) Allow addresses on private networks (10.x, 192.168.x, localhost …)
</label> </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 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). receiver in your own network (home lab).
</p> </p>
{save.error && <Alert>{save.error.message}</Alert>} {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 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 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 with the webhook&apos;s secret. A failed POST is tried again after 1 and 5 minutes, half an
@@ -1082,12 +1081,12 @@ function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
}); });
const remove = useMutation({ mutationFn: () => webhooksApi.remove(w.id), onSuccess: onChange }); const remove = useMutation({ mutationFn: () => webhooksApi.remove(w.id), onSuccess: onChange });
return ( 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"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium break-all">{w.url}</span> <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> </div>
<p className="text-zinc-500">{w.events.join(', ')}</p> <p className="text-muted">{w.events.join(', ')}</p>
<p> <p>
Secret: <code className="break-all">{w.secret}</code> Secret: <code className="break-all">{w.secret}</code>
</p> </p>
@@ -1111,7 +1110,7 @@ function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
</p> </p>
)} )}
{showLog && ( {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?.length === 0 && <li>Nothing sent yet.</li>}
{log.data?.map((d) => ( {log.data?.map((d) => (
<li key={d.id}> <li key={d.id}>
@@ -1193,19 +1192,19 @@ function TokensTab() {
</div> </div>
)} )}
</form> </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 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 admin can, except manage tokens. It stops working when revoked or when the admin is
disabled. disabled.
</p> </p>
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No tokens.</p>} {list.data?.length === 0 && <p className="text-sm text-muted">No tokens.</p>}
<ul aria-label="API tokens" className="divide-y divide-zinc-200 dark:divide-zinc-800"> <ul aria-label="API tokens" className="divide-y divide-line">
{list.data?.map((t) => ( {list.data?.map((t) => (
<li key={t.id} className="flex items-center justify-between gap-3 py-2 text-sm"> <li key={t.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<span> <span>
<span className="font-medium">{t.name}</span>{' '} <span className="font-medium">{t.name}</span>{' '}
<code className="text-zinc-500">{t.tokenPrefix}…</code> <code className="text-muted">{t.tokenPrefix}…</code>
<span className="block text-xs text-zinc-500"> <span className="block text-xs text-muted">
By {t.createdBy}, last used{' '} By {t.createdBy}, last used{' '}
{t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'} {t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'}
</span> </span>
@@ -1351,7 +1350,7 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
</span> </span>
)} )}
</div> </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 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 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. it off in Contrack, or point it at your local AI.
@@ -1373,6 +1372,279 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
); );
} }
/** Test for one spam check provider (14 §7.1): the typed key, else the saved one. */
function SpamTest({ provider, keyText }: { provider: 'ipqs' | 'ftc'; keyText: string }) {
const test = useMutation({
mutationFn: () => settingsApi.testSpam(provider, keyText || undefined),
});
return (
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => test.mutate()} disabled={test.isPending}>
Test
</Button>
{test.isError && <Alert>{test.error.message}</Alert>}
{test.data && (
<span
role="status"
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
>
{test.data.ok ? test.data.detail : test.data.error}
</span>
)}
</div>
);
}
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : null);
/** Where the FTC download is, and Download now (14 §2.2). */
function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) {
const qc = useQueryClient();
const sync = useMutation({
mutationFn: settingsApi.syncFtc,
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
});
return (
<div className="flex flex-col gap-1 text-sm">
<p className="text-muted">
{st.ftcCursor
? `Downloading: at the complaints of ${st.ftcCursor.day}.`
: st.ftcLastSyncAt
? `Last download ${when(st.ftcLastSyncAt)}.`
: 'Not downloaded yet: it starts within a minute.'}
</p>
{st.ftcLastError && (
<p className="text-amber-700 dark:text-amber-400">
The last try failed: {st.ftcLastError}. It tries again every minute.
</p>
)}
{!st.ftcCursor && (
<div>
<Button onClick={() => sync.mutate()} disabled={sync.isPending}>
Download now
</Button>
</div>
)}
{sync.isError && <Alert>{sync.error.message}</Alert>}
</div>
);
}
/** Spam check (spec 14 §7.1): IPQualityScore first, then the FTC list. Information only. */
function SpamTab({ s }: { s: SettingsDto }) {
const save = useSave();
const sp = s.spam;
const st = s['spam.state'];
const [v, setV] = useState({
enabled: sp.enabled,
useIpqs: sp.ipqs !== null,
ipqsKey: '',
useFtc: sp.ftc !== null,
ftcKey: '',
syncHours: String(sp.ftc?.syncHours ?? 24),
windowDays: String(sp.ftc?.windowDays ?? 30),
flaggedDays: String(sp.cache.flaggedDays),
cleanDays: String(sp.cache.cleanDays),
high: sp.deskPhoneLabels.high,
medium: sp.deskPhoneLabels.medium,
});
const [confirming, setConfirming] = useState(false);
const text = (k: keyof typeof v) => (e: { target: { value: string } }) =>
setV({ ...v, [k]: e.target.value });
const flag = (k: 'enabled' | 'useIpqs' | 'useFtc') => (e: { target: { checked: boolean } }) =>
setV({ ...v, [k]: e.target.checked });
const submit = () => {
setConfirming(false);
save.mutate(
{
spam: {
enabled: v.enabled,
ipqs: v.useIpqs ? { key: v.ipqsKey || undefined } : null,
ftc: v.useFtc
? {
key: v.ftcKey || undefined,
syncHours: Number(v.syncHours),
windowDays: Number(v.windowDays),
}
: null,
cache: { flaggedDays: Number(v.flaggedDays), cleanDays: Number(v.cleanDays) },
deskPhoneLabels: { high: v.high, medium: v.medium },
},
},
{ onSuccess: () => setV((x) => ({ ...x, ipqsKey: '', ftcKey: '' })) },
);
};
// 14 §8: sending callers' numbers out is confirmed once, when IPQualityScore starts being used.
const sendsOut = v.enabled && v.useIpqs && !(sp.enabled && sp.ipqs);
const num = (k: 'syncHours' | 'windowDays' | 'flaggedDays' | 'cleanDays', label: string) => (
<Field label={label}>
{(a) => (
<input {...a} inputMode="numeric" className={inputClass} value={v[k]} onChange={text(k)} />
)}
</Field>
);
const keyField = (k: 'ipqsKey' | 'ftcKey', label: string, saved: boolean, hint: string) => (
<Field label={label} hint={saved ? 'Saved. Paste a new one to replace it.' : hint}>
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
placeholder={saved ? '••••••••' : undefined}
className={inputClass}
value={v[k]}
onChange={text(k)}
/>
)}
</Field>
);
return (
<form
className="flex flex-col gap-4"
noValidate
onSubmit={(e) => {
e.preventDefault();
if (sendsOut) setConfirming(true);
else submit();
}}
>
<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
answers when it isn&rsquo;t, or can&rsquo;t.
</p>
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={v.enabled} onChange={flag('enabled')} className="size-4" />
Spam check on
</label>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">IPQualityScore</legend>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={v.useIpqs}
onChange={flag('useIpqs')}
className="size-4"
/>
Use IPQualityScore: a live lookup with a fraud score. It sends the caller&rsquo;s number
to IPQualityScore. The free plan has 1,000 lookups a month.
</label>
{v.useIpqs && (
<>
{keyField(
'ipqsKey',
'API key',
!!sp.ipqs?.hasKey,
'From your IPQualityScore account settings.',
)}
<SpamTest provider="ipqs" keyText={v.ipqsKey} />
{st.ipqsPausedAt && (
<p className="text-sm text-amber-700 dark:text-amber-400">
Paused since {when(st.ipqsPausedAt)}: its lookups are used up until they come back.
</p>
)}
</>
)}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">FTC complaint list</legend>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={v.useFtc} onChange={flag('useFtc')} className="size-4" />
Use the FTC list: complaints people filed with the FTC about unwanted calls, downloaded to
this server. Free, and nothing about your calls is sent out. The reports aren&rsquo;t
verified, and spammers fake real people&rsquo;s numbers.
</label>
{v.useFtc && (
<>
{keyField(
'ftcKey',
'api.data.gov key',
!!sp.ftc?.hasKey,
'Free, from api.data.gov/signup.',
)}
<SpamTest provider="ftc" keyText={v.ftcKey} />
<div className="grid grid-cols-2 gap-3">
{num('syncHours', 'Download again every (hours, 1–168)')}
{num('windowDays', 'Keep complaints for (days, 7–365)')}
</div>
{sp.ftc && <FtcSyncStatus st={st} />}
</>
)}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Keep IPQualityScore answers</legend>
<div className="grid grid-cols-2 gap-3">
{num('flaggedDays', 'Possible or likely spam (days, 1–365)')}
{num('cleanDays', 'Everything else (days, 1–365)')}
</div>
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Desk phones</legend>
<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>
<div className="grid grid-cols-2 gap-3">
<Field label="Likely spam">
{(a) => (
<input
{...a}
maxLength={20}
className={inputClass}
value={v.high}
onChange={text('high')}
/>
)}
</Field>
<Field label="Possible spam">
{(a) => (
<input
{...a}
maxLength={20}
className={inputClass}
value={v.medium}
onChange={text('medium')}
/>
)}
</Field>
</div>
</fieldset>
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<div>
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
</div>
<ConfirmDialog
open={confirming}
title="Send callers' numbers to IPQualityScore?"
message={
<>
The numbers of outside callers who aren&rsquo;t in your contacts will be sent to
IPQualityScore. You may need to say so in your privacy notice, and to have a data
processing agreement with them. In the EU and UK the GDPR applies.
</>
}
confirmLabel="Turn on"
onConfirm={submit}
onCancel={() => setConfirming(false)}
busy={save.isPending}
/>
</form>
);
}
const lines = (s: string) => const lines = (s: string) =>
s s
.split(/[\n,]/) .split(/[\n,]/)
@@ -1467,16 +1739,16 @@ function SignInTab({ s }: { s: SettingsDto }) {
</fieldset> </fieldset>
<fieldset className="flex flex-col gap-3"> <fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">OpenID Connect</legend> <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 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: a confidential client using the standard (authorization code) flow, and add these:
</p> </p>
<dl className="flex flex-col gap-2 text-sm"> <dl className="flex flex-col gap-2 text-sm">
{uris.map(([label, uri]) => ( {uris.map(([label, uri]) => (
<div key={label} className="flex flex-wrap items-center gap-2"> <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"> <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> <Button onClick={() => void navigator.clipboard?.writeText(uri)}>Copy</Button>
</dd> </dd>
</div> </div>
@@ -1559,7 +1831,7 @@ function SignInTab({ s }: { s: SettingsDto }) {
)} )}
{test.error && <Alert>{test.error.message}</Alert>} {test.error && <Alert>{test.error.message}</Alert>}
</div> </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. Then connect your own account in My account, and sign in from a private window.
</p> </p>
</fieldset> </fieldset>
@@ -1635,12 +1907,12 @@ function DirectoryForm({ s }: { s: SettingsDto }) {
> >
<fieldset className="flex flex-col gap-3"> <fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Directory (LDAP)</legend> <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 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 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. user here by email. Nobody gets a user just for being in the directory.
</p> </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) 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 is disabled here, password and OpenID sign-in included. It never enables anyone again: do
that on Users. that on Users.
+17 -16
View File
@@ -2,6 +2,7 @@
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements. // 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 // Upload by picking or dropping a file; play, rename, delete. Music-on-hold classes and their
// files (03 §8) and announcements live here too. // files (03 §8) and announcements live here too.
import { ArrowDown, ArrowUp } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { import {
@@ -68,7 +69,7 @@ export function SoundsPage() {
<h1 className="text-xl font-semibold">Sounds &amp; music</h1> <h1 className="text-xl font-semibold">Sounds &amp; music</h1>
<form <form
aria-label="Upload a sound" 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) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
setDragging(true); setDragging(true);
@@ -122,19 +123,19 @@ export function SoundsPage() {
{add.isPending ? 'Converting…' : 'Upload'} {add.isPending ? 'Converting…' : 'Upload'}
</Button> </Button>
</div> </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>} {tooBig && <Alert>That file is larger than 20 MB.</Alert>}
{add.error && <Alert>{add.error.message}</Alert>} {add.error && <Alert>{add.error.message}</Alert>}
</form> </form>
{sounds.isError && <Alert>{sounds.error.message}</Alert>} {sounds.isError && <Alert>{sounds.error.message}</Alert>}
{sounds.data?.length === 0 && ( {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. No sounds yet. Upload greetings for your digital receptionist and announcements.
</p> </p>
)} )}
{sounds.data && sounds.data.length > 0 && ( {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) => ( {sounds.data.map((s) => (
<li <li
key={s.id} key={s.id}
@@ -142,7 +143,7 @@ export function SoundsPage() {
> >
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="font-medium">{s.name}</span>{' '} <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> </span>
<audio <audio
controls controls
@@ -210,7 +211,7 @@ function MusicOnHold() {
<h2 id="moh-h" className="text-lg font-semibold"> <h2 id="moh-h" className="text-lg font-semibold">
Music on hold Music on hold
</h2> </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 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. set here, then choose it for a user (Phone tab) or a queue.
</p> </p>
@@ -297,7 +298,7 @@ function MohClassCard({
<div <div
aria-label={`Music on hold ${cls.name}`} aria-label={`Music on hold ${cls.name}`}
role="group" 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"> <div className="flex flex-wrap items-center gap-2">
<span className="flex-1 font-medium">{cls.name}</span> <span className="flex-1 font-medium">{cls.name}</span>
@@ -306,7 +307,7 @@ function MohClassCard({
</Button> </Button>
</div> </div>
{cls.files.length === 0 && ( {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 && ( {cls.files.length > 1 && (
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
@@ -317,7 +318,7 @@ function MohClassCard({
onChange={(e) => update.mutate({ shuffle: e.target.checked })} onChange={(e) => update.mutate({ shuffle: e.target.checked })}
/> />
Shuffle Shuffle
<span className="text-zinc-500"> <span className="text-muted">
{cls.shuffle {cls.shuffle
? '(plays in random order)' ? '(plays in random order)'
: '(each caller on hold starts at the top, in this 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"> <li key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{cls.files.length > 1 && !cls.shuffle && ( {cls.files.length > 1 && !cls.shuffle && (
<span className="flex items-center gap-0.5"> <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 <Button
variant="ghost" variant="ghost"
aria-label={`Move ${f.name} up`} aria-label={`Move ${f.name} up`}
@@ -337,7 +338,7 @@ function MohClassCard({
disabled={i === 0 || update.isPending} disabled={i === 0 || update.isPending}
onClick={() => move(i, -1)} onClick={() => move(i, -1)}
> >
↑ <ArrowUp className="size-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -346,12 +347,12 @@ function MohClassCard({
disabled={i === cls.files.length - 1 || update.isPending} disabled={i === cls.files.length - 1 || update.isPending}
onClick={() => move(i, 1)} onClick={() => move(i, 1)}
> >
↓ <ArrowDown className="size-4" />
</Button> </Button>
</span> </span>
)} )}
<span className="min-w-0 flex-1"> <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> </span>
<audio <audio
controls controls
@@ -425,19 +426,19 @@ function Announcements() {
</h2> </h2>
<Button onClick={() => setEditing('new')}>Add announcement</Button> <Button onClick={() => setEditing('new')}>Add announcement</Button>
</div> </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 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. a menu. Use them from phone numbers, menus and office hours.
</p> </p>
{list.data && list.data.length > 0 && ( {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) => ( {list.data.map((a) => (
<li <li
key={a.id} key={a.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm" 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="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} plays "{a.soundName}", then {a.destinationSummary}
</span> </span>
<Button onClick={() => setEditing(a)}>Edit</Button> <Button onClick={() => setEditing(a)}>Edit</Button>
+25 -28
View File
@@ -33,12 +33,9 @@ function Panel({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<section <section aria-label={title} className="rounded-xl border border-line bg-surface shadow-sm">
aria-label={title} <header className="flex items-center gap-2 border-b border-line px-4 py-3">
className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900/60" <span className="text-link">{icon(path)}</span>
>
<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>
<h2 className="flex-1 font-medium">{title}</h2> <h2 className="flex-1 font-medium">{title}</h2>
{action} {action}
</header> </header>
@@ -54,7 +51,7 @@ function Copy({ text, label }: { text: string; label: string }) {
type="button" type="button"
aria-label={`Copy ${label}`} aria-label={`Copy ${label}`}
title={done ? 'Copied' : `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={() => onClick={() =>
void navigator.clipboard.writeText(text).then(() => { void navigator.clipboard.writeText(text).then(() => {
setDone(true); setDone(true);
@@ -70,8 +67,8 @@ function Copy({ text, label }: { text: string; label: string }) {
/** One "label value" cell of the information grid. */ /** One "label value" cell of the information grid. */
function Item({ label, children }: { label: string; children: ReactNode }) { function Item({ label, children }: { label: string; children: ReactNode }) {
return ( 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"> <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-line py-2">
<dt className="text-zinc-500">{label}</dt> <dt className="text-muted">{label}</dt>
<dd className="flex items-center gap-1.5 font-semibold">{children}</dd> <dd className="flex items-center gap-1.5 font-semibold">{children}</dd>
</div> </div>
); );
@@ -87,7 +84,7 @@ const STATUS: Record<DiagnosticCheck['status'], [string, string]> = {
function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] }) { function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] }) {
return ( 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) => ( {checks.map((c) => (
<li key={c.id} className="flex gap-3 px-4 py-2 text-sm"> <li key={c.id} className="flex gap-3 px-4 py-2 text-sm">
<span <span
@@ -97,9 +94,9 @@ function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] })
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p> <p>
<span className="font-medium">{c.label}</span> <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>
<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 && ( {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"> <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} <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> <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.error && <Alert>{inside.error.message}</Alert>}
{inside.data && <Checks label="Server checks" checks={inside.data.checks} />} {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> <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 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 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. over TLS, the browser phone and the call audio ports.
@@ -151,16 +148,16 @@ function Diagnostics() {
</Button> </Button>
</div> </div>
{inside.data && !hasProbe && ( {inside.data && !hasProbe && (
<p className="text-xs text-zinc-500"> <p className="text-xs text-muted">
Set a probe first:{' '} 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 Settings → Network
</Link> </Link>
. .
</p> </p>
)} )}
{test.isPending && ( {test.isPending && (
<p role="status" className="text-zinc-500"> <p role="status" className="text-muted">
Testing… (up to half a minute) Testing… (up to half a minute)
</p> </p>
)} )}
@@ -203,7 +200,7 @@ export function SystemPage() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">System</h1> <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 About and source code
</Link> </Link>
</div> </div>
@@ -222,7 +219,7 @@ export function SystemPage() {
<Item label="Company">{s.companyName}</Item> <Item label="Company">{s.companyName}</Item>
<Item label="Public IP"> <Item label="Public IP">
{s.publicIp ?? 'not set'} {s.publicIp ?? 'not set'}
<span className="font-normal text-zinc-500"> <span className="font-normal text-muted">
{s.publicIpDynamic ? 'dynamic, re-checked' : 'static'} {s.publicIpDynamic ? 'dynamic, re-checked' : 'static'}
</span> </span>
{s.publicIp && <Copy text={s.publicIp} label="public IP" />} {s.publicIp && <Copy text={s.publicIp} label="public IP" />}
@@ -231,7 +228,7 @@ export function SystemPage() {
<Item label="Ports"> <Item label="Ports">
<span> <span>
SIP {p.sip} 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} {p.sips && ` · SIPS ${p.sips}`} · Media {p.rtp} · HTTPS {p.https}
</span> </span>
</Item> </Item>
@@ -266,13 +263,13 @@ export function SystemPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<pre <pre
aria-label="Restart command" 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} {RESTART}
</pre> </pre>
<Copy text={RESTART} label="restart command" /> <Copy text={RESTART} label="restart command" />
</div> </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 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 again by themselves within a minute; the web app, voicemail and history aren&apos;t
affected. affected.
@@ -288,7 +285,7 @@ export function SystemPage() {
action={ action={
<a <a
href="/api/system/events.csv" 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 {icon(DOWNLOAD)} Download
</a> </a>
@@ -296,12 +293,12 @@ export function SystemPage() {
> >
{events.error && <Alert>{events.error.message}</Alert>} {events.error && <Alert>{events.error.message}</Alert>}
{events.data && !rows.length && ( {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 && ( {!!rows.length && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table aria-label="Events" className="w-full text-left text-sm"> <table aria-label="Events" className="w-full text-left text-sm">
<thead className="text-zinc-500"> <thead className="text-muted">
<tr> <tr>
{['Severity', 'Event', 'Time', 'Source', 'Details'].map((h) => ( {['Severity', 'Event', 'Time', 'Source', 'Details'].map((h) => (
<th key={h} scope="col" className="px-4 py-2 font-medium whitespace-nowrap"> <th key={h} scope="col" className="px-4 py-2 font-medium whitespace-nowrap">
@@ -310,7 +307,7 @@ export function SystemPage() {
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody className="divide-y divide-line">
{rows.map((e) => ( {rows.map((e) => (
<tr key={e.id}> <tr key={e.id}>
<td className="px-4 py-1.5 whitespace-nowrap"> <td className="px-4 py-1.5 whitespace-nowrap">
@@ -332,7 +329,7 @@ export function SystemPage() {
</div> </div>
)} )}
{events.hasNextPage && ( {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 <Button
onClick={() => void events.fetchNextPage()} onClick={() => void events.fetchNextPage()}
disabled={events.isFetchingNextPage} disabled={events.isFetchingNextPage}
+15 -16
View File
@@ -20,8 +20,7 @@ import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useLive } from '../../live/store.ts'; import { useLive } from '../../live/store.ts';
export function trunkStatus(t: Pick<TrunkDto, 'enabled' | 'authMode'>, live?: TrunkLive) { export function trunkStatus(t: Pick<TrunkDto, 'enabled' | 'authMode'>, live?: TrunkLive) {
if (!t.enabled) if (!t.enabled) return { label: 'Off', color: 'bg-line text-fg' };
return { label: 'Off', color: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' };
const ok = 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200'; 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 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'; 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 New trunk
</Button> </Button>
</div> </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 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. calls can go to and come from the outside world.
</p> </p>
{trunks.isError && <Alert>{trunks.error.message}</Alert>} {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 && ( {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 && ( {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) => ( {trunks.data.map((t) => (
<li key={t.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm"> <li key={t.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm">
<StatusPill t={t} /> <StatusPill t={t} />
<span className="font-medium">{t.name}</span> <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.username ? `${t.username}@` : ''}
{t.serverHost} {t.serverHost}
</span> </span>
@@ -404,7 +403,7 @@ export function TrunkForm({
Enabled Enabled
</label> </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> <summary className="cursor-pointer font-medium">Advanced</summary>
<div className="mt-3 flex flex-col gap-4"> <div className="mt-3 flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
@@ -434,10 +433,10 @@ export function TrunkForm({
</details> </details>
{voipms && ( {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> <summary className="cursor-pointer font-medium">voip.ms API (optional)</summary>
<div className="mt-3 flex flex-col gap-4"> <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 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 REST/JSON API. Enable it, set an API password, and allow this server's public IP
address. address.
@@ -506,9 +505,9 @@ export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
return ( return (
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<legend className="mb-1 font-medium">Your voip.ms numbers</legend> <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.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) => ( {list.data?.map((d) => (
<label key={d.number} className="flex items-center gap-2"> <label key={d.number} className="flex items-center gap-2">
<input <input
@@ -522,8 +521,8 @@ export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
} }
/> />
{formatNumber(d.number)} {formatNumber(d.number)}
{d.description && <span className="text-zinc-500">{d.description}</span>} {d.description && <span className="text-muted">{d.description}</span>}
{d.added && <span className="text-zinc-500">(added)</span>} {d.added && <span className="text-muted">(added)</span>}
</label> </label>
))} ))}
{fresh.length > 0 && ( {fresh.length > 0 && (
@@ -558,7 +557,7 @@ function TestPanel({ trunk }: { trunk: TrunkDto }) {
const live = useLive((s) => s.trunks[trunk.id]); const live = useLive((s) => s.trunks[trunk.id]);
const status = trunkStatus(trunk, live); const status = trunkStatus(trunk, live);
return ( 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"> <div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.color}`}> <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.color}`}>
{status.label} {status.label}
@@ -582,7 +581,7 @@ function TestPanel({ trunk }: { trunk: TrunkDto }) {
/** What the customer sets in the voip.ms portal (06 §1). */ /** What the customer sets in the voip.ms portal (06 §1). */
function VoipmsChecklist() { function VoipmsChecklist() {
return ( 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> <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"> <ol className="mt-2 flex list-decimal flex-col gap-1.5 pl-5">
<li> <li>
+11 -11
View File
@@ -82,7 +82,7 @@ export function UserDrawer({
<div <div
role="tablist" role="tablist"
aria-label="User settings" 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) => ( {tabs.map((t) => (
<button <button
@@ -93,7 +93,7 @@ export function UserDrawer({
disabled={!t.enabled} disabled={!t.enabled}
title={t.enabled ? undefined : 'Give the user an extension number first'} title={t.enabled ? undefined : 'Give the user an extension number first'}
onClick={() => setTab(t.id)} 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} {t.label}
</button> </button>
@@ -243,9 +243,9 @@ function GeneralTab({
</label> </label>
)} )}
{user && ( {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> <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. Creates a new temporary password and signs them out everywhere.
</p> </p>
{user.twoStep && <ResetTwoStep user={user} />} {user.twoStep && <ResetTwoStep user={user} />}
@@ -413,7 +413,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
}; };
const row = (label: string, value: ReactNode) => ( const row = (label: string, value: ReactNode) => (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3"> <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> <dd className="font-mono break-all">{value}</dd>
</div> </div>
); );
@@ -421,7 +421,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
return ( return (
<div className="flex flex-col gap-4 text-sm"> <div className="flex flex-col gap-4 text-sm">
{error && <Alert>{error}</Alert>} {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 Use these to set up a desk phone or softphone by hand. Viewing the password is recorded in
the audit log. the audit log.
</p> </p>
@@ -436,7 +436,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
{creds && row('Dial plan', creds.dialPlan)} {creds && row('Dial plan', creds.dialPlan)}
</dl> </dl>
{creds && ( {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 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 #. match go out at once, the rest after a pause or #.
</p> </p>
@@ -446,7 +446,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
Reveal password Reveal password
</Button> </Button>
)} )}
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800"> <div className="border-t border-line pt-4">
{!confirming ? ( {!confirming ? (
<Button onClick={() => setConfirming(true)}>New SIP password</Button> <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>} {done && <p className="mt-2 text-green-700 dark:text-green-400">New password saved.</p>}
</div> </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> <h3 className="mb-2 font-medium">Phone app</h3>
<PhoneAppSetup load={() => extensionsApi.softphoneQr(ext.id)} /> <PhoneAppSetup load={() => extensionsApi.softphoneQr(ext.id)} />
</div> </div>
@@ -614,7 +614,7 @@ function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap'
<p> <p>
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'} <span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
</p> </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'} Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
</p> </p>
</> </>
@@ -650,7 +650,7 @@ function ResetTwoStep({ user }: { user: UserDto }) {
Turn off two-step sign-in Turn off two-step sign-in
</Button> </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. For a lost phone: they sign in with their password alone and can turn it on again.
</p> </p>
{reset.error && <Alert>{reset.error.message}</Alert>} {reset.error && <Alert>{reset.error.message}</Alert>}
+12 -14
View File
@@ -45,7 +45,7 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
</div> </div>
{users.isError && <Alert>{users.error.message}</Alert>} {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 && ( {users.data && (
<UsersTable <UsersTable
users={users.data} users={users.data}
@@ -105,7 +105,7 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
they must change it when they first sign in. they must change it when they first sign in.
</p> </p>
<div className="flex items-center gap-2"> <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} {tempPassword?.password}
</code> </code>
<Button <Button
@@ -131,7 +131,7 @@ function UsersTable({
}) { }) {
const live = useLive((s) => s.extensions); const live = useLive((s) => s.extensions);
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID'; 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) => const ext = (u: UserDto) =>
u.extension ? ( u.extension ? (
<span className="inline-flex items-center gap-1.5 font-mono"> <span className="inline-flex items-center gap-1.5 font-mono">
@@ -144,13 +144,11 @@ function UsersTable({
const badge = (u: UserDto) => ( const badge = (u: UserDto) => (
<span className="flex flex-wrap gap-1"> <span className="flex flex-wrap gap-1">
{u.role === 'admin' && ( {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 admin
</span> </span>
)} )}
{!u.isActive && ( {!u.isActive && <span className="rounded bg-line px-1.5 py-0.5 text-xs">inactive</span>}
<span className="rounded bg-zinc-200 px-1.5 py-0.5 text-xs dark:bg-zinc-700">inactive</span>
)}
</span> </span>
); );
// 12 §5: every way this user can sign in. // 12 §5: every way this user can sign in.
@@ -173,18 +171,18 @@ function UsersTable({
{/* Stacked cards on small screens (05 §1). */} {/* Stacked cards on small screens (05 §1). */}
<ul className="flex flex-col gap-2 md:hidden"> <ul className="flex flex-col gap-2 md:hidden">
{users.map((u) => ( {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="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium"> <p className="font-medium">
{u.displayName} {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>
<p className="truncate text-sm text-zinc-500">{u.email}</p> <p className="truncate text-sm text-muted">{u.email}</p>
</div> </div>
{badge(u)} {badge(u)}
</div> </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> <span>
Last login: {fmtDate(u.lastLoginAt)} · {ways(u)} Last login: {fmtDate(u.lastLoginAt)} · {ways(u)}
</span> </span>
@@ -194,7 +192,7 @@ function UsersTable({
))} ))}
</ul> </ul>
<table className="hidden w-full text-left text-sm md:table"> <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> <tr>
<th className="py-2 font-medium">Ext.</th> <th className="py-2 font-medium">Ext.</th>
<th className="py-2 font-medium">Name</th> <th className="py-2 font-medium">Name</th>
@@ -209,13 +207,13 @@ function UsersTable({
</thead> </thead>
<tbody> <tbody>
{users.map((u) => ( {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">{ext(u)}</td>
<td className="py-2 font-medium">{u.displayName}</td> <td className="py-2 font-medium">{u.displayName}</td>
<td className="py-2">{u.email}</td> <td className="py-2">{u.email}</td>
<td className="py-2">{badge(u)}</td> <td className="py-2">{badge(u)}</td>
<td className="py-2">{ways(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> <td className="py-2 text-right">{actions(u)}</td>
</tr> </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 // 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 // /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. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
@@ -133,7 +134,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
href={ctk.publicUrl} href={ctk.publicUrl}
target="_blank" target="_blank"
rel="noreferrer" 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 Import in Contrack
</a> </a>
@@ -146,7 +147,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)} )}
</div> </div>
{note && ( {note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400"> <p role="status" className="text-sm text-muted">
{note} {note}
</p> </p>
)} )}
@@ -161,11 +162,9 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)} )}
{ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />} {ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />}
{list.data && shown.length === 0 && ( {list.data && shown.length === 0 && (
<p className="text-sm text-zinc-500"> <p className="text-sm text-muted">{q ? 'No contact matches that.' : 'No contacts yet.'}</p>
{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) => ( {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"> <li key={c.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm">
{canShare && ( {canShare && (
@@ -184,9 +183,9 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)} )}
<span className="min-w-40 flex-1"> <span className="min-w-40 flex-1">
<span className="font-medium">{c.name}</span> <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' && ( {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 Company
</span> </span>
)} )}
@@ -196,22 +195,17 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
<button <button
key={n.number} key={n.number}
type="button" 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)}`} aria-label={`Call ${c.name} ${n.label} ${show(n.number)}`}
onClick={() => call(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)} {show(n.number)}
</button> </button>
))} ))}
</span> </span>
{c.url && ( {c.url && (
<a <a href={c.url} target="_blank" rel="noreferrer" className="text-link underline">
href={c.url}
target="_blank"
rel="noreferrer"
className="text-sky-700 underline dark:text-sky-400"
>
Open in Contrack Open in Contrack
</a> </a>
)} )}
@@ -272,7 +266,7 @@ function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
return ( return (
<div <div
role="status" 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"> <span className="flex-1">
{count} contact{count === 1 ? '' : 's'} from before Contrack{' '} {count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
@@ -385,7 +379,7 @@ function ContactDrawer(props: {
setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) }) setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) })
} }
> >
✕ <X className="size-4" />
</Button> </Button>
</div> </div>
))} ))}
@@ -454,7 +448,7 @@ function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => voi
} }
> >
<div className="flex flex-col gap-3 text-sm"> <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 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… a header row: name, company, email, label1, number1, label2, number2…
</p> </p>
@@ -474,7 +468,7 @@ function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => voi
if (read.length) run.mutate(false); if (read.length) run.mutate(false);
}} }}
/> />
{run.isPending && <p className="text-zinc-500">Reading…</p>} {run.isPending && <p className="text-muted">Reading…</p>}
{preview && ( {preview && (
<div role="status" className="flex flex-col gap-1"> <div role="status" className="flex flex-col gap-1">
<p> <p>
@@ -538,7 +532,7 @@ function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean)
</p> </p>
) : ( ) : (
<div className="flex flex-col gap-2 text-sm"> <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. Each person gets a copy to accept into their own contacts, or deny.
</p> </p>
<label className="flex items-center gap-2 font-medium"> <label className="flex items-center gap-2 font-medium">
@@ -564,7 +558,7 @@ function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean)
setTo(next); setTo(next);
}} }}
/> />
{p.name} <span className="text-zinc-500">({p.number})</span> {p.name} <span className="text-muted">({p.number})</span>
</label> </label>
))} ))}
{share.error && <Alert>{share.error.message}</Alert>} {share.error && <Alert>{share.error.message}</Alert>}
@@ -604,7 +598,7 @@ function SharedWithYou() {
return ( return (
<section <section
aria-labelledby="shared-h" 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"> <h2 id="shared-h" className="text-lg font-semibold">
Shared with you Shared with you
@@ -643,11 +637,9 @@ function SharedWithYou() {
<li key={s.id} className="flex flex-wrap items-center gap-2 text-sm"> <li key={s.id} className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1"> <span className="flex-1">
{s.contact.name} {s.contact.name}
{s.contact.company && ( {s.contact.company && <span className="text-muted"> · {s.contact.company}</span>}
<span className="text-zinc-500"> · {s.contact.company}</span>
)}
{s.contact.numbers.map((n) => ( {s.contact.numbers.map((n) => (
<span key={n.number} className="text-zinc-500"> <span key={n.number} className="text-muted">
{' '} {' '}
· {show(n.number)} · {show(n.number)}
</span> </span>
+207 -96
View File
@@ -1,12 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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), Settings, People, and the softphone.
import {
BookUser,
House,
Info,
LogOut,
Menu,
Phone,
PhoneMissed,
Settings,
Shield,
Users,
Voicemail,
X,
} from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared'; import type { AuthUser } from '@selfphoneng/shared';
import { meApi, signOut } from '../../api/client.ts'; import { meApi, signOut } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx'; 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 { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx'; import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { PresencePanel } from '../../softphone/PresencePanel.tsx'; import { PresencePanel } from '../../softphone/PresencePanel.tsx';
@@ -17,7 +31,7 @@ import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx'; import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx'; import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400'; const link = 'text-link underline';
export function MePage({ user }: { user: AuthUser }) { export function MePage({ user }: { user: AuthUser }) {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -36,15 +50,25 @@ export function MePage({ user }: { user: AuthUser }) {
const toasts = useLive((s) => s.toasts); const toasts = useLive((s) => s.toasts);
const offers = useContactShares().data?.length ?? 0; 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 }) => 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 ( return (
<main className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-6 p-4 sm:p-8"> <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"> <h1 className="text-xl">
<Brand /> <Brand />
</h1> </h1>
<div className="flex items-center gap-2"> <div className="hidden items-center gap-2 md:flex">
{user.role === 'admin' && ( {user.role === 'admin' && (
<Link className={`${link} text-sm`} to="/admin"> <Link className={`${link} text-sm`} to="/admin">
Admin Admin
@@ -54,47 +78,81 @@ export function MePage({ user }: { user: AuthUser }) {
About About
</Link> </Link>
<ThemeToggle /> <ThemeToggle />
<Button <Button variant="ghost" onClick={logout}>
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 });
}}
>
Sign out Sign out
</Button> </Button>
</div> </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> </header>
{/* On phones the tabs and the header links fold into the menu button. */}
<nav <nav
id="me-nav"
aria-label="My phone" 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 Home
</NavLink> </NavLink>
<NavLink to="/me/calls" className={tab}> <NavLink to="/me/calls" className={tab} onClick={close}>
<Phone className={icon} />
Calls Calls
</NavLink> </NavLink>
<NavLink to="/me/voicemail" className={tab}> <NavLink to="/me/voicemail" className={tab} onClick={close}>
<Voicemail className={icon} />
Voicemail Voicemail
</NavLink> </NavLink>
<NavLink to="/me/contacts" className={tab}> <NavLink to="/me/contacts" className={tab} onClick={close}>
<BookUser className={icon} />
Contacts Contacts
{offers > 0 && ( {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> <span className="sr-only">, shared with you: </span>
{offers} {offers}
</span> </span>
)} )}
</NavLink> </NavLink>
<NavLink to="/me/settings" className={tab}> <NavLink to="/me/settings" className={tab} onClick={close}>
<Settings className={icon} />
Settings Settings
</NavLink> </NavLink>
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */} {/* 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 People
</NavLink> </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> </nav>
{toasts.map((t) => ( {toasts.map((t) => (
<div <div
@@ -111,7 +169,7 @@ export function MePage({ user }: { user: AuthUser }) {
useLive.setState((s) => ({ toasts: s.toasts.filter((x) => x.id !== t.id) })) useLive.setState((s) => ({ toasts: s.toasts.filter((x) => x.id !== t.id) }))
} }
> >
✕ <X className="size-4" />
</Button> </Button>
</div> </div>
))} ))}
@@ -135,39 +193,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 }) { function Home({ user }: { user: AuthUser }) {
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const fresh = list.data?.filter(isNew) ?? [];
const nameOf = useContactName();
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<p>Hi {user.displayName}.</p> <p className="text-2xl font-semibold">Hi {user.displayName}</p>
<SharedNotice /> <SharedNotice />
{user.extension && <StatusCard ext={user.extension.number} />} {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 />} {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"> <p className="text-sm">
<Link className={link} to="/change-password"> <Link className={link} to="/change-password">
Change password Change password
@@ -177,13 +246,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). */ /** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
function SharedNotice() { function SharedNotice() {
const offers = useContactShares().data ?? []; const offers = useContactShares().data ?? [];
if (!offers.length) return null; if (!offers.length) return null;
const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', '); const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', ');
return ( 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.{' '} {from} shared {offers.length} contact{offers.length === 1 ? '' : 's'} with you.{' '}
<Link className={link} to="/me/contacts"> <Link className={link} to="/me/contacts">
Accept or deny Accept or deny
@@ -202,19 +307,20 @@ function StatusCard({ ext }: { ext: string }) {
me.data.cfna && `no answer → ${showTarget(me.data.cfna)}`, me.data.cfna && `no answer → ${showTarget(me.data.cfna)}`,
].filter(Boolean); ].filter(Boolean);
return ( return (
<section <Panel
aria-label="My status" title="My status"
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800" action={
<Link className={seeAll} to="/me/settings">
Change
</Link>
}
> >
<DndSwitch me={me.data} /> <DndSwitch me={me.data} />
<QueueLogins ext={ext} /> <QueueLogins ext={ext} />
<p className="text-sm text-zinc-600 dark:text-zinc-400"> <p className="text-sm text-muted">
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}{' '} {fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}
<Link className={link} to="/me/settings">
Change
</Link>
</p> </p>
</section> </Panel>
); );
} }
@@ -242,7 +348,7 @@ function QueueLogins({ ext }: { ext: string }) {
onChange={(e) => toggle.mutate({ id: q.id, paused: !e.target.checked })} onChange={(e) => toggle.mutate({ id: q.id, paused: !e.target.checked })}
/> />
Taking calls from queue {q.name} ({q.number}) 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> </label>
); );
})} })}
@@ -258,7 +364,7 @@ function ParkedOnHome() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<ParkedCalls onPickUp={call} /> <ParkedCalls onPickUp={call} />
{note && ( {note && (
<p role="status" className="text-sm text-zinc-500"> <p role="status" className="text-sm text-muted">
{note} {note}
</p> </p>
)} )}
@@ -273,44 +379,49 @@ function MissedCalls() {
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? []; const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
const nameOf = useContactName(); const nameOf = useContactName();
return ( return (
<section aria-labelledby="missed-h" className="flex flex-col gap-2"> <Panel
<h2 id="missed-h" className="text-lg font-semibold"> title="Missed calls"
Missed calls action={
</h2> calls.data &&
{calls.data && missed.length === 0 && <p className="text-sm text-zinc-500">None.</p>} 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 && ( {note && (
<p role="status" className="text-sm text-zinc-500"> <p role="status" className="text-sm text-muted">
{note} {note}
</p> </p>
)} )}
<ul className="flex flex-col gap-1 text-sm"> {missed.length > 0 && (
{missed.map((c) => { <ul className="-my-2 divide-y divide-line">
const o = otherParty(c); {missed.map((c) => {
return ( const o = otherParty(c);
<li key={c.id}> return (
<span className="font-semibold"> <Row
{nameOf(o.number) ?? o.name ?? fmtNumber(o.number)} key={c.id}
</span>{' '} icon={<PhoneMissed className="size-4 text-red-600 dark:text-red-400" />}
<span className="text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>{' '} title={nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
{o.number && ( detail={when(c.startedAt)}
<button >
type="button" {o.number && (
className={link} <Button
onClick={() => call(o.number!)} variant="ghost"
aria-label={`Call back ${fmtNumber(o.number)}`} className="text-link"
> onClick={() => call(o.number!)}
Call back aria-label={`Call back ${fmtNumber(o.number)}`}
</button> >
)} <Phone className="size-4" />
</li> </Button>
); )}
})} </Row>
</ul> );
{calls.data && calls.data.items.length > 0 && ( })}
<Link className={`${link} text-sm`} to="/me/calls"> </ul>
All my calls
</Link>
)} )}
</section> </Panel>
); );
} }
+13 -18
View File
@@ -37,7 +37,7 @@ export function DndSwitch({ me }: { me: MeDto }) {
onChange={(e) => save.mutate({ dnd: e.target.checked })} onChange={(e) => save.mutate({ dnd: e.target.checked })}
/> />
Do not disturb 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> </label>
); );
} }
@@ -45,7 +45,7 @@ export function DndSwitch({ me }: { me: MeDto }) {
export function MeSettingsPage() { export function MeSettingsPage() {
const me = useMe(); const me = useMe();
if (me.error) return <Alert>{me.error.message}</Alert>; 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 // 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). // shows after a reload (remounting on every change would also wipe "Saved." and errors).
return <SettingsForms me={me.data} />; return <SettingsForms me={me.data} />;
@@ -119,7 +119,7 @@ function Forwarding({ me }: { me: MeDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save forwarding Save forwarding
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{save.error && <Alert>{save.error.message}</Alert>} {save.error && <Alert>{save.error.message}</Alert>}
</form> </form>
@@ -188,7 +188,7 @@ function Voicemail({ me }: { me: MeDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save voicemail settings Save voicemail settings
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{save.error && <Alert>{save.error.message}</Alert>} {save.error && <Alert>{save.error.message}</Alert>}
</form> </form>
@@ -218,7 +218,7 @@ function CallReportForm({ me }: { me: MeDto }) {
<h2 id="report-h" className="text-lg font-semibold"> <h2 id="report-h" className="text-lg font-semibold">
Call report Call report
</h2> </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 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. calls, no email. It goes out a few minutes after each period ends, in the company time zone.
</p> </p>
@@ -285,14 +285,14 @@ function CallReportForm({ me }: { me: MeDto }) {
onChange={(e) => setForm({ ...form, callReportSummaries: e.target.checked })} onChange={(e) => setForm({ ...form, callReportSummaries: e.target.checked })}
/> />
Add a summary of each recorded call 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> </label>
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save call report Save call report
</Button> </Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>} {save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div> </div>
{save.error && <Alert>{save.error.message}</Alert>} {save.error && <Alert>{save.error.message}</Alert>}
</form> </form>
@@ -313,12 +313,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done }); const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
const options = useMutation({ mutationFn: meApi.contrackOptions, onSuccess: done }); const options = useMutation({ mutationFn: meApi.contrackOptions, onSuccess: done });
const where = contrack.publicUrl ? ( const where = contrack.publicUrl ? (
<a <a className="text-link underline" href={contrack.publicUrl} target="_blank" rel="noreferrer">
className="text-sky-700 underline dark:text-sky-400"
href={contrack.publicUrl}
target="_blank"
rel="noreferrer"
>
Contrack Contrack
</a> </a>
) : ( ) : (
@@ -365,9 +360,9 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
<span> <span>
With the summary and action items of each recorded call With the summary and action items of each recorded call
{!contrack.summariesAvailable && ( {!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 What was said goes into Contrack. If Contrack's own AI uses a cloud provider, it can
reach that provider. reach that provider.
</span> </span>
@@ -376,7 +371,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
</div> </div>
)} )}
{!contrack.tokenPrefix && ( {!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 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 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. 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"> <li key={d.id} className="flex flex-wrap items-center gap-3">
<span> <span>
{d.name}{' '} {d.name}{' '}
<span className="text-zinc-500"> <span className="text-muted">
({d.platform === 'ios' ? 'iPhone' : 'Android'}, paired{' '} ({d.platform === 'ios' ? 'iPhone' : 'Android'}, paired{' '}
{new Date(d.createdAt).toLocaleDateString()} {new Date(d.createdAt).toLocaleDateString()}
{d.lastUsedAt && `, last used ${new Date(d.lastUsedAt).toLocaleString()}`}) {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>, Or type it in the app: server <span className="font-mono">{pair.data.server}</span>,
code <span className="font-mono">{pair.data.code}</span> code <span className="font-mono">{pair.data.code}</span>
</p> </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 Works once, until {new Date(pair.data.expiresAt).toLocaleTimeString()}. Whoever uses it
gets into your account: don't share it. gets into your account: don't share it.
</p> </p>
+8 -6
View File
@@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx';
import { softphone } from '../../softphone/SoftphoneEngine.ts'; import { softphone } from '../../softphone/SoftphoneEngine.ts';
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx'; import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
import { AddToContacts, useContactName } from './ContactsPage.tsx'; import { AddToContacts, useContactName } from './ContactsPage.tsx';
import { SpamNote } from '../../components/SpamNote.tsx';
/** The other side of the call, from where I sit. */ /** The other side of the call, from where I sit. */
export const otherParty = (c: MyCallDto) => export const otherParty = (c: MyCallDto) =>
@@ -46,12 +47,12 @@ export function MyCallsPage() {
</h2> </h2>
{list.isError && <Alert>{list.error.message}</Alert>} {list.isError && <Alert>{list.error.message}</Alert>}
{note && ( {note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400"> <p role="status" className="text-sm text-muted">
{note} {note}
</p> </p>
)} )}
{list.data && calls.length === 0 && <p className="text-sm text-zinc-500">No calls yet.</p>} {list.data && calls.length === 0 && <p className="text-sm text-muted">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"> <ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{calls.map((c) => { {calls.map((c) => {
const other = otherParty(c); const other = otherParty(c);
const known = nameOf(other.number); const known = nameOf(other.number);
@@ -60,7 +61,7 @@ export function MyCallsPage() {
key={c.id} key={c.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm" 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="w-10">{c.myRole === 'out' ? 'Out' : 'In'}</span>
<span <span
className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`} className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`}
@@ -69,7 +70,7 @@ export function MyCallsPage() {
{other.number ? ( {other.number ? (
<button <button
type="button" type="button"
className="text-sky-700 underline dark:text-sky-400" className="text-link underline"
aria-label={`Call back ${fmtNumber(other.number)}`} aria-label={`Call back ${fmtNumber(other.number)}`}
onClick={() => call(other.number!)} onClick={() => call(other.number!)}
> >
@@ -79,11 +80,12 @@ export function MyCallsPage() {
'Unknown' 'Unknown'
)} )}
{c.direction !== 'internal' && ( {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' && ( {!known && other.number && c.direction !== 'internal' && (
<AddToContacts number={other.number} name={other.name} /> <AddToContacts number={other.number} name={other.name} />
)} )}
{c.myRole === 'in' && !known && <SpamNote check={c.spam} />}
</span> </span>
{c.recordingId && ( {c.recordingId && (
<audio <audio
+11 -11
View File
@@ -68,32 +68,32 @@ function Messages() {
onChange={(e) => setQ(e.target.value)} 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 && ( {!!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"> <ul className="flex flex-col gap-2">
{shown?.map((m) => ( {shown?.map((m) => (
<li <li
key={m.id} 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"> <div className="min-w-0 flex-1">
<p className={isNew(m) ? 'font-semibold' : ''}> <p className={isNew(m) ? 'font-semibold' : ''}>
{who(m)} {who(m)}
{isNew(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 New
</span> </span>
)} )}
</p> </p>
<p className="text-sm text-zinc-500"> <p className="text-sm text-muted">
{m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''} {m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''}
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)} {new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
</p> </p>
{m.transcript !== null && ( {m.transcript !== null && (
<p className="mt-1 text-sm" title="Automatic transcript, may have mistakes"> <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> </p>
)} )}
</div> </div>
@@ -108,7 +108,7 @@ function Messages() {
<div className="flex gap-2"> <div className="flex gap-2">
<a <a
href={meApi.audioUrl(m.id, true)} 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 Download
</a> </a>
@@ -217,7 +217,7 @@ function Greeting(props: {
<div <div
role="group" role="group"
aria-labelledby={id} 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"> <p id={id} className="font-medium">
{label} {label}
@@ -231,7 +231,7 @@ function Greeting(props: {
className="w-full sm:w-80" 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"> <div className="flex flex-wrap gap-2">
{recording ? ( {recording ? (
@@ -265,8 +265,8 @@ function Greeting(props: {
)} )}
</div> </div>
{recording && <p className="text-sm text-red-600 dark:text-red-400">Recording…</p>} {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.isPending && <p className="text-sm text-muted">Saving…</p>}
{upload.isSuccess && !upload.isPending && <p className="text-sm text-zinc-500">Saved.</p>} {upload.isSuccess && !upload.isPending && <p className="text-sm text-muted">Saved.</p>}
{(micError ?? upload.error?.message ?? remove.error?.message) && ( {(micError ?? upload.error?.message ?? remove.error?.message) && (
<Alert>{micError ?? upload.error?.message ?? remove.error?.message}</Alert> <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)} onChange={(e) => setQ(e.target.value)}
/> />
{note && ( {note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400"> <p role="status" className="text-sm text-muted">
{note} {note}
</p> </p>
)} )}
@@ -63,7 +63,7 @@ export function PresencePanel({ me }: { me: string | null }) {
<li <li
key={e.number} key={e.number}
data-number={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) => { onDragOver={(ev) => {
if (active && ev.dataTransfer.types.includes(CALL_DRAG)) ev.preventDefault(); if (active && ev.dataTransfer.types.includes(CALL_DRAG)) ev.preventDefault();
}} }}
@@ -75,7 +75,7 @@ export function PresencePanel({ me }: { me: string | null }) {
<StatusDot ext={e} /> <StatusDot ext={e} />
<span className="min-w-0 flex-1 truncate text-sm"> <span className="min-w-0 flex-1 truncate text-sm">
<span className="font-medium">{e.name}</span>{' '} <span className="font-medium">{e.name}</span>{' '}
<span className="text-zinc-500"> <span className="text-muted">
{e.number} · {statusLabel(e)} {e.number} · {statusLabel(e)}
</span> </span>
</span> </span>
+250 -76
View File
@@ -2,11 +2,23 @@
// Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the // Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog. // dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7). // Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
import { useQuery } from '@tanstack/react-query';
import {
ArrowRightLeft,
Grid3x3,
Mic,
MicOff,
Pause,
Phone,
PhoneOff,
Power,
type LucideIcon,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { formatNumber } from '@selfphoneng/shared'; import { formatNumber } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts'; import { meApi } from '../api/client.ts';
import { Dialog } from '../components/Dialog.tsx'; import { SpamNote } from '../components/SpamNote.tsx';
import { Button, inputClass } from '../components/ui.tsx'; import { Button, focusRing, inputClass } from '../components/ui.tsx';
import { useLive } from '../live/store.ts'; import { useLive } from '../live/store.ts';
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts'; import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts'; import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
@@ -63,6 +75,24 @@ const STATUS = {
error: 'Error', error: 'Error',
elsewhere: 'Active in another tab', elsewhere: 'Active in another tab',
}; };
const DOT = {
off: 'bg-line-strong',
connecting: 'bg-amber-500 animate-pulse motion-reduce:animate-none',
registered: 'bg-green-500',
error: 'bg-red-500',
elsewhere: 'bg-line-strong',
};
// The letters on a phone's keys, as a hint only (screen readers get the digit).
const LETTERS: Record<string, string> = {
'2': 'ABC',
'3': 'DEF',
'4': 'GHI',
'5': 'JKL',
'6': 'MNO',
'7': 'PQRS',
'8': 'TUV',
'9': 'WXYZ',
};
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']; const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
const clean = (s: string) => s.replace(/[^0-9*#+]/g, ''); const clean = (s: string) => s.replace(/[^0-9*#+]/g, '');
@@ -78,6 +108,19 @@ function useNameOf() {
(c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown'); (c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown');
} }
/** The spam check for an outside caller (14 §5.1): asked once, as it starts ringing. */
function CallerCheck({ call }: { call: CallInfo }) {
const contact = useContactName();
const check = useQuery({
queryKey: ['caller-check', call.remote],
queryFn: () => meApi.callerCheck(call.remote),
enabled: call.direction === 'in' && call.remote.length >= 10 && !contact(call.remote),
staleTime: Infinity,
retry: false,
});
return <SpamNote check={check.data} />;
}
function useNow(on: boolean) { function useNow(on: boolean) {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
@@ -178,11 +221,15 @@ export function SoftphoneDock() {
return ( return (
<section <section
aria-label="Softphone" aria-label="Softphone"
className="sticky bottom-0 mt-auto flex flex-col gap-2 border-t border-zinc-200 bg-white py-3 dark:border-zinc-800 dark:bg-zinc-950" className="sticky bottom-0 mt-auto flex flex-col gap-2 border-t border-line bg-page py-3"
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium"> <span className="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-sm font-medium">
Softphone: <span role="status">{STATUS[reg]}</span> <span aria-hidden className={`size-2 rounded-full ${DOT[reg]}`} />
Softphone<span className="sr-only">:</span>
<span role="status" className="text-muted">
{STATUS[reg]}
</span>
</span> </span>
<span className="flex-1" /> <span className="flex-1" />
{reg === 'off' && ( {reg === 'off' && (
@@ -202,6 +249,7 @@ export function SoftphoneDock() {
aria-controls="sp-panel" aria-controls="sp-panel"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
> >
<Grid3x3 className="size-4" />
{open ? 'Hide keypad' : 'Keypad'} {open ? 'Hide keypad' : 'Keypad'}
</Button> </Button>
<Button <Button
@@ -211,6 +259,7 @@ export function SoftphoneDock() {
run(softphone.stop().then(stepDown)); run(softphone.stop().then(stepDown));
}} }}
> >
<Power className="size-4" />
Turn off Turn off
</Button> </Button>
</> </>
@@ -232,43 +281,144 @@ export function SoftphoneDock() {
<Devices /> <Devices />
</div> </div>
)} )}
<Dialog {dialogCall && (
open={dialogCall !== undefined} <IncomingCall
onClose={() => dialogCall && run(softphone.decline(dialogCall.id))} call={dialogCall}
title="Incoming call" name={nameOf(dialogCall)}
footer={ onAnswer={() => run(softphone.answer(dialogCall.id))}
dialogCall && ( onDecline={() => run(softphone.decline(dialogCall.id))}
<> />
<Button onClick={() => run(softphone.decline(dialogCall.id))}>Decline</Button> )}
<Button variant="primary" onClick={() => run(softphone.answer(dialogCall.id))}>
Answer
</Button>
</>
)
}
>
{dialogCall && (
<p className="text-lg">
<span className="font-semibold">{nameOf(dialogCall)}</span>
{nameOf(dialogCall) !== dialogCall.remote && (
<span className="block text-sm text-zinc-500">
{dialogCall.remote.length > 6 ? formatNumber(dialogCall.remote) : dialogCall.remote}
</span>
)}
</p>
)}
</Dialog>
</section> </section>
); );
} }
function RoundButton({
label,
color,
icon: Icon,
onClick,
}: {
label: string;
color: string;
icon: LucideIcon;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="group flex flex-col items-center gap-2 rounded-lg text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-focus"
>
<span
className={`grid size-16 place-items-center rounded-full text-white transition-transform group-active:scale-95 motion-reduce:transition-none ${color}`}
>
<Icon className="size-7" />
</span>
{label}
</button>
);
}
/** The ringing screen: full screen on a phone, a card on wider screens. Esc declines. */
function IncomingCall({
call,
name,
onAnswer,
onDecline,
}: {
call: CallInfo;
name: string;
onAnswer: () => void;
onDecline: () => void;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (d && !d.open) d.showModal();
}, []);
return (
<dialog
ref={ref}
aria-labelledby="ring-title"
onCancel={(e) => {
e.preventDefault();
onDecline();
}}
className="m-0 h-dvh max-h-none w-full max-w-none bg-surface p-0 text-fg backdrop:bg-black/60 sm:m-auto sm:h-auto sm:max-w-sm sm:rounded-2xl sm:shadow-xl"
>
<div className="flex h-full flex-col items-center justify-between gap-10 px-6 py-16 text-center sm:py-8">
<div className="flex flex-col items-center gap-2">
<h2 id="ring-title" className="text-sm font-medium text-muted">
Incoming call
</h2>
<p className="text-3xl font-semibold break-words">{name}</p>
{name !== call.remote && (
<p className="text-muted tabular-nums">
{call.remote.length > 6 ? formatNumber(call.remote) : call.remote}
</p>
)}
<CallerCheck call={call} />
</div>
<div className="flex gap-16">
<RoundButton label="Decline" color="bg-red-600" icon={PhoneOff} onClick={onDecline} />
<RoundButton label="Answer" color="bg-emerald-600" icon={Phone} onClick={onAnswer} />
</div>
</div>
</dialog>
);
}
const TONE = {
normal: 'border border-line bg-surface hover:bg-subtle',
pressed: 'bg-primary text-white hover:bg-primary-hover',
answer: 'bg-emerald-700 text-white hover:bg-emerald-800',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
/** A call control: icon over a short label, phone style. `pressed` makes it a toggle. */
function Control({
icon: Icon,
label,
pressed,
tone = 'normal',
onClick,
}: {
icon: LucideIcon;
label: string;
pressed?: boolean;
tone?: 'normal' | 'answer' | 'danger';
onClick: () => void;
}) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
className={`flex min-h-14 min-w-16 flex-col items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none ${focusRing} ${TONE[pressed ? 'pressed' : tone]}`}
>
<Icon className="size-5" />
{label}
</button>
);
}
function Keypad({ onKey, label }: { onKey: (k: string) => void; label: string }) { function Keypad({ onKey, label }: { onKey: (k: string) => void; label: string }) {
return ( return (
<div role="group" aria-label={label} className="grid w-56 grid-cols-3 gap-1"> <div role="group" aria-label={label} className="grid w-fit grid-cols-3 gap-3">
{KEYS.map((k) => ( {KEYS.map((k) => (
<Button key={k} onClick={() => onKey(k)} className="text-lg"> <button
{k} key={k}
</Button> type="button"
aria-label={k}
onClick={() => onKey(k)}
className={`flex size-14 flex-col items-center justify-center rounded-full border border-line bg-surface transition-colors hover:bg-subtle active:bg-line ${focusRing}`}
>
<span className="text-xl leading-none font-medium">{k}</span>
<span aria-hidden className="h-3 text-[0.625rem] leading-3 tracking-widest text-muted">
{LETTERS[k]}
</span>
</button>
))} ))}
</div> </div>
); );
@@ -349,66 +499,90 @@ function CallCard({
<div <div
role="group" role="group"
aria-label={`${label}: ${name}`} aria-label={`${label}: ${name}`}
className="flex flex-col gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900" className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-3 shadow-sm"
// Drop it on a person in People to transfer it there. // Drop it on a person in People to transfer it there.
draggable={call.state === 'active'} draggable={call.state === 'active'}
onDragStart={(e) => e.dataTransfer.setData(CALL_DRAG, call.id)} onDragStart={(e) => e.dataTransfer.setData(CALL_DRAG, call.id)}
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-col gap-1">
<span className="flex-1 text-sm"> <p className="flex flex-wrap items-baseline gap-x-2">
<span className="font-medium">{name}</span> · {label} <span className="font-semibold">{name}</span>
{call.answeredAt && <> · {elapsed(call.answeredAt, now)}</>} <span className="text-sm text-muted">
{label}
{call.answeredAt && (
<span className="tabular-nums"> · {elapsed(call.answeredAt, now)}</span>
)}
</span>
{call.poorQuality && ( {call.poorQuality && (
<span className="ml-2 text-amber-700 dark:text-amber-400">Poor connection</span> <span className="text-sm text-amber-700 dark:text-amber-400">Poor connection</span>
)} )}
</span> </p>
<CallerCheck call={call} />
</div>
<div className="flex flex-wrap gap-2">
{call.state === 'incoming' ? ( {call.state === 'incoming' ? (
<> <>
<Button variant="primary" onClick={() => run(softphone.answer(call.id))}> <Control
Answer icon={Phone}
</Button> label="Answer"
<Button variant="ghost" onClick={() => run(softphone.decline(call.id))}> tone="answer"
Decline onClick={() => run(softphone.answer(call.id))}
</Button> />
<Control
icon={PhoneOff}
label="Decline"
tone="danger"
onClick={() => run(softphone.decline(call.id))}
/>
</> </>
) : ( ) : (
<> <>
{call.held && other && !consultId && (
<Button
variant="primary"
onClick={() => run(softphone.attendedTransfer(call.id, other.id))}
>
Complete transfer
</Button>
)}
{call.state === 'active' && ( {call.state === 'active' && (
<> <>
<Button <Control
aria-pressed={call.muted} icon={call.muted ? MicOff : Mic}
label="Mute"
pressed={call.muted}
onClick={() => softphone.mute(call.id, !call.muted)} onClick={() => softphone.mute(call.id, !call.muted)}
> />
{call.muted ? 'Unmute' : 'Mute'} <Control
</Button> icon={Pause}
<Button label="Hold"
aria-pressed={call.held} pressed={call.held}
onClick={() => run(softphone.hold(call.id, !call.held))} onClick={() => run(softphone.hold(call.id, !call.held))}
> />
{call.held ? 'Resume' : 'Hold'} <Control
</Button> icon={Grid3x3}
<Button aria-pressed={keypad} onClick={() => setKeypad((k) => !k)}> label="Keypad"
Keypad pressed={keypad}
</Button> onClick={() => setKeypad((k) => !k)}
<Button aria-pressed={transfer} onClick={() => setTransfer((t) => !t)}> />
Transfer <Control
</Button> icon={ArrowRightLeft}
label="Transfer"
pressed={transfer}
onClick={() => setTransfer((t) => !t)}
/>
</> </>
)} )}
<Button variant="danger" onClick={() => run(softphone.hangup(call.id))}> <Control
Hang up icon={PhoneOff}
</Button> label="Hang up"
tone="danger"
onClick={() => run(softphone.hangup(call.id))}
/>
</> </>
)} )}
</div> </div>
{call.held && other && !consultId && (
<Button
variant="primary"
className="self-start"
onClick={() => run(softphone.attendedTransfer(call.id, other.id))}
>
Complete transfer
</Button>
)}
{keypad && call.state === 'active' && ( {keypad && call.state === 'active' && (
<Keypad label="Send tones" onKey={(k) => softphone.dtmf(call.id, k)} /> <Keypad label="Send tones" onKey={(k) => softphone.dtmf(call.id, k)} />
)} )}
@@ -534,7 +708,7 @@ function Devices() {
</Button> </Button>
)} )}
{perm === 'denied' && ( {perm === 'denied' && (
<p className="text-zinc-600 dark:text-zinc-400"> <p className="text-muted">
Call notifications are blocked. To get them, allow notifications for this site in your Call notifications are blocked. To get them, allow notifications for this site in your
browser’s site settings (the icon left of the address), then reload the page. browser’s site settings (the icon left of the address), then reload the page.
</p> </p>
+1
View File
@@ -13,6 +13,7 @@ keeping the system safe, legal and up to date.
8. [Local AI](ai.md): voicemail transcripts 8. [Local AI](ai.md): voicemail transcripts
9. [Contacts](contacts.md): the builtin phonebook, or Contrack 9. [Contacts](contacts.md): the builtin phonebook, or Contrack
10. [Sign-in](sign-in.md): passwords, and OpenID Connect (Keycloak and others) 10. [Sign-in](sign-in.md): passwords, and OpenID Connect (Keycloak and others)
11. [Spam check](spam-check.md): IPQualityScore and the FTC complaint list
--- ---
+1 -1
View File
@@ -89,7 +89,7 @@ needs inside (`selfphoneng-probe-linux-x64`, or `-arm64` for ARM machines), next
With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead. With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead.
```sh ```sh
v=v0.0.9 # the release v=v0.0.11 # the release
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/selfphoneng-probe-linux-x64 curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/selfphoneng-probe-linux-x64
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS sha256sum --check --ignore-missing SHA256SUMS
+87
View File
@@ -0,0 +1,87 @@
# Spam check
When an outside caller from the US isn't anyone's contact, SelfPhoneNG can look the number up
and show what's known about it while it rings:
- **Browser phone:** a red **Likely spam** or amber **Possible spam** badge under the caller's
name, with a line such as "Fraud score 92 · spammer · VOIP · Austin, TX" or "Reported to the
FTC 4 times (3 robocalls), last on Oct 20: Warranties".
- **Desk phones:** the caller's name shows as **SPAM LIKELY** or **Spam risk** (you can change
these), if the answer comes in before the phone starts ringing (about a second).
- **Call history:** the same badge and line, for good: what was known when the call ended.
It's **information only**. Calls are never blocked, delayed or sent elsewhere, and a lookup that
fails or is slow only means nothing is shown (and one warning in the api log). Low-risk callers,
contacts, internal calls, withheld numbers and numbers outside the US show nothing.
It's off until you turn it on in **Settings → Spam check**.
## The two sources
You can use either one, or both. With both, IPQualityScore answers first, and the FTC list
answers when IPQualityScore isn't set up, fails, or has run out of lookups.
| | IPQualityScore | FTC complaint list |
|---|---|---|
| What it is | A live lookup service with a fraud score per number | Complaints people filed with the FTC about unwanted calls |
| Cost | 1,000 lookups a month free, then paid plans | Free |
| Sends your callers' numbers out | **Yes**, to IPQualityScore | No: the list is downloaded to this server |
| Likely spam | Flagged as a spammer, or fraud score 85+ | 3+ complaints in the window |
| Possible spam | Fraud score 75+, recent abuse, or risky | 1–2 complaints |
The FTC's reports aren't verified, and spammers fake real people's numbers: a number with
complaints may belong to someone whose number was faked. That's why the badge says what was
reported, not that the caller is a spammer.
## IPQualityScore
1. Sign up at <https://www.ipqualityscore.com/> and copy your API key from the account settings.
2. In **Settings → Spam check**, tick **Use IPQualityScore**, paste the key and press **Test**.
It shows how many lookups are left this billing period.
3. Tick **Spam check on** and **Save**. You're asked to confirm that callers' numbers will be
sent to IPQualityScore (see [Privacy](#privacy)).
Each number is looked up once and the answer kept: 14 days for possible and likely spam, 3 days
for the rest (**Keep IPQualityScore answers** changes these). Five phones ringing for the same
call cost one lookup.
**When the lookups run out**, SelfPhoneNG stops asking IPQualityScore and the dashboard says so
(**Dismiss** hides it until the next time). Checks use the FTC list meanwhile, if it's set up.
Every 6 hours it asks IPQualityScore whether lookups are back and carries on by itself. Saving a
new key carries on at once.
## FTC complaint list
1. Get a free key at <https://api.data.gov/signup/>.
2. In **Settings → Spam check**, tick **Use the FTC list**, paste the key, press **Test**, then
**Save** (with **Spam check on**).
The first download takes the last 30 days (**Keep complaints for**) and runs for about 7 hours:
api.data.gov allows a key 1,000 requests an hour, and there are 5,000 to 15,000 complaints a day.
The tab shows how far it got. After that it fetches what's new every 24 hours (**Download again
every**); **Download now** starts one at once. If the FTC refuses the key or is down, the tab
shows why and it tries again every minute from where it stopped.
## Privacy
- With **IPQualityScore**, the number of every outside caller who isn't a contact is sent to
IPQualityScore, a company in the US. If your privacy notice lists who you share data with, add
it. In the EU and UK the GDPR applies: you need a lawful basis (usually legitimate interest in
protecting staff from fraud) and a data processing agreement with IPQualityScore.
- With the **FTC list** only, nothing about your calls leaves the server.
- Only a number that's ringing one of the user's own phones is looked up: nobody can use the
browser phone to look up numbers that never called.
- The keys are stored encrypted and never shown again; the audit log hides them.
## If nothing shows
- Is **Spam check on**, and is the caller in someone's contacts (contacts always win)?
- Check the api log for `spam check failed` (it says which source and why) or
`FTC complaint download failed`:
```sh
docker compose logs api | grep -iE 'spam|ftc'
```
- Desk phones only show the label when the answer is quick. A first call from a number may show
nothing there while the browser phone shows it; the next call uses the kept answer.
+14
View File
@@ -0,0 +1,14 @@
# 0006 — Spam check: IPQualityScore and the FTC complaint list
Owner, 2026-10-27: show what's known about an outside caller who isn't in anyone's contacts
(14). The callers are in the US, so the providers are US ones: **IPQualityScore** (a live
lookup with a fraud score, spammer and recent-abuse flags, line type and carrier; free plan of
1,000 lookups a month) and the **FTC Do Not Call complaint data** (free, synced daily into a local
table through api.ftc.gov, so a check sends nothing out). IPQualityScore answers first when set
up; the FTC list answers when it isn't or can't. Rejected: OpenSpam (Spanish numbers;
kept for later), Hiya and Telnyx Number Reputation (contract only, or only for your own numbers),
Truecaller (no open API), CallerAPI, Nomorobo, Trestle and YouMail (no free plan, prices on
request or per query only). No package is added: both are plain HTTPS with `fetch`. IPQualityScore
is closed-source, but it's an optional outside service the admin turns on, like an SMTP server,
not a component we ship. It sends the caller's number out of the server, so it is off by default
and asks the admin to confirm (14 §8).
+29 -2
View File
@@ -8,7 +8,7 @@ Conventions:
- Primary keys: `uuid` with `default gen_random_uuid()` unless stated (built into PostgreSQL 18, so no pgcrypto extension is needed). - Primary keys: `uuid` with `default gen_random_uuid()` unless stated (built into PostgreSQL 18, so no pgcrypto extension is needed).
- Every table has `created_at timestamptz not null default now()` and - Every table has `created_at timestamptz not null default now()` and
`updated_at timestamptz not null default now()` (left out below to save space, `updated_at timestamptz not null default now()` (left out below to save space,
**but add them**). Exceptions: `cdr`, `audit_log`, `system_events`, `webhook_deliveries`, `generator_runs` (append-only, `created_at` only). **but add them**). Exceptions: `cdr`, `audit_log`, `system_events`, `webhook_deliveries`, `generator_runs`, `ftc_complaints` (append-only, `created_at` only).
- Columns ending in `_enc` hold secrets encrypted with AES-256-GCM using - Columns ending in `_enc` hold secrets encrypted with AES-256-GCM using
`APP_SECRET` (format: `base64(iv[12] | tag[16] | ciphertext)`). Only `APP_SECRET` (format: `base64(iv[12] | tag[16] | ciphertext)`). Only
`crypto/secrets.ts` encrypts and decrypts. API responses never include `_enc` `crypto/secrets.ts` encrypts and decrypts. API responses never include `_enc`
@@ -451,10 +451,35 @@ create table calls ( -- one row per logical cal
did_id uuid references dids(id) on delete set null, did_id uuid references dids(id) on delete set null,
answered_by_extension_id uuid references extensions(id) on delete set null, answered_by_extension_id uuid references extensions(id) on delete set null,
talk_seconds int not null default 0, talk_seconds int not null default 0,
recording_id uuid references recordings(id) on delete set null recording_id uuid references recordings(id) on delete set null,
spam_risk text, -- 14 §6: 'low' | 'medium' | 'high'; null = not checked
spam_info jsonb -- 14 §6: what the check said
); );
create index on calls (started_at desc); create index on calls (started_at desc);
-- Spam check (14 §7): IPQualityScore answers, and the local copy of the FTC complaints.
create table spam_lookups ( -- IPQualityScore answers (14 §4)
number text primary key, -- 1NXXNXXXXXX
provider text not null, -- 'ipqs'
risk text not null check (risk in ('low', 'medium', 'high')),
info jsonb not null, -- the 14 §2.1 fields only
expires_at timestamptz not null, -- updated_at is when it was checked
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index on spam_lookups (expires_at);
create table ftc_complaints ( -- the FTC copy (14 §2.2), kept windowDays; append-only
id text primary key, -- the FTC's id
number text not null, -- 1NXXNXXXXXX
reported_at timestamptz not null, -- the complaint's created-date
subject text,
robocall boolean,
created_at timestamptz not null default now()
);
create index on ftc_complaints (number);
create index on ftc_complaints (reported_at);
create table call_extensions ( -- which extensions were involved (for per-user history) create table call_extensions ( -- which extensions were involved (for per-user history)
call_id uuid not null references calls(id) on delete cascade, call_id uuid not null references calls(id) on delete cascade,
extension_id uuid not null references extensions(id) on delete cascade, extension_id uuid not null references extensions(id) on delete cascade,
@@ -654,3 +679,5 @@ with a zod schema per key and a default. Missing rows return the default.
| `provisioning.lanOnly` | boolean | false | only serve `/prov` to clients from local nets | | `provisioning.lanOnly` | boolean | false | only serve `/prov` to clients from local nets |
| `provisioning.enabled` | boolean | true | | | `provisioning.enabled` | boolean | true | |
| `system.restartRequired` | boolean | false | set by generator | | `system.restartRequired` | boolean | false | set by generator |
| `spam` | object | off, no providers | spam check (14 §7): `enabled`, `ipqs {keyEnc}`, `ftc {keyEnc, syncHours, windowDays}`, `cache {flaggedDays, cleanDays}`, `deskPhoneLabels {high, medium}` |
| `spam.state` | object | `{}` | set by the system (14 §3, §2.2): IPQualityScore paused, the warning dismissed, the FTC sync's progress |
+8 -2
View File
@@ -391,6 +391,7 @@ Auth & self:
| GET | `/api/me/greetings/:kind/audio` | user | the wav | | GET | `/api/me/greetings/:kind/audio` | user | the wav |
| PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) | | PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) |
| GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment | | GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment |
| GET | `/api/me/caller-check?number=` | user | the spam check for a caller ringing one of my extensions now, else `{check: null}` (14 §5.1, §9.4) |
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1), `POST /copy-to-contrack` (11 §6.2) | | GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1), `POST /copy-to-contrack` (11 §6.2) |
| POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45<queue>` | | POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45<queue>` |
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out | | POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out |
@@ -433,8 +434,11 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| GET | `/api/audit-log` | | | GET | `/api/audit-log` | |
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off | | GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
| POST | `/api/settings/smtp/test` | `{to}` sends a test email | | POST | `/api/settings/smtp/test` | `{to}` sends a test email |
| POST | `/api/settings/spam/ftc/sync` | Download now: the FTC download starts at the next minute (14 §9.3) |
| POST | `/api/settings/spam/ipqs/test`, `/api/settings/spam/ftc/test` | `{key?}`: the typed key, else the saved one; `{ok, detail}` or `{ok: false, error}` (14 §7.1) |
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` | | POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people). "Trunk down" comes from live state in the browser | | GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
| POST | `/api/system/warnings/spam_quota/dismiss` | hides `spam_quota` until IPQualityScore's credits run out again (14 §3) |
| POST | `/api/settings/network/detect-public-ip` | | | POST | `/api/settings/network/detect-public-ip` | |
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` | | GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) | | GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
@@ -465,7 +469,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| Job | Schedule | Does | | Job | Schedule | Does |
|---|---|---| |---|---|---|
| `nightlyRegenerate` | 00:05 daily | `generator.apply({trigger:'nightly', force:true})` (holidays roll over), aggregate orphan CDRs | | `nightlyRegenerate` | 00:05 daily | `generator.apply({trigger:'nightly', force:true})` (holidays roll over), aggregate orphan CDRs |
| `retentionCleanup` | 02:00 daily | delete recordings older than `recordings.retentionDays` (file + row), voicemail older than `voicemail.retentionDays` if > 0, sessions past expiry, webhook_deliveries older than 30 days | | `retentionCleanup` | 02:00 daily | delete recordings older than `recordings.retentionDays` (file + row), voicemail older than `voicemail.retentionDays` if > 0, sessions past expiry, webhook_deliveries older than 30 days, expired `spam_lookups` (14 §4) |
| `backup` | `backup.time` daily if enabled | §8.1 | | `backup` | `backup.time` daily if enabled | §8.1 |
| `publicIpWatch` | every 5 min if enabled | detect IP; if changed: update settings, apply, set restartRequired, email admins | | `publicIpWatch` | every 5 min if enabled | detect IP; if changed: update settings, apply, set restartRequired, email admins |
| `webhookDelivery` | every 10 s | send pending deliveries (§9) | | `webhookDelivery` | every 10 s | send pending deliveries (§9) |
@@ -474,6 +478,8 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them | | `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) | | `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's | | `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
| `spamFtcSync` | every 60 s, when due or under way | the FTC complaint download, 15 pages a minute (14 §2.2, §9.3) |
| `spamQuotaCheck` | every 6 hours, while IPQualityScore is paused | ask its Credit Usage API; credits back → resume (14 §3) |
Jobs must never overlap with themselves (use a running flag). Jobs must never overlap with themselves (use a running flag).
+3 -3
View File
@@ -27,7 +27,7 @@ Left sidebar navigation, in this order:
| Page | Path | Contents | | Page | Path | Contents |
|---|---|---| |---|---|---|
| Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down) | | Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down; IPQualityScore out of credits, with Dismiss, 14 §3) |
| Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable). As built (owner follow-up to M11-T7, 2026-10-20): the Calling tab's recording mode was missing until then; it is a **Record calls** select (Never, Calls from outside, Calls to outside, All outside calls = `recordMode` never/inbound/outbound/all). Calls inside the company are never recorded, and no recording notice plays for it (only numbers set to record play one) | | Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable). As built (owner follow-up to M11-T7, 2026-10-20): the Calling tab's recording mode was missing until then; it is a **Record calls** select (Never, Calls from outside, Calls to outside, All outside calls = `recordMode` never/inbound/outbound/all). Calls inside the company are never recorded, and no recording notice plays for it (only numbers set to record play one) |
| Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" | | Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" |
| Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed | | Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed |
@@ -47,8 +47,8 @@ Left sidebar navigation, in this order:
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page. Owner follow-up (2026-10-21, "total minutes, calltime, missed calls"): totals also have answered, talk time and outside minutes (in and out: inbound from arrival, since a receptionist or voicemail answers too; outbound from the answer; roughly what the provider bills), an outside-minutes-per-day chart, and one tile per voip.ms trunk with its account balance (`GET /api/reports/balance`, `getBalance`; a trunk without the API login says what to add) | | Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page. Owner follow-up (2026-10-21, "total minutes, calltime, missed calls"): totals also have answered, talk time and outside minutes (in and out: inbound from arrival, since a receptionist or voicemail answers too; outbound from the answer; roughly what the provider bills), an outside-minutes-per-day chart, and one tile per voip.ms trunk with its account balance (`GET /api/reports/balance`, `getBalance`; a trunk without the API login says what to add) |
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs | | SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | | | Blocked numbers | `/admin/blocked` | |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS | | Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS. Spam check (14 §7.1) |
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell | | System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`, `spam.quotaExhausted`/`spam.quotaRestored` (14 §3). Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
### 2.1 First-run setup wizard (`/setup`) ### 2.1 First-run setup wizard (`/setup`)
+3
View File
@@ -9,6 +9,9 @@ Legend: 📖 read · 🔨 build · ✅ done when
--- ---
## Backlog (owner's order, 2026-10-21) ## Backlog (owner's order, 2026-10-21)
0. **Spam check** (owner, 2026-10-27: SD1–SD5 done; the owner's real-key checks are left), tasks SD1–SD5
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
callers who aren't in anyone's contacts; information only, off by default.
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work): 0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
Android and iPhone from one Expo codebase in the `mobile` repo; user portal only, no push in v1. Android and iPhone from one Expo codebase in the `mobile` repo; user portal only, no push in v1.
0. **Single sign-on** (owner, 2026-10-25: next), tasks S1–S5 in [12 §8](12-sso.md#8-order-of-work): 0. **Single sign-on** (owner, 2026-10-25: next), tasks S1–S5 in [12 §8](12-sso.md#8-order-of-work):
+2
View File
@@ -302,6 +302,8 @@ As built (C5, `contacts/timeline.ts`):
agent or a forward does its own lookup in its own user's book, and never sees another's. agent or a forward does its own lookup in its own user's book, and never sees another's.
- The prefix of a number, ring group or queue ("Sales:") is kept in front: - The prefix of a number, ring group or queue ("Sales:") is kept in front:
`__SP_CNAM_PREFIX` grows the same way `CALLERID(name)` does. `__SP_CNAM_PREFIX` grows the same way `CALLERID(name)` does.
- No contact: with spam check on, the route waits up to 1.2 s for its answer and names a likely
or possible spammer with `spam.deskPhoneLabels` (14 §5.2, §9.4).
- Globals `SP_CNAM_URL` (http://127.0.0.1:API_PORT/…) and `SP_CNAM_TOKEN` (HMAC-SHA256 of - Globals `SP_CNAM_URL` (http://127.0.0.1:API_PORT/…) and `SP_CNAM_TOKEN` (HMAC-SHA256 of
APP_SECRET, so it survives restarts). The route answers 404 unless the request comes from this APP_SECRET, so it survives restarts). The route answers 404 unless the request comes from this
machine without `X-Forwarded-For` (not through the reverse proxy) and with the key, and isn't machine without `X-Forwarded-For` (not through the reverse proxy) and with the key, and isn't
+361
View File
@@ -0,0 +1,361 @@
# 14 — Spam check
Owner, 2026-10-27: when an outside caller isn't in anyone's contacts, look their number up and show
what's known about them. Why these providers and why it may leave the server:
[decision 0006](../decisions/0006-spam-check.md).
## 1. What it does
- An outside call rings a user, and the number is none of: an extension, a contact of that user, a
company phonebook entry (the 11 §1 lookup finds no name). Then the api **checks the number**
with a provider and the user sees the answer while it rings.
- **Information only** (owner): a check never blocks, reroutes or delays a call. The call rings at
once; the answer appears when it arrives.
- **US numbers only** (owner: the callers are in the US): `1NXXNXXXXXX` after `normalizeNumber`
(00 §8). Anything else, a withheld number, and internal calls are never checked.
- Shown in the **browser softphone** and **call history**; on **desk phones** only when the answer
is in before their caller-name lookup gives up (§5.2).
- **Off by default** (owner). The admin turns it on in Settings → Spam check (§8).
- **Never in the way:** a provider that fails or is slow only means no answer is shown. Each
failure is one `warn` log line with the provider and the reason (HTTP status, timeout, the
provider's message), never the key or the request URL (IPQualityScore puts its key in the path).
## 2. Providers
The admin sets up either provider or both. The order is fixed (owner, 2026-10-27):
**IPQualityScore first** when it's set up; the **FTC list** answers when IPQualityScore isn't set
up or can't answer. v1 has these two:
| | IPQualityScore | FTC Do Not Call complaints |
|---|---|---|
| What | Fraud score and flags for any number, live | Unwanted-call complaints people filed with the FTC |
| Where it runs | An HTTPS request per check | A local copy, synced by a job; a check is a database query |
| Sends out | The caller's number | Nothing (it only downloads public data) |
| Key | IPQualityScore API key | api.data.gov key (free) |
| Cost | Free plan 1,000 lookups a month; paid from about $99 a month | Free |
"Can't answer" means: not set up, paused (§3), or this check failed. The FTC list is then asked
for that check only; nothing is remembered about the failure. Neither set up → no checks.
### 2.1 IPQualityScore (`spam/ipqs.ts`)
- `GET https://www.ipqualityscore.com/api/json/phone/{key}/{number}?country[]=US`, the number as
11 digits, 4 s timeout.
- `success: false` whose message says the credits are used up ("You have insufficient credits to
make this query…") → **exhausted**, §3. Any other `success: false`, an HTTP error or a timeout →
a failure (logged, not cached, the FTC list asked).
- Risk (owner, 2026-10-27): **high** if `spammer` or `fraud_score >= 85`; **medium** if
`fraud_score >= 75`, `recent_abuse` or `risky`; else **low**. These follow IPQualityScore's
general advice (75 suspicious, 85 high risk).
- Kept (and nothing else from the answer): `fraud_score`, `spammer`, `recent_abuse`,
`line_type`, `carrier`, `name`, `city`, `region`.
- Credit Usage API: `GET https://www.ipqualityscore.com/api/json/account/{key}` → `credits` (left
in this billing period), `usage`, `phone_usage`. Used by Test, the Settings tab and §3.
`// TODO(spec): confirm with the owner's real key that this call costs no credit (SD2).`
### 2.2 FTC Do Not Call complaints (`spam/ftc.ts`)
- Source: `GET https://api.ftc.gov/v0/dnc-complaints`, key in `X-Api-Key`,
`created_date_from` / `created_date_to`, `sort_order=ASC`, `items_per_page=50` (the maximum),
`offset`. Kept per complaint: `id`, `company-phone-number` (10 digits, stored with the leading
1), `created-date`, `subject`, `recorded-message-or-robocall`.
- Not the daily CSV files on ftc.gov: the site answers scripted downloads with 403 (seen
2026-10-27).
- **Sync job** `spamFtcSync`, every `syncHours` hours (default 24; the FTC posts weekday data by
about noon Eastern, weekend data on Monday). It asks from the last synced `created-date` minus
one day (complaint ids make the overlap harmless) up to now, then deletes complaints older than
`windowDays` (default 30). The first sync fills the whole window. **Sync now** on the Settings
tab starts one at once.
- Volume: 5,000 to 15,000 complaints a day (probed 2026-10-26), so 100–300 requests a day, and
about 6,000 for the first 30 days. api.data.gov allows 1,000 requests an hour per key, so the
sync paces itself to 900 an hour, saves where it got to, and the next run carries on (the first
fill takes about 7 hours). A 429 ends the run there; the next run carries on.
- Risk from complaints in the window (owner, 2026-10-27): **high** at 3 or more, **medium** at
1–2, **low** at none.
- Kept for the answer: the complaint count, how many were robocalls, the last complaint's date,
the most common subject.
- The reports aren't verified, and spammers fake real people's numbers: the softphone and the
admin guide say "reported to the FTC", never "is spam".
## 3. When IPQualityScore's credits run out
IPQualityScore counts credits per billing period, which needn't be a calendar month, and the free
plan may also have a daily cap. So rather than wait for the month to end, the api asks
IPQualityScore when credits are back:
1. A check answered "insufficient credits" → IPQualityScore is **paused** (`spam.state.ipqsPausedAt`):
- one `system_events` row, `spam.quotaExhausted` (warning, source "Spam check");
- one `warn` log line;
- the dashboard warning `spam_quota` (04 §7, `GET /api/system/warnings`).
2. While paused, no request goes to IPQualityScore. Checks go to the FTC list if it's set up,
else no checks are made. Nothing is logged per call.
3. Job `spamQuotaCheck`, every 6 hours while paused, asks the Credit Usage API. `credits > 0` →
resumed: `ipqsPausedAt` cleared, a `spam.quotaRestored` info event, the warning goes away. If
the account call fails, the job tries again next time.
The warning, shown once (owner): "IPQualityScore's lookups are used up. Spam checks use the FTC
list until they come back." (or "…are off until they come back." without the FTC list). It has a
**Dismiss** button (`POST /api/system/warnings/spam_quota/dismiss`, admin), which sets
`spam.state.warningDismissedAt`. Dismissed, it stays hidden until the next time the credits run out.
## 4. Cache (IPQualityScore only)
- `spam_lookups` (§7) keeps each answer, so a caller who rings again costs no credit:
- **flagged** (medium or high) for `cache.flaggedDays`, default **14**;
- **low** for `cache.cleanDays`, default **3**.
- Both 1–365 days (owner: configurable).
- Failures aren't cached. Expired rows go in `retentionCleanup` (04 §8).
- One request per number at a time: a check already under way is shared (an in-memory map), so a
ring group ringing five people costs one credit.
- The FTC needs no cache: its check is already a local query.
## 5. Where checks happen
Everything goes through `spam/check.ts` `checkNumber(number, { cachedOnly })` → `{ risk,
provider, info } | null`. It runs the §1 rules, then the cache, then the providers (§2). It
doesn't do the contacts test: `checkCaller` (§9.4) does that for the user being rung.
### 5.1 Browser softphone
- `GET /api/me/caller-check?number=` → `{ check: { risk, provider, info } | null }`. It answers
`null` when the number is in this user's books (11 §1), isn't checked (§1), or no provider
answered within 5 s.
- Only for a number that is ringing at one of the user's extensions, or rang there in the last
2 minutes (live state, 04 §5.1); otherwise `null` without checking. A user can't spend
credits on numbers that never called.
- The softphone asks once per incoming call, as it starts ringing, and doesn't retry.
- Shown under the caller's name, in the ringing dialog and on the call card, for **high** and
**medium** only; **low** shows nothing (owner, 2026-10-27):
- a red "Likely spam" (high) or amber "Possible spam" (medium) badge;
- a line with what's known: IPQualityScore "Fraud score 92 · spammer · VoIP · Verizon · Austin,
TX" (and the name, if it has one); FTC "Reported to the FTC 4 times (3 robocalls), last on Oct
20: Warranties & protection plans".
- Not in the browser notification: that appears as the call starts, before the answer.
### 5.2 Desk phones
- In `GET /api/internal/caller-name` (11 §6.1): when there's no contact name, it waits for the
check for up to 1.2 s (inside the dialplan's 2 s). A high or medium answer puts its label in
front of the caller ID name: `deskPhoneLabels`, by default "SPAM LIKELY" and "Spam risk"
(owner, 2026-10-27: configurable; no `?`, which `phoneSafeName` drops). The whole name goes
through `phoneSafeName`, as now. A later answer is still cached for the softphone.
- The dialplan doesn't change, so no generator change and no new golden file.
## 6. Call history
- `calls.spam_risk` and `calls.spam_info` (§7) are written with the call row (CDR aggregation,
incoming calls only) from `checkNumber(from_number, { cachedOnly: true })`: the cache and the
local FTC copy, never a request out.
- The Calls pages (`/me/calls`, `/admin/calls`) show the §5.1 badge and line, unless the viewer's
books name the number (contacts come first, 11 §1). The call list API adds
`spam: { risk, provider, info } | null` to each item. The mobile app's call history (13 MB5)
gets it from the same API.
## 7. Data and settings
Migration (SD1):
```sql
create table spam_lookups ( -- IPQualityScore answers (14 §4)
number text primary key, -- 1NXXNXXXXXX
provider text not null, -- 'ipqs'
risk text not null check (risk in ('low', 'medium', 'high')),
info jsonb not null, -- the 14 §2.1 fields only
expires_at timestamptz not null, -- updated_at is when it was checked
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index on spam_lookups (expires_at);
create table ftc_complaints ( -- the FTC copy (14 §2.2), kept windowDays; append-only
id text primary key, -- the FTC's id
number text not null, -- 1NXXNXXXXXX
reported_at timestamptz not null, -- the complaint's created-date
subject text,
robocall boolean,
created_at timestamptz not null default now()
);
create index on ftc_complaints (number);
create index on ftc_complaints (reported_at);
alter table calls add column spam_risk text, add column spam_info jsonb;
```
Settings (02 §3):
| Key | Type | Default | Notes |
|---|---|---|---|
| `spam` | object | below | the admin's choices |
| `spam.state` | object | `{}` | read-only to the API, like `system.*`: `ipqsPausedAt`, `warningDismissedAt`, `ftcCursor` (`{day, offset}` while a download is under way), `ftcLastSyncAt`, `ftcLastError` |
```ts
spam = {
enabled: false,
ipqs: { keyEnc: string } | null, // null = not used; first when set (§2)
ftc: { keyEnc: string, syncHours: 24, windowDays: 30 } | null, // 1–168 hours, 7–365 days
cache: { flaggedDays: 14, cleanDays: 3 }, // 1–365 each
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' }, // 1–20 characters, §5.2
}
```
Keys are `_enc` (02, top: `crypto/secrets.ts`): the API returns `hasKey`, never the key; the audit
log masks it. Validated with zod (`packages/shared`).
### 7.1 Settings → Spam check
- **Spam check on** (the switch asks to confirm the first time, §8).
- **Use IPQualityScore**: key, **Test** (the Credit Usage API: shows the credits left without
spending one), "paused until credits come back" while paused.
- **Use the FTC list**: api.data.gov key (a link to its sign-up), **Test** (one request),
re-download every N hours, keep N days, last sync time and result, **Sync now**. With both on,
the tab says IPQualityScore answers first and the FTC list when it can't.
- Keep answers: flagged N days, others N days.
- Desk phone labels: high, medium. Saving refuses a label `phoneSafeName` would change.
- Test buttons use the stricter Test rate limit, as the others do (04 §2.2).
## 8. Privacy
- Spam check is off until the admin turns it on. With IPQualityScore it is an exception to
"nothing leaves the server": the caller's number (and nothing else: no name, no extension)
goes to IPQualityScore. The FTC list sends nothing about calls.
- Turning on IPQualityScore asks once: "The numbers of outside
callers who aren't in your contacts will be sent to IPQualityScore. You may need to say so in
your privacy notice, and to have a data processing agreement with them. In the EU and UK the
GDPR applies." The audit log records who turned it on.
- GDPR covers businesses in the EU or UK and calls from people there, so it isn't the owner's case
(US callers only). The admin guide says so for everyone else, along with US state privacy laws
such as California's. It's not legal advice.
- Admin guide: a new page `docs/admin-guide/spam-check.md` (what each provider is, what leaves the
server, the FTC caveat, the free allowance) and a line in `legal.md`.
## 9. Order of work
| Task | What | Done when |
|---|---|---|
| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP. Done, see §9.1 |
| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free. Done but the real-key check, see §9.2 |
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key. Done but the real sync, see §9.3 |
| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time. Done but a real call, see §9.4 |
| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires. Done, see §9.5 |
### 9.1 As built: SD1
- `spam/check.ts` `checkNumber(deps, number, { cachedOnly })`. There's no `wait`: a caller that
can't wait (14 §5.1, §5.2) races it against its own timer, and the check carries on and fills
the cache. The cache is only read while IPQualityScore is set up; turning it off leaves the
rows to expire.
- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is §9.3), `spam/http.ts`
(`getJson`, 4 s timeout, and `SpamError`, the only error text that is logged or shown: never
the URL or fetch's own error). A provider message is cut to 200 characters with the key taken
out, in case it quotes it.
- "Insufficient credits": see §9.2.
- Settings: the `spam` key holds the admin's choices, `spam.state` is read-only to PATCH (like
`system.*`). PATCH refuses a provider with no key typed or saved ("Enter the IPQualityScore
key", "Enter the api.data.gov key") and spam check on with neither provider. Labels are 1–20 of
`phoneSafeName`'s characters, runs of spaces made one. The audit log masks `keyEnc` (its
`…Enc` rule).
- `POST /api/settings/spam/ipqs/test` and `/ftc/test` `{ key? }` → `{ ok, detail | error }`: the
typed key, else the saved one. IPQualityScore's is the Credit Usage API ("Works: 912 credits
left this billing period."); the FTC's is one request for one complaint.
- Settings → Spam check (`SpamTab`): the switch, a section per provider with its key and Test,
the cache days and the desk phone labels. Saving with IPQualityScore newly in use opens the
14 §8 confirmation first.
- `retentionCleanup` deletes expired `spam_lookups` rows.
- Tests: `test/spam.test.ts` with a stubbed `fetch` (risk, kept fields, cache days and expiry,
the shared request, IPQualityScore down / timing out → the FTC list, warnings without the key,
`cachedOnly`, the settings and Test routes).
### 9.2 As built: SD2
- `spam/check.ts` `patchSpamState(db, patch, onlyIf?)` merges into `spam.state` in one
statement (`value || patch`), so a check and a job never overwrite each other's fields; SD3's
cursor uses it too. Pausing is `onlyIf` not paused yet, so of several checks running out at
once, one logs the warning and the `spam.quotaExhausted` event.
- Paused: the cache still answers (14 §4), IPQualityScore isn't asked, nothing is logged.
- `spamQuotaCheck` runs at start and every 6 hours; it does nothing unless paused. A failing
account call throws `SpamError` (no key in it), which the job's log line shows.
- Also resumed (owner's intent, not in §3): saving a newly typed IPQualityScore key, so a new
account isn't paused for up to 6 hours.
- `Warning` has `dismissable`; the dashboard shows **Dismiss** next to **Fix** for it. Pausing
clears `warningDismissedAt`, so the warning comes back the next time.
- Not done: the owner's real key showing that the Credit Usage API costs no credit (the TODO in
§2.1). If it does cost one, 4 calls a day while paused is the price.
- Tests: `test/spam.test.ts` (two checks running out at once: one warning, one event; the FTC
list answering; no requests while paused; the warning and Dismiss; the job leaving it paused at
0 credits and resuming above; the "are off" text without the FTC list; a new key; the job
asking nothing unless paused and failing without the key).
### 9.3 As built: SD3
- `spam/ftc.ts` `ftcSync(db, { now, budget })`, job `spamFtcSync` **every minute**. It does
nothing unless spam check and the FTC list are on and a download is due (`ftcLastSyncAt`
older than `syncHours`, or never) or under way (`ftcCursor`).
- One FTC date at a time (`created_date_from` / `_to` that day, `sort_order=ASC`, 50 a page,
`offset`), so an offset never grows past one day's complaints. A short page moves on to the
next day; today's short page ends the download: complaints older than `windowDays` are
deleted and `ftcLastSyncAt` set.
- Pacing: at most 15 pages a minute (900 an hour), and the place (`ftcCursor`) saved after each
run, so a restart carries on. The first 30 days take about 7 hours.
- Where a download starts: the newest complaint kept, less a day (for ones posted late, as on
weekends), but no further back than `windowDays`. Ids make the overlap harmless.
- The FTC's dates have no zone: taken as Eastern, so a day is midnight to midnight in
Washington. Complaints without a 10-digit US number or a full date are skipped.
- A failure (403 "api.data.gov refused the key", a 429 "…over its hourly limit", a timeout)
keeps the place and is saved in `ftcLastError`; the next minute tries again from the same page.
The job logs it (`warn`) only when it differs from the last one, so a long outage is one line.
- Settings → Spam check: "Downloading: at the complaints of {day}", "Last download {time}", the
last failure, and **Download now** (`POST /api/settings/spam/ftc/sync`, which clears
`ftcLastSyncAt` so the next minute starts one; 400 without the FTC list).
- Not done: one real download with the owner's api.data.gov key.
- Tests: `test/ftc-sync.test.ts` against a fake api.ftc.gov (off; the first download a day at a
time with three pages on one day, old complaints dropped, robocall and Eastern time; not again
until `syncHours`, then from the newest day less one; 15 pages a minute with the place saved; a
429 stopping and the next minute carrying on without duplicates; the same failure logged once;
a refused key). `test/spam.test.ts`: Download now.
### 9.4 As built: SD4
- `spam/check.ts` `checkCaller(deps, userId, number, waitMs)`: the contacts test (that user's
books and the company's, `contactNames` without waiting on Contrack; books that can't be read
count as "known", so nothing is shown), then `checkNumber` raced against `waitMs`. A late
answer still fills the cache. `usNumber` normalizes (00 §8) or gives ''.
- `GET /api/me/caller-check?number=` (5 s): only for an inbound call in live state (04 §5.1)
from that number with one of my extensions among its channels (the browser's
`PJSIP/101-web-…` and ring group / queue `Local/101@…` legs count). Live state already keeps
the caller's number (the first channel's caller ID), so nothing was added there.
- Not built: "or rang there in the last 2 minutes". The softphone asks as the call starts
ringing, so it's in live state; a call gone before its question arrives has nothing to show.
- `GET /api/internal/caller-name`: no contact → `checkCaller(…, 1200)`; high or medium →
`deskPhoneLabels` and, from IPQualityScore, its name for the line. The label **replaces** the
carrier's caller ID name rather than going in front of it: the dialplan doesn't send that
name, and §5.2 keeps the dialplan as it is. Its failures log through the app's logger (the
route's own is silent: the token is in the query).
- Softphone: `CallerCheck` in `SoftphoneDock.tsx`, in the ringing dialog and on each call card
(so the note stays after answering). Asked once per number (react-query, no retry), only for
an incoming call from 10+ digits that the browser doesn't already know as a contact.
- `components/SpamNote.tsx`: the badge and line (`spamLine`), for SD5's call history too.
- Tests: `test/spam.test.ts` "while it rings" (only callers ringing me, +1 formatting, someone
else's call, not ringing, a contact, low; desk labels, a contact's name, a number nobody has,
a late answer then the cache, custom labels from the FTC list), `SpamNote.test.ts` (the line).
- Not checked: a real call in the browser and on a desk phone.
### 9.5 As built: SD5
- `aggregateCall` (04 §5.4) runs `checkNumber(…, { cachedOnly: true })` for inbound calls and
writes `spam_risk` and `spam_info` (`{ provider, info }`) with the row. A low answer is kept
too (the FTC list answers low for a number nobody reported); null means not checked.
- `CallDto.spam` on `/api/calls`, `/api/calls/:id` and `/api/me/calls`: null when the viewer's
books (theirs and the company's) name the number, or when the books can't be read.
- Calls pages: `SpamNote` under the caller, on `/me/calls` only for calls to me that the browser
doesn't know as a contact. The CSV export has no spam columns.
- Admin guide: `docs/admin-guide/spam-check.md`.
- Tests: `test/spam.test.ts` (four calls aggregated: IPQualityScore's answer and the FTC's kept
after the cache is emptied, a contact's call shows nothing, an unreported number low, and no
request out).
Later, not now:
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
number to a provider that takes reports (OpenSpam has `POST /api/report`).
- **OpenSpam** as a provider for Spanish numbers (the owner has a key; it has little US data).
- **Low-risk details** (owner, 2026-10-27: nothing for now): show what IPQualityScore knows
about a low-risk caller too (line type, carrier, city, name), without a badge.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "selfphoneng", "name": "selfphoneng",
"version": "0.0.9", "version": "0.0.11",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@selfphoneng/shared", "name": "@selfphoneng/shared",
"version": "0.0.9", "version": "0.0.11",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
+3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Call history (02 calls, call_extensions; 04 §5.4, §7.3). // Call history (02 calls, call_extensions; 04 §5.4, §7.3).
import { z } from 'zod'; import { z } from 'zod';
import type { SpamCheck } from './spam.ts';
export const CALL_DIRECTIONS = ['inbound', 'outbound', 'internal'] as const; export const CALL_DIRECTIONS = ['inbound', 'outbound', 'internal'] as const;
export const CALL_STATUSES = [ export const CALL_STATUSES = [
@@ -43,6 +44,8 @@ export type CallDto = {
answeredBy: { id: string; number: string; name: string } | null; answeredBy: { id: string; number: string; name: string } | null;
talkSeconds: number; talkSeconds: number;
recordingId: string | null; recordingId: string | null;
/** 14 §6: what the spam check knew as it ended; null when not checked or in the viewer's books. */
spam: SpamCheck | null;
}; };
/** One raw CDR leg, for the expanded row. */ /** One raw CDR leg, for the expanded row. */
+1
View File
@@ -27,3 +27,4 @@ export * from './blocked.ts';
export * from './calls.ts'; export * from './calls.ts';
export * from './contacts.ts'; export * from './contacts.ts';
export * from './integrations.ts'; export * from './integrations.ts';
export * from './spam.ts';
+14 -2
View File
@@ -2,6 +2,12 @@
// One zod schema (with its default) per settings key (spec 02 §3). // One zod schema (with its default) per settings key (spec 02 §3).
import { z } from 'zod'; import { z } from 'zod';
import { DestinationSchema } from './destination.ts'; import { DestinationSchema } from './destination.ts';
import {
SPAM_DEFAULTS,
SpamSettingsSchema,
SpamStateSchema,
type SpamSettingsDto,
} from './spam.ts';
const range = (from: number, to: number) => const range = (from: number, to: number) =>
z z
@@ -151,6 +157,10 @@ export const SettingsSchemas = {
}) })
.nullable() .nullable()
.default(null), .default(null),
/** Spam check (spec 14 §7): off until the admin turns it on. */
spam: SpamSettingsSchema.default(SPAM_DEFAULTS),
/** What the system keeps about the spam check's providers (14 §3, §2.2). */
'spam.state': SpamStateSchema.default(SpamStateSchema.parse({})),
/** Why the directory couldn't be used (12 §4.2, §4.5), for the dashboard; null = fine. */ /** Why the directory couldn't be used (12 §4.2, §4.5), for the dashboard; null = fine. */
'system.directoryWarning': z.string().nullable().default(null), 'system.directoryWarning': z.string().nullable().default(null),
// Not in 02 §3's table but used by the generator (03 §3). // Not in 02 §3's table but used by the generator (03 §3).
@@ -317,12 +327,13 @@ export type SettingsDto = {
[ [
K in Exclude< K in Exclude<
SettingKey, SettingKey,
'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap' 'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap' | 'spam'
> >
]: SettingValue<K>; ]: SettingValue<K>;
} & { } & {
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null; smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null; 'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
spam: SpamSettingsDto;
'auth.oidc': 'auth.oidc':
(Omit<OidcInput, 'clientSecret' | 'clearClientSecret'> & { hasClientSecret: boolean }) | null; (Omit<OidcInput, 'clientSecret' | 'clearClientSecret'> & { hasClientSecret: boolean }) | null;
'auth.ldap': 'auth.ldap':
@@ -337,7 +348,8 @@ export type AiTestResult =
{ ok: true; ms: number; detail: string; audio?: string } | { ok: false; error: string }; { ok: true; ms: number; detail: string; audio?: string } | { ok: false; error: string };
/** GET /api/system/warnings: shown as the dashboard banner (05 §2). */ /** GET /api/system/warnings: shown as the dashboard banner (05 §2). */
export type Warning = { code: string; message: string; link: string }; /** `dismissable`: POST /api/system/warnings/{code}/dismiss hides it (14 §3). */
export type Warning = { code: string; message: string; link: string; dismissable?: boolean };
/** GET /api/system/backups (04 §8.1). */ /** GET /api/system/backups (04 §8.1). */
export type BackupDto = { name: string; size: number; createdAt: string }; export type BackupDto = { name: string; size: number; createdAt: string };
+109
View File
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Spam check (spec 14): what a check says about an outside caller, and its settings.
import { z } from 'zod';
export type SpamRisk = 'low' | 'medium' | 'high';
export type SpamProvider = 'ipqs' | 'ftc';
/** IPQualityScore's answer, only the fields 14 §2.1 keeps. */
export type IpqsInfo = {
fraudScore: number;
spammer: boolean;
recentAbuse: boolean;
lineType: string | null;
carrier: string | null;
name: string | null;
city: string | null;
region: string | null;
};
/** The FTC complaints in the window (14 §2.2). */
export type FtcInfo = {
complaints: number;
robocalls: number;
lastReportedAt: string | null;
subject: string | null;
};
export type SpamCheck =
| { risk: SpamRisk; provider: 'ipqs'; info: IpqsInfo }
| { risk: SpamRisk; provider: 'ftc'; info: FtcInfo };
/** The desk phone labels go through phoneSafeName (api), so only what it keeps. */
const label = z
.string()
.trim()
.min(1)
.max(20)
.regex(/^[\p{L}\p{M}\p{N} .'&@-]+$/u, "Letters, digits, spaces and . ' & @ - only")
.transform((s) => s.replace(/ +/g, ' '));
export const SpamLabelsSchema = z.object({ high: label, medium: label });
export const SpamCacheSchema = z.object({
flaggedDays: z.number().int().min(1).max(365),
cleanDays: z.number().int().min(1).max(365),
});
/** The stored `spam` setting (14 §7). Keys encrypted; a null provider isn't used. */
export const SpamSettingsSchema = z.object({
enabled: z.boolean(),
ipqs: z.object({ keyEnc: z.string() }).nullable(),
ftc: z
.object({
keyEnc: z.string(),
syncHours: z.number().int().min(1).max(168),
windowDays: z.number().int().min(7).max(365),
})
.nullable(),
cache: SpamCacheSchema,
deskPhoneLabels: SpamLabelsSchema,
});
export type SpamSettings = z.infer<typeof SpamSettingsSchema>;
export const SPAM_DEFAULTS: SpamSettings = {
enabled: false,
ipqs: null,
ftc: null,
cache: { flaggedDays: 14, cleanDays: 3 },
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
};
/** What the system keeps about the providers (`spam.state`, read-only to PATCH). */
export const SpamStateSchema = z.object({
ipqsPausedAt: z.string().nullable().default(null),
warningDismissedAt: z.string().nullable().default(null),
/** A sync under way (14 §2.2): the FTC's date (Eastern) being read and how far into it. */
ftcCursor: z
.object({ day: z.iso.date(), offset: z.number().int().min(0) })
.nullable()
.default(null),
ftcLastSyncAt: z.string().nullable().default(null),
ftcLastError: z.string().nullable().default(null),
});
export type SpamState = z.infer<typeof SpamStateSchema>;
/** PATCH /api/settings `spam`: keys are write-only (empty keeps the stored one). */
export const SpamInputSchema = z.object({
enabled: z.boolean(),
ipqs: z.object({ key: z.string().trim().max(200).optional() }).nullable(),
ftc: z
.object({
key: z.string().trim().max(200).optional(),
syncHours: z.number().int().min(1).max(168),
windowDays: z.number().int().min(7).max(365),
})
.nullable(),
cache: SpamCacheSchema,
deskPhoneLabels: SpamLabelsSchema,
});
export type SpamInput = z.input<typeof SpamInputSchema>;
/** GET /api/settings `spam`: keys replaced by hasKey. */
export type SpamSettingsDto = Omit<SpamSettings, 'ipqs' | 'ftc'> & {
ipqs: { hasKey: boolean } | null;
ftc: { hasKey: boolean; syncHours: number; windowDays: number } | null;
};
/** POST /api/settings/spam/{ipqs,ftc}/test. */
export type SpamTestResult = { ok: true; detail: string } | { ok: false; error: string };
+18
View File
@@ -117,6 +117,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.104.3 specifier: ^5.104.3
version: 5.104.3(react@19.3.1) version: 5.104.3(react@19.3.1)
lucide-react:
specifier: ^1.56.0
version: 1.56.0(@types/react@19.3.0)(react@19.3.1)
react: react:
specifier: ^19.3.1 specifier: ^19.3.1
version: 19.3.1 version: 19.3.1
@@ -2156,6 +2159,15 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.56.0:
resolution: {integrity: sha512-b+hqj30/trHBma5r1zlKb6yLewQp6tUtnz9loc3xdrYerPmvLYhlEbyPVCJ8DOg2/blBf6lwPLznftFVACka6A==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4288,6 +4300,12 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.56.0(@types/react@19.3.0)(react@19.3.1):
dependencies:
react: 19.3.1
optionalDependencies:
'@types/react': 19.3.0
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0