Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b499e263 | ||
|
|
34f443385d | ||
|
|
f435b475ed | ||
|
|
1979a02827 | ||
|
|
08bc611dec | ||
|
|
63286a1954 | ||
|
|
6bd2aaec56 | ||
|
|
80e97b4118 | ||
|
|
0a18d5354d | ||
|
|
a0121d4975 | ||
|
|
678d45e4f9 | ||
|
|
60348cf0c5 | ||
|
|
958405651b | ||
|
|
db244b116a | ||
|
|
1db5aa22dc | ||
|
|
8a1f17099d | ||
|
|
a75113b6ac | ||
|
|
2bc7d5cde5 | ||
|
|
19ca4ffc91 | ||
|
|
1bc0fe8811 | ||
|
|
add90204c0 | ||
|
|
4369dd5ae1 | ||
|
|
3d0f091bba | ||
|
|
5db4914fa7 | ||
|
|
fd3f06c167 |
No files matched your search
@@ -39,3 +39,4 @@
|
||||
**/*.key
|
||||
**/*.log
|
||||
**/*.pid
|
||||
docs/voip.ms-api
|
||||
@@ -8,3 +8,5 @@ coverage/
|
||||
*.pid
|
||||
# The mobile app is its own repo (13), checked out here beside the PBX.
|
||||
mobile/
|
||||
# voip.ms's API example bundle (06 §5.1): their copyright, kept locally for reference.
|
||||
docs/voip.ms-api/
|
||||
@@ -5,6 +5,80 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
|
||||
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||
Docker.
|
||||
|
||||
## v0.0.13 — 2026-10-29
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.13` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.13`.
|
||||
|
||||
Hardening after the attacks on FreePBX servers (spec 08 §8), where stolen trunk passwords were used
|
||||
for toll fraud:
|
||||
|
||||
- **Fraud alerts:** every admin gets an email, and the System page a warning, when the phone system
|
||||
calls an international or premium number (including Caribbean area codes, which are dialed like
|
||||
a US call but cost like abroad), when voip.ms bills calls on the trunk that this phone system
|
||||
didn't make, or when the voip.ms balance drops more than 10 in an hour. The last two need the
|
||||
voip.ms API login on the trunk. All three can be changed on Settings → Security → **Fraud
|
||||
alerts**.
|
||||
- **voip.ms IP restriction** (strongly recommended): the setup checklist on Trunks now says to
|
||||
turn it on for the sub-account, with only this server's public IP. A stolen trunk password is
|
||||
then useless anywhere else ([voip.ms](docs/admin-guide/voipms.md#1-in-the-voipms-portal)).
|
||||
- **Asterisk:** the api's manager login (AMI) can only do what SelfPhoneNG uses, and the image no
|
||||
longer has the modules that run commands on the server.
|
||||
|
||||
## v0.0.12 — 2026-10-28
|
||||
|
||||
**Before you update:** this version changes the database (spam reports); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.12` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.12`. If you run the network test probe, update it too (see
|
||||
**Probe key** below).
|
||||
|
||||
- **My reports:** everyone with an extension gets a Reports tab: the calls that rang them, how
|
||||
many they answered and missed, talk time, calls and missed calls per day, and their busiest
|
||||
hours.
|
||||
- **Report spam:** on the Calls page, an incoming outside call has a **Report spam** button. When
|
||||
the spam check flagged the call, its finding is the reason; otherwise the person types one.
|
||||
Reports wait at the top of **Blocked numbers** for an admin to Block or Dismiss, and the
|
||||
Dashboard counts them.
|
||||
- **Second security review** (spec 08 §7), eight findings fixed, none critical:
|
||||
- Signing in by email (OpenID or directory) no longer matches admins or anyone who already has
|
||||
a connected sign-in, and connecting an OpenID account only finishes in the browser that
|
||||
started it.
|
||||
- **Recordings, transcripts and AI summaries** only go to whoever made or answered the call.
|
||||
Ring group and queue members whose phone only rang no longer get a coworker's recording, nor
|
||||
its summary in their call report email or on their Contrack timeline.
|
||||
- The `voicemail.new` webhook no longer carries the voicemail's transcript.
|
||||
- Changing the Contrack address asks for the company token again, and everyone reconnects
|
||||
their own Contrack token.
|
||||
- A directory account can only be swapped for another by an admin.
|
||||
- **Probe key** (strongly recommended): Settings → Network → **Generate key**, then set it as
|
||||
`PROBE_KEY` on the probe and restart it. The probe then only answers your server. Without a key
|
||||
it still works, and Diagnostics warns. Behind your own reverse proxy (`TRUST_PROXY=true`), the
|
||||
proxy must run on the probe's machine.
|
||||
- **Asterisk 24.0.0-rc3.** TLS trunks get a health check again, and Test checks them too (rc2
|
||||
couldn't check them over TLS). `pnpm test:asterisk-upgrade` now checks what SelfPhoneNG relies
|
||||
on Asterisk doing before any version change.
|
||||
- **Docs:** a CrowdSec rule for slow SIP scanners, and how to check that CrowdSec bans
|
||||
([network](docs/admin-guide/network.md#crowdsec)).
|
||||
|
||||
## v0.0.11 — 2026-10-28
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.11`.
|
||||
|
||||
- **Phones:** on an iPhone a ringing call dimmed the screen but showed no Answer button (iOS
|
||||
zoomed into the small sign-in fields and stayed zoomed, so the call dialog was off screen).
|
||||
Fields are now 16px on touch screens and buttons at least 44px. A ringing call gets its own
|
||||
screen: full screen on a phone, with big round Decline and Answer buttons. My Phone's tabs fold
|
||||
into a menu button on narrow screens.
|
||||
- **New look:** colors from the logo (blue, and its navy for dark mode), cards on an off-white
|
||||
page, and text that is easy to read in both themes (all text at least WCAG AA). Icons
|
||||
throughout.
|
||||
- **Admin sidebar** grouped into Call routing, People, Features, History and System.
|
||||
- **My Phone Home** is a set of cards: My status, Missed calls (with call back) and New
|
||||
voicemails.
|
||||
- **Browser phone:** a status pill (Ready, Connecting…), phone-style call buttons with icons
|
||||
(Mute, Hold, Keypad, Transfer, Hang up) and a round keypad with the letters under the digits.
|
||||
|
||||
## v0.0.10 — 2026-10-27
|
||||
|
||||
**Before you update:** this version changes the database (spam check); the api backs up first,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||
|
||||
**Status:** pre-1.0 (v0.0.10), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
**Status:** pre-1.0 (v0.0.13), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
|
||||
import { ON_THE_CALL } from '../services/cdr.ts';
|
||||
|
||||
/** A recorded call waits this long for its summary before it's logged without one. */
|
||||
const SUMMARY_WAIT_MS = 30 * 60_000;
|
||||
@@ -141,11 +142,13 @@ export async function logCallsToContrack(d: {
|
||||
.limit(500);
|
||||
|
||||
// One entry per call and person (a person can be in a call as caller, rang and answered).
|
||||
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
|
||||
// `heard`: made or answered it; a phone that only rang gets no recording or summary (08 §7 #3).
|
||||
const todo = new Map<string, (typeof rows)[number] & { out: boolean; heard: boolean }>();
|
||||
for (const r of rows) {
|
||||
const k = `${r.call.id}|${r.userId}`;
|
||||
const out = r.role === 'caller' || todo.get(k)?.out === true;
|
||||
todo.set(k, { ...r, out });
|
||||
const heard = ON_THE_CALL.includes(r.role) || todo.get(k)?.heard === true;
|
||||
todo.set(k, { ...r, out, heard });
|
||||
}
|
||||
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
|
||||
let logged = 0;
|
||||
@@ -169,7 +172,8 @@ export async function logCallsToContrack(d: {
|
||||
await record(null);
|
||||
continue;
|
||||
}
|
||||
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
|
||||
const recorded = r.heard && r.call.recordingId !== null;
|
||||
const wantSummary = r.withSummary && summariesOn && recorded;
|
||||
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
|
||||
continue; // the transcript job hasn't summarized it yet
|
||||
const e = timelineEntry(
|
||||
@@ -180,7 +184,7 @@ export async function logCallsToContrack(d: {
|
||||
toNumber: r.call.toNumber,
|
||||
talkSeconds: r.call.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
|
||||
recorded: r.call.recordingId !== null,
|
||||
recorded,
|
||||
},
|
||||
wantSummary ? r.summary : null,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "spam_reports" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"reported_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "spam_reports_number_reportedBy_unique" UNIQUE("number","reported_by")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "spam_reports" ADD CONSTRAINT "spam_reports_reported_by_users_id_fk" FOREIGN KEY ("reported_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -106,6 +106,13 @@
|
||||
"when": 1793127210386,
|
||||
"tag": "0014_spam_check",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1793212840276,
|
||||
"tag": "0015_spam_reports",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -670,6 +670,19 @@ export const ftcComplaints = pgTable(
|
||||
(t) => [index().on(t.number), index().on(t.reportedAt)],
|
||||
);
|
||||
|
||||
/** "Report spam" from a user's call history, waiting for an admin (05 §2 Blocked numbers). */
|
||||
export const spamReports = pgTable(
|
||||
'spam_reports',
|
||||
{
|
||||
id: id(),
|
||||
number: text().notNull(),
|
||||
reason: text().notNull(),
|
||||
reportedBy: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [unique().on(t.number, t.reportedBy)],
|
||||
);
|
||||
|
||||
export const callExtensions = pgTable(
|
||||
'call_extensions',
|
||||
{
|
||||
|
||||
@@ -140,7 +140,7 @@ endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
|
||||
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
|
||||
type = aor
|
||||
contact = sip:chicago1.voip.ms:5061;transport=tls
|
||||
qualify_frequency = 0
|
||||
qualify_frequency = 60
|
||||
|
||||
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
|
||||
type = endpoint
|
||||
|
||||
@@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
|
||||
section(`trunk-${id}`, [
|
||||
'type = aor',
|
||||
`contact = sip:${target}`,
|
||||
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so
|
||||
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and
|
||||
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health.
|
||||
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
|
||||
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
|
||||
// Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name
|
||||
// (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname).
|
||||
'qualify_frequency = 60',
|
||||
]),
|
||||
section(`trunk-${id}`, [
|
||||
'type = endpoint',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// voip.ms REST/JSON API (06 §5.1): GET rest.php?api_username&api_password&method&…, JSON back with
|
||||
// `status` = "success" or an error code. The password is in the query string, so the URL is never
|
||||
// logged or put in an error.
|
||||
// TODO(spec): response shapes and error codes are from the 06 spec, not a real account yet
|
||||
// (voip.ms's API docs need a login); check them with the owner's account and fix here.
|
||||
// TODO(spec): most response shapes and error codes are from the 06 spec, not a real account yet;
|
||||
// voip.ms's example bundle (docs/voip.ms-api) only shows getCDR's. Check with the owner's account.
|
||||
import { normalizeNumber } from '@selfphoneng/shared';
|
||||
|
||||
export type VoipmsCreds = { username: string; password: string };
|
||||
@@ -106,6 +106,59 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise<Voip
|
||||
});
|
||||
}
|
||||
|
||||
export type VoipmsCall = {
|
||||
at: Date;
|
||||
destination: string;
|
||||
account: string;
|
||||
disposition: string;
|
||||
seconds: number;
|
||||
total: string;
|
||||
uniqueid: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One sub-account's call records between two UTC dates (getCDR, 06 §5.1). `timezone=0` makes
|
||||
* `date` UTC. The request, `no_cdr` and most fields are in voip.ms's examples; `account`,
|
||||
* `disposition` and `uniqueid` aren't (verify), and come back '' if missing.
|
||||
*/
|
||||
export async function getCdr(
|
||||
baseUrl: string,
|
||||
creds: VoipmsCreds,
|
||||
q: { from: string; to: string; account: string },
|
||||
): Promise<VoipmsCall[]> {
|
||||
let r: Record<string, unknown>;
|
||||
try {
|
||||
r = await voipmsApi(baseUrl, creds, 'getCDR', {
|
||||
date_from: q.from,
|
||||
date_to: q.to,
|
||||
answered: '1',
|
||||
noanswer: '1',
|
||||
busy: '1',
|
||||
failed: '1',
|
||||
timezone: '0',
|
||||
account: q.account,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof VoipmsError && e.status === 'no_cdr') return [];
|
||||
throw e;
|
||||
}
|
||||
return list(r.cdr).flatMap((c) => {
|
||||
const at = new Date(`${str(c.date).replace(' ', 'T')}Z`);
|
||||
if (Number.isNaN(at.getTime())) return [];
|
||||
return [
|
||||
{
|
||||
at,
|
||||
destination: str(c.destination),
|
||||
account: str(c.account),
|
||||
disposition: str(c.disposition),
|
||||
seconds: Number(str(c.seconds)) || 0,
|
||||
total: str(c.total),
|
||||
uniqueid: str(c.uniqueid),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's balance (getBalance → balance.current_balance, in the account's currency).
|
||||
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
|
||||
|
||||
+14
-1
@@ -31,6 +31,7 @@ import { logCallsToContrack } from './contacts/timeline.ts';
|
||||
import { checkDirectory } from './sso/directory.ts';
|
||||
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { createFraudWatch } from './services/fraud.ts';
|
||||
import { spamQuotaCheck } from './spam/check.ts';
|
||||
import { ftcSync } from './spam/ftc.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
@@ -130,6 +131,13 @@ export async function retentionCleanup(d: JobDeps) {
|
||||
export function createJobs(d: JobDeps) {
|
||||
const now = d.now ?? (() => new Date());
|
||||
const tz = () => getSetting(d.db, 'general.timezone');
|
||||
const fraud = createFraudWatch({
|
||||
db: d.db,
|
||||
voipmsUrl: d.config.VOIPMS_API_URL,
|
||||
mail: (subject, text) => mailAdmins(d, subject, text),
|
||||
log: d.log,
|
||||
now,
|
||||
});
|
||||
|
||||
// Holidays roll over at midnight (03 §6.10): render again when the local date changes.
|
||||
// Retention runs then too (and at start), not at 02:00.
|
||||
@@ -195,7 +203,8 @@ export function createJobs(d: JobDeps) {
|
||||
`Public IP changed to ${ip}`,
|
||||
`The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` +
|
||||
'Restart telephony so calls use the new address: Admin → System says how (it drops calls in progress).\n' +
|
||||
'Phones and the provider may need the new IP if they were set up with the old one.',
|
||||
'Phones and the provider may need the new IP if they were set up with the old one ' +
|
||||
'(voip.ms: the sub-account’s IP restriction or IP authentication, and the API whitelist).',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -296,6 +305,8 @@ export function createJobs(d: JobDeps) {
|
||||
publicIpWatch,
|
||||
trunkWatch,
|
||||
diskWatch,
|
||||
riskyCallWatch: fraud.riskyCallWatch,
|
||||
providerWatch: fraud.providerWatch,
|
||||
callReports,
|
||||
contactsWarm,
|
||||
contrackLog,
|
||||
@@ -321,6 +332,8 @@ export function startJobs(d: JobDeps): () => void {
|
||||
run('trunkWatch', j.trunkWatch);
|
||||
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
||||
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
||||
run('riskyCallWatch', j.riskyCallWatch);
|
||||
if (minute % 15 === 0) run('providerWatch', j.providerWatch);
|
||||
run('callReports', j.callReports);
|
||||
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
||||
run('contrackLog', j.contrackLog);
|
||||
|
||||
@@ -213,12 +213,10 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
||||
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
|
||||
async testTrunk(
|
||||
id: string,
|
||||
{ waitMs = 1500, qualify = true } = {},
|
||||
{ waitMs = 1500 } = {},
|
||||
): Promise<{ live: TrunkLive; rttMs: number | null }> {
|
||||
if (qualify) {
|
||||
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
|
||||
}
|
||||
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
|
||||
await this.refreshRegistrations();
|
||||
const rttMs = await this.refreshReachable(id);
|
||||
const live = this.trunks.get(id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk
|
||||
// entry checks (03 §6.4). Admin only.
|
||||
// entry checks (03 §6.4). /api/spam-reports: users' Report spam, to block or dismiss. Admin only.
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
formatNumber,
|
||||
type BlockedDto,
|
||||
type ListResponse,
|
||||
type SpamReportDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { blockedNumbers } from '../db/schema.ts';
|
||||
import { blockedNumbers, spamReports, users } from '../db/schema.ts';
|
||||
import { conflict, notFound } from '../errors.ts';
|
||||
import { setBlock } from '../services/astdb.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
|
||||
throw e;
|
||||
}
|
||||
await mirror(b.number, true);
|
||||
await db.delete(spamReports).where(eq(spamReports.number, b.number));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/spam-reports', async (): Promise<ListResponse<SpamReportDto>> => ({
|
||||
items: (
|
||||
await db
|
||||
.select({ r: spamReports, name: users.displayName })
|
||||
.from(spamReports)
|
||||
.leftJoin(users, eq(users.id, spamReports.reportedBy))
|
||||
.orderBy(desc(spamReports.createdAt))
|
||||
).map(({ r, name }) => ({
|
||||
id: r.id,
|
||||
number: r.number,
|
||||
reason: r.reason,
|
||||
reportedBy: name,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
nextCursor: null,
|
||||
}));
|
||||
|
||||
app.delete('/api/spam-reports/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const [r] = await db.delete(spamReports).where(eq(spamReports.id, id)).returning();
|
||||
if (!r) throw notFound('Already handled');
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging.
|
||||
import { and, asc, desc, eq, ilike, inArray, or, sql, type SQL } from 'drizzle-orm';
|
||||
// /api/me/spam-reports: Report spam on a call that rang me (05 §4).
|
||||
import { and, asc, desc, eq, ilike, inArray, ne, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CallFilterSchema,
|
||||
SpamReportCreateSchema,
|
||||
formatNumber,
|
||||
normalizeNumber,
|
||||
type CallDetailDto,
|
||||
type CallDto,
|
||||
type ListResponse,
|
||||
@@ -14,9 +18,18 @@ import {
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
|
||||
import {
|
||||
blockedNumbers,
|
||||
callExtensions,
|
||||
calls,
|
||||
cdr,
|
||||
dids,
|
||||
extensions,
|
||||
spamReports,
|
||||
} from '../db/schema.ts';
|
||||
import { contactNames } from '../contacts/book.ts';
|
||||
import { badRequest, notFound } from '../errors.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { ON_THE_CALL } from '../services/cdr.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
const answerer = alias(extensions, 'answerer');
|
||||
@@ -216,6 +229,8 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
const out = mine.includes('caller');
|
||||
return {
|
||||
...c,
|
||||
// Only rang (a ring group): someone else's conversation (08 §7 #3).
|
||||
recordingId: mine.some((r) => ON_THE_CALL.includes(r)) ? c.recordingId : null,
|
||||
myRole: out ? 'out' : 'in',
|
||||
missedByMe: !out && (MISSED as readonly string[]).includes(c.status),
|
||||
};
|
||||
@@ -224,4 +239,42 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** The admins see it on Blocked numbers and block or dismiss it. */
|
||||
app.post('/api/me/spam-reports', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const body = SpamReportCreateSchema.parse(req.body);
|
||||
const [c] = await db
|
||||
.select({ number: calls.fromNumber })
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.where(
|
||||
and(
|
||||
eq(calls.id, body.callId),
|
||||
eq(calls.direction, 'inbound'),
|
||||
eq(extensions.userId, req.user!.id),
|
||||
ne(callExtensions.role, 'caller'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!c) throw notFound('Not a call from outside that rang you');
|
||||
let number: string;
|
||||
try {
|
||||
number = normalizeNumber(c.number ?? '');
|
||||
} catch {
|
||||
throw badRequest("This caller's number was hidden, so it can't be blocked");
|
||||
}
|
||||
const [blocked] = await db
|
||||
.select({ id: blockedNumbers.id })
|
||||
.from(blockedNumbers)
|
||||
.where(eq(blockedNumbers.number, number));
|
||||
if (blocked) throw conflict(`${formatNumber(number)} is already blocked`);
|
||||
const [r] = await db
|
||||
.insert(spamReports)
|
||||
.values({ number, reason: body.reason, reportedBy: req.user!.id })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (!r) throw conflict(`You already reported ${formatNumber(number)}`);
|
||||
return reply.code(201).send({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -270,8 +270,10 @@ export async function contactRoutes(app: FastifyInstance) {
|
||||
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
|
||||
.parse(req.body);
|
||||
const saved = await getSetting(db, 'contacts.contrack');
|
||||
// The saved token only for the saved address: another one gets none (08 §7 #7).
|
||||
const same = saved?.url === b.url.replace(/\/+$/, '');
|
||||
const token =
|
||||
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
|
||||
b.companyToken || (same && saved.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
|
||||
try {
|
||||
return { ok: true, detail: await contrackCheck(b.url, token) };
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
|
||||
import { and, eq, gt, isNull, notExists } from 'drizzle-orm';
|
||||
import { and, eq, gt, isNull, ne, notExists } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '../errors.ts';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
|
||||
import { createSession, setSessionCookie } from '../auth/sessions.ts';
|
||||
import { COOKIE, createSession, getSessionUser, setSessionCookie } from '../auth/sessions.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { decide, verifiedEmail } from '../sso/claims.ts';
|
||||
@@ -146,7 +146,13 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
|
||||
|
||||
if (connecting) {
|
||||
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in.
|
||||
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in, and
|
||||
// only in that browser: a planted sp_oidc can't connect someone else's account (08 §7 #8).
|
||||
const here = req.cookies[COOKIE]
|
||||
? await getSessionUser(db, req.cookies[COOKIE], false)
|
||||
: null;
|
||||
if (here?.sessionId !== connecting)
|
||||
return fail('expired', 'connect finished in another browser than the one that asked');
|
||||
const [sess] = await db
|
||||
.select({ userId: sessions.userId, email: users.email })
|
||||
.from(sessions)
|
||||
@@ -186,7 +192,8 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
|
||||
let userId = owner?.userId;
|
||||
if (!userId) {
|
||||
// A user without a password or an OpenID account yet, by verified email (12 §3.3 step 2).
|
||||
// A user without a password or any connected account yet, by verified email (12 §3.3
|
||||
// step 2). Never an admin: a provider account with their email isn't them (08 §7 #2).
|
||||
const v = verifiedEmail(r.claims, s.trustEmail);
|
||||
const [u] = v
|
||||
? await db
|
||||
@@ -196,13 +203,12 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
and(
|
||||
eq(users.email, v),
|
||||
isNull(users.passwordHash),
|
||||
ne(users.role, 'admin'),
|
||||
notExists(
|
||||
db
|
||||
.select({ id: userIdentities.id })
|
||||
.from(userIdentities)
|
||||
.where(
|
||||
and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
|
||||
),
|
||||
.where(eq(userIdentities.userId, users.id)),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -322,8 +328,15 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
recordLoginFailure(req.ip, login);
|
||||
throw badRequest('The directory refused that username or password');
|
||||
}
|
||||
if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
|
||||
const r = await connectDirectory(db, req.log, req.user!.id, entry, req.ip);
|
||||
if (r === 'taken')
|
||||
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
|
||||
if (r === 'connected_other')
|
||||
throw new HttpError(
|
||||
409,
|
||||
'connected_other',
|
||||
'Another directory account is connected. Ask an admin to disconnect it first',
|
||||
);
|
||||
return { ok: true };
|
||||
});
|
||||
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
|
||||
import { badRequest, forbidden, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { sideFiles } from '../ai/calls.ts';
|
||||
import { ON_THE_CALL } from '../services/cdr.ts';
|
||||
|
||||
/** The recording and, when call transcripts left them, its two side files. */
|
||||
const removeFiles = async (dir: string, filePath: string) => {
|
||||
@@ -109,7 +110,10 @@ export async function recordingRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
/** Admins, or a participant: their extension is on the call this recording belongs to. */
|
||||
/**
|
||||
* Admins, or who was on the call: their extension made or answered it. Not a phone that only
|
||||
* rang (a ring group, a queue): that's a coworker's conversation (08 §7 #3).
|
||||
*/
|
||||
async function mayHear(user: { id: string; role: string }, recordingId: string) {
|
||||
if (user.role === 'admin') return;
|
||||
const [mine] = await db
|
||||
@@ -117,7 +121,13 @@ export async function recordingRoutes(app: FastifyInstance) {
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
|
||||
.where(
|
||||
and(
|
||||
eq(calls.recordingId, recordingId),
|
||||
eq(extensions.userId, user.id),
|
||||
inArray(callExtensions.role, ON_THE_CALL),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!mine) throw forbidden('Not your call');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
|
||||
// but /api/me/reports/summary: the same summary of the calls made to me.
|
||||
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
CALL_DIRECTIONS,
|
||||
@@ -10,12 +11,12 @@ import {
|
||||
type ProviderBalanceDto,
|
||||
type ReportSummary,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { calls, queues, trunks } from '../db/schema.ts';
|
||||
import { callExtensions, calls, extensions, queues, trunks } from '../db/schema.ts';
|
||||
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
|
||||
import { badRequest } from '../errors.ts';
|
||||
import { badRequest, notFound } from '../errors.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { queueStats } from '../services/queuestats.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
|
||||
|
||||
export async function reportRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
const admin = { preHandler: requireAdmin };
|
||||
|
||||
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
|
||||
const { tz, from, to } = await range(db, req.query);
|
||||
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
|
||||
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
|
||||
const { tz, from, to } = await range(db, query);
|
||||
const where = and(
|
||||
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
|
||||
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
|
||||
toExt
|
||||
? inArray(
|
||||
calls.id,
|
||||
db
|
||||
.select({ id: callExtensions.callId })
|
||||
.from(callExtensions)
|
||||
.where(and(eq(callExtensions.extensionId, toExt), ne(callExtensions.role, 'caller'))),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
// Mine: answered and talk time are the calls I picked up, not a coworker in my ring group.
|
||||
const answered = toExt
|
||||
? sql`${calls.answeredByExtensionId} = ${toExt}`
|
||||
: sql`${calls.status} = 'answered'`;
|
||||
const local = sql`(${calls.startedAt} at time zone ${tz})`;
|
||||
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
||||
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
db
|
||||
.select({
|
||||
calls: sql<number>`count(*)::int`,
|
||||
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
|
||||
answered: sql<number>`count(*) filter (where ${answered})::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
|
||||
talk: sql<number>`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 0)::int`,
|
||||
lineIn: sql<number>`round(${lineIn})::int`,
|
||||
lineOut: sql<number>`${lineOut}::int`,
|
||||
avgTalk: sql<
|
||||
number | null
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where),
|
||||
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
},
|
||||
hours,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/reports/summary', admin, (req) => summary(req.query));
|
||||
|
||||
app.get('/api/me/reports/summary', { preHandler: requireAuth }, async (req) => {
|
||||
const [ext] = await db
|
||||
.select({ id: extensions.id })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, req.user!.id));
|
||||
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
|
||||
return summary(req.query, ext.id);
|
||||
});
|
||||
|
||||
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
|
||||
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||
app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trunks)
|
||||
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
return { items, nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||
app.get('/api/reports/queues', admin, async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||
const { from, to } = await range(db, req.query);
|
||||
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq, inArray, isNotNull } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
type Warning,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { dids, extensions, trunks } from '../db/schema.ts';
|
||||
import { dids, extensions, trunks, users } from '../db/schema.ts';
|
||||
import { HttpError, badRequest } from '../errors.ts';
|
||||
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
@@ -63,6 +64,7 @@ const READ_ONLY = new Set<string>([
|
||||
'spam.state',
|
||||
'sms.webhookToken',
|
||||
'setup.completed',
|
||||
'network.probeKeyEnc',
|
||||
]);
|
||||
|
||||
async function toDto(db: Db): Promise<SettingsDto> {
|
||||
@@ -72,10 +74,12 @@ async function toDto(db: Db): Promise<SettingsDto> {
|
||||
'auth.oidc': oidc,
|
||||
'auth.ldap': ldap,
|
||||
spam,
|
||||
'network.probeKeyEnc': probeKey,
|
||||
...rest
|
||||
} = await getAllSettings(db);
|
||||
return {
|
||||
...rest,
|
||||
'network.hasProbeKey': probeKey !== null,
|
||||
spam: {
|
||||
...spam,
|
||||
ipqs: spam.ipqs && { hasKey: true },
|
||||
@@ -257,6 +261,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.patch('/api/settings', async (req) => {
|
||||
const body = z.record(z.string(), z.unknown()).parse(req.body);
|
||||
const values: Record<string, unknown> = {};
|
||||
let contrackMoved = false;
|
||||
for (const [key, raw] of Object.entries(body)) {
|
||||
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
|
||||
throw badRequest(`${key} can't be changed here`);
|
||||
@@ -287,8 +292,13 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
} catch (err) {
|
||||
throw badRequest(`Contrack address: ${(err as Error).message}`);
|
||||
}
|
||||
// The saved tokens only go back to the address they were typed for (08 §7 #7).
|
||||
const url = input?.url.replace(/\/+$/, '');
|
||||
const moved = !!old && !!input && old.url !== url;
|
||||
if (moved && old.companyTokenEnc && !input.companyToken && !input.clearCompanyToken)
|
||||
throw badRequest('The Contrack address changed: enter the company token again');
|
||||
values[key] = input && {
|
||||
url: input.url.replace(/\/+$/, ''),
|
||||
url: url!,
|
||||
publicUrl: input.publicUrl,
|
||||
companyTokenEnc: input.companyToken
|
||||
? encrypt(input.companyToken)
|
||||
@@ -296,6 +306,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
? ''
|
||||
: (old?.companyTokenEnc ?? ''),
|
||||
};
|
||||
if (moved) contrackMoved = true;
|
||||
forgetBook(null);
|
||||
} else if (key === 'auth.ldap') {
|
||||
values[key] = await ldapFromInput(raw);
|
||||
@@ -349,8 +360,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const cert = await readTlsCert(app.config.TLS_DIR);
|
||||
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
|
||||
}
|
||||
// The probe's key was made for its address: another probe doesn't get it (08 §7 #9).
|
||||
if ('network.probeUrl' in values && values['network.probeUrl'] !== before['network.probeUrl'])
|
||||
values['network.probeKeyEnc'] = null;
|
||||
for (const [key, value] of Object.entries(values))
|
||||
await setSetting(db, key as SettingKey, value as never);
|
||||
// People's own Contrack tokens were for the old address: they connect again (08 §7 #7).
|
||||
if (contrackMoved)
|
||||
await db
|
||||
.update(users)
|
||||
.set({ contrackTokenEnc: null })
|
||||
.where(isNotNull(users.contrackTokenEnc));
|
||||
// A new IPQualityScore key may have credits: don't wait for spamQuotaCheck (14 §3).
|
||||
if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key');
|
||||
const changed = Object.keys(values).filter(
|
||||
@@ -474,6 +494,25 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* A new key for the network test probe (08 §7 #9): shown this once, for its PROBE_KEY. Made for
|
||||
* the probe address set now; the old key stops working on the next test.
|
||||
*/
|
||||
app.post('/api/settings/network/probe-key', async (req) => {
|
||||
if (!(await getSetting(db, 'network.probeUrl')))
|
||||
throw badRequest('Set the probe address first');
|
||||
const key = randomBytes(32).toString('base64url');
|
||||
await setSetting(db, 'network.probeKeyEnc', encrypt(key));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'settings',
|
||||
summary: 'Made a new network test probe key',
|
||||
});
|
||||
return { key };
|
||||
});
|
||||
|
||||
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
|
||||
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
|
||||
const ip = await detectPublicIp();
|
||||
|
||||
@@ -208,21 +208,18 @@ export async function trunkRoutes(app: FastifyInstance) {
|
||||
if (!t.enabled) throw badRequest('Turn the trunk on first');
|
||||
if (!app.live.amiConnected)
|
||||
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
|
||||
// No OPTIONS over TLS (see the trunk's aor in generator/pjsip.ts).
|
||||
const { live, rttMs } = await app.live.testTrunk(id, { qualify: t.transport !== 'tls' });
|
||||
const { live, rttMs } = await app.live.testTrunk(id);
|
||||
const reg = t.authMode === 'registration';
|
||||
const detail =
|
||||
reg && live.registration === 'registered'
|
||||
? `Registered with ${t.serverHost}`
|
||||
: reg && live.registration === 'rejected'
|
||||
? `${t.serverHost} rejected the username or password`
|
||||
: !reg && t.transport === 'tls'
|
||||
? `Can't check a TLS trunk without registration from here: make a test call.`
|
||||
: !live.reachable
|
||||
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
|
||||
: reg
|
||||
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
|
||||
: `${t.serverHost} answers (IP authentication: no registration)`;
|
||||
: !live.reachable
|
||||
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
|
||||
: reg
|
||||
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
|
||||
: `${t.serverHost} answers (IP authentication: no registration)`;
|
||||
return { registration: reg ? live.registration : 'unknown', rttMs, detail };
|
||||
});
|
||||
|
||||
|
||||
@@ -138,6 +138,9 @@ export async function sendCallReports(d: {
|
||||
aiDoneAt: recordings.aiDoneAt,
|
||||
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
||||
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
|
||||
// Made or answered it: only rang means a coworker's conversation (08 §7 #3).
|
||||
heard: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
||||
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} in ('caller', 'answered'))`,
|
||||
})
|
||||
.from(calls)
|
||||
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
||||
@@ -177,9 +180,9 @@ export async function sendCallReports(d: {
|
||||
toNumber: r.c.toNumber,
|
||||
talkSeconds: r.c.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
|
||||
recorded: r.c.recordingId !== null,
|
||||
summary: r.summary,
|
||||
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
|
||||
recorded: r.heard && r.c.recordingId !== null,
|
||||
summary: r.heard ? r.summary : null,
|
||||
summaryPending: r.heard && r.c.recordingId !== null && r.aiDoneAt === null,
|
||||
}));
|
||||
const withSummaries =
|
||||
e.callReportSummaries &&
|
||||
|
||||
@@ -14,6 +14,12 @@ import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/
|
||||
import { durationMs } from '../media/audio.ts';
|
||||
|
||||
type CdrRow = typeof cdr.$inferSelect;
|
||||
|
||||
/**
|
||||
* The call_extensions roles of someone who was on the call, not just rung: they may hear its
|
||||
* recording and read its transcript and summary (08 §7 #3).
|
||||
*/
|
||||
export const ON_THE_CALL = ['caller', 'answered'];
|
||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
|
||||
/** cdr_manager.conf has usegmtime = yes: "2026-10-18 19:28:37" is UTC. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BlockList, isIPv4 } from 'node:net';
|
||||
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
|
||||
import type { AmiClient } from '../ami/client.ts';
|
||||
import type { Config } from '../config.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { detectPublicIp } from '../jobs.ts';
|
||||
import { getAllSettings } from './settings.ts';
|
||||
@@ -221,15 +222,23 @@ export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
|
||||
);
|
||||
|
||||
out.push(
|
||||
s['network.probeUrl']
|
||||
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
|
||||
: check(
|
||||
!s['network.probeUrl']
|
||||
? check(
|
||||
'probe',
|
||||
'Network test probe',
|
||||
'skip',
|
||||
'None set, so nothing is tested from outside your network.',
|
||||
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
|
||||
),
|
||||
)
|
||||
: s['network.probeKeyEnc']
|
||||
? check('probe', 'Network test probe', 'ok', `${s['network.probeUrl']}, with its key`)
|
||||
: check(
|
||||
'probe',
|
||||
'Network test probe',
|
||||
'warn',
|
||||
`${s['network.probeUrl']} has no key: anyone who finds it can use it.`,
|
||||
'Settings → Network → Generate key, then set it as PROBE_KEY on the probe and restart it (strongly recommended).',
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -289,14 +298,20 @@ export async function networkTest(
|
||||
let ip: string | null = null;
|
||||
let results: ProbeResult[] = [];
|
||||
let probeError: string | null = null;
|
||||
let keyRefused = false;
|
||||
const key = s['network.probeKeyEnc'];
|
||||
try {
|
||||
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(key && { authorization: `Bearer ${decrypt(key)}` }),
|
||||
},
|
||||
body: JSON.stringify({ host, tests }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
|
||||
keyRefused = r.status === 401;
|
||||
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
|
||||
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
|
||||
// Datagrams sent last arrive just after the answer.
|
||||
@@ -316,7 +331,9 @@ export async function networkTest(
|
||||
'Network test probe',
|
||||
'fail',
|
||||
`${probe} didn't answer: ${probeError}`,
|
||||
'Check the probe runs and is reachable from this server, then try again.',
|
||||
keyRefused
|
||||
? "The probe's PROBE_KEY isn't this server's key. Settings → Network → Generate key, set it as PROBE_KEY on the probe and restart it."
|
||||
: 'Check the probe runs and is reachable from this server, then try again.',
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Fraud alerts (08 §8.3): risky calls the PBX placed, voip.ms calls it didn't place, and a
|
||||
// balance that drops fast. Each is an email to every admin and a warning event.
|
||||
import { and, eq, gt, gte, isNotNull, like, lte } from 'drizzle-orm';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { calls, cdr, dids, trunks } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import {
|
||||
getBalance,
|
||||
getCdr,
|
||||
VoipmsError,
|
||||
type VoipmsCall,
|
||||
type VoipmsCreds,
|
||||
} from '../integrations/voipms/client.ts';
|
||||
import { logEvent } from './events.ts';
|
||||
import { getSetting } from './settings.ts';
|
||||
|
||||
/** NANP area codes outside the US and Canada: dialed like a home call, billed like abroad. (verify) */
|
||||
const ABROAD_NANP = new Set(
|
||||
'242 246 264 268 284 345 441 473 649 658 664 721 758 767 784 809 829 849 868 869 876'.split(' '),
|
||||
);
|
||||
|
||||
/** Why a dialed number is worth an email, or null. 10 digits count as 1 + them. */
|
||||
export function riskyDestination(dialed: string): string | null {
|
||||
const n = /^[2-9]\d{9}$/.test(dialed) ? `1${dialed}` : dialed;
|
||||
if (n.startsWith('011')) return 'international';
|
||||
const m = /^1(\d{3})(\d{3})\d{4}$/.exec(n);
|
||||
if (!m) return null;
|
||||
if (m[1] === '900' || m[2] === '976') return 'premium';
|
||||
if (ABROAD_NANP.has(m[1]!)) return 'international rates (outside the US and Canada)';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The number a trunk Dial sent (`PJSIP/<number>@trunk-<id>,120`), or null. */
|
||||
export const trunkDialNumber = (lastdata: string | null): string | null =>
|
||||
/^PJSIP\/([^@]+)@trunk-/.exec(lastdata ?? '')?.[1] ?? null;
|
||||
|
||||
const tail = (n: string) => n.replace(/\D/g, '').slice(-10);
|
||||
const MIN = 60_000;
|
||||
const day = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
export type FraudDeps = {
|
||||
db: Db;
|
||||
voipmsUrl: string;
|
||||
mail: (subject: string, text: string) => Promise<void>;
|
||||
log: { warn(o: object, m: string): void };
|
||||
now: () => Date;
|
||||
};
|
||||
|
||||
export function createFraudWatch(d: FraudDeps) {
|
||||
const when = async (at: Date) =>
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
timeZone: await getSetting(d.db, 'general.timezone'),
|
||||
}).format(at);
|
||||
const warn = (code: string, source: string, message: string) =>
|
||||
logEvent(d.db, { severity: 'warning', code, source, message });
|
||||
|
||||
// Calls are read 10 s behind the clock, so a row whose insert is still committing isn't skipped.
|
||||
// ponytail: the cursor starts at api start; a call made while the api was down has no row anyway.
|
||||
let since = new Date(d.now().getTime() - 10_000);
|
||||
const riskyCallWatch = async () => {
|
||||
const until = new Date(d.now().getTime() - 10_000);
|
||||
const rows = await d.db
|
||||
.select()
|
||||
.from(calls)
|
||||
.where(
|
||||
and(
|
||||
eq(calls.direction, 'outbound'),
|
||||
gt(calls.createdAt, since),
|
||||
lte(calls.createdAt, until),
|
||||
),
|
||||
)
|
||||
.orderBy(calls.createdAt);
|
||||
since = until;
|
||||
if (!(await getSetting(d.db, 'alerts.riskyCalls'))) return;
|
||||
const lines: string[] = [];
|
||||
for (const c of rows) {
|
||||
const why = riskyDestination(c.toNumber ?? '');
|
||||
if (why)
|
||||
lines.push(
|
||||
`${await when(c.startedAt)} from ${c.fromNumber ?? '?'} to ${formatNumber(c.toNumber!)}: ` +
|
||||
`${why}, ${c.status}, ${c.talkSeconds} s talking`,
|
||||
);
|
||||
}
|
||||
if (!lines.length) return;
|
||||
await warn(
|
||||
'fraud.risky_call',
|
||||
'Fraud alerts',
|
||||
`${lines.length} call(s) to international or premium numbers`,
|
||||
);
|
||||
await d.mail(
|
||||
`Calls to risky numbers (${lines.length})`,
|
||||
'The phone system placed calls to numbers that can cost a lot:\n\n' +
|
||||
`${lines.join('\n')}\n\n` +
|
||||
"If you don't recognise them, someone may have a phone's password: change it " +
|
||||
'(Extensions → the extension → New SIP password) and check Outbound rules.\n' +
|
||||
'These emails can be turned off on Settings → Security → Fraud alerts.',
|
||||
);
|
||||
};
|
||||
|
||||
// voip.ms records already reported, for 3 hours (08 §8.3).
|
||||
const reported = new Map<string, number>();
|
||||
async function callCheck(t: { name: string; username: string }, creds: VoipmsCreds) {
|
||||
const now = d.now().getTime();
|
||||
for (const [id, at] of reported) if (at < now - 180 * MIN) reported.delete(id);
|
||||
const from = new Date(now - 120 * MIN);
|
||||
const to = new Date(now - 10 * MIN);
|
||||
// uniqueid isn't in voip.ms's examples (06 §5.1), so the time and number make the key too.
|
||||
const key = (r: VoipmsCall) => `${r.uniqueid}|${r.at.getTime()}|${r.destination}`;
|
||||
const records = (
|
||||
await getCdr(d.voipmsUrl, creds, { from: day(from), to: day(to), account: t.username })
|
||||
).filter((r) => r.at >= from && r.at <= to && !reported.has(key(r)));
|
||||
if (!records.length) return;
|
||||
const ours = new Set((await d.db.select({ n: dids.number }).from(dids)).map((x) => tail(x.n)));
|
||||
// Every call the PBX sent to a trunk, forwards and 911 included (03 §6.12, §6.14).
|
||||
const sent = (
|
||||
await d.db
|
||||
.select({ at: cdr.startAt, data: cdr.lastdata })
|
||||
.from(cdr)
|
||||
.where(
|
||||
and(
|
||||
like(cdr.lastdata, 'PJSIP/%@trunk-%'),
|
||||
gte(cdr.startAt, new Date(from.getTime() - 5 * MIN)),
|
||||
lte(cdr.startAt, new Date(to.getTime() + 5 * MIN)),
|
||||
),
|
||||
)
|
||||
).flatMap((r) => {
|
||||
const n = trunkDialNumber(r.data);
|
||||
return n && r.at ? [{ n: tail(n), at: r.at.getTime() }] : [];
|
||||
});
|
||||
const strange: VoipmsCall[] = [];
|
||||
for (const r of records) {
|
||||
reported.set(key(r), r.at.getTime());
|
||||
if (r.account && r.account !== t.username) continue;
|
||||
if (ours.has(tail(r.destination))) continue; // incoming, to one of our numbers
|
||||
const i = sent.findIndex(
|
||||
(s) => s.n === tail(r.destination) && Math.abs(s.at - r.at.getTime()) <= 5 * MIN,
|
||||
);
|
||||
if (i >= 0) sent.splice(i, 1);
|
||||
else strange.push(r);
|
||||
}
|
||||
if (!strange.length) return;
|
||||
const lines = await Promise.all(
|
||||
strange.map(
|
||||
async (r) =>
|
||||
`${await when(r.at)} to ${r.destination}: ${r.disposition.toLowerCase() || '?'}, ` +
|
||||
`${r.seconds} s, cost ${r.total}`,
|
||||
),
|
||||
);
|
||||
await warn(
|
||||
'fraud.provider_call',
|
||||
`Trunk ${t.name}`,
|
||||
`voip.ms has ${strange.length} call(s) on ${t.username} that this phone system didn't make`,
|
||||
);
|
||||
await d.mail(
|
||||
`voip.ms calls this phone system didn't make (${strange.length})`,
|
||||
`voip.ms billed these calls on sub-account ${t.username} (trunk ${t.name}), and this ` +
|
||||
'phone system has no record of making them:\n\n' +
|
||||
`${lines.join('\n')}\n\n` +
|
||||
'Someone else may have the sub-account password. At voip.ms: change the sub-account ' +
|
||||
"password (then paste it on Trunks), and turn on its IP restriction with this server's " +
|
||||
'public IP. Check the voip.ms call records too.\n' +
|
||||
'A call made while SelfPhoneNG was restarting, or by another device using the same ' +
|
||||
'sub-account, also shows up here.\n' +
|
||||
'These emails can be turned off on Settings → Security → Fraud alerts.',
|
||||
);
|
||||
}
|
||||
|
||||
// Balance samples per API login (one account can have several trunks), the last hour's.
|
||||
const balances = new Map<string, { at: number; v: number }[]>();
|
||||
const balanceMailed = new Map<string, number>();
|
||||
async function balanceCheck(creds: VoipmsCreds, limit: number) {
|
||||
const now = d.now().getTime();
|
||||
const v = await getBalance(d.voipmsUrl, creds);
|
||||
const samples = [
|
||||
...(balances.get(creds.username) ?? []).filter((s) => s.at >= now - 60 * MIN),
|
||||
{ at: now, v },
|
||||
];
|
||||
balances.set(creds.username, samples);
|
||||
const high = Math.max(...samples.map((s) => s.v));
|
||||
if (high - v <= limit || (balanceMailed.get(creds.username) ?? 0) > now - 60 * MIN) return;
|
||||
balanceMailed.set(creds.username, now);
|
||||
const drop = (high - v).toFixed(2);
|
||||
await warn(
|
||||
'fraud.balance_drop',
|
||||
'Fraud alerts',
|
||||
`The voip.ms balance dropped ${drop} in an hour`,
|
||||
);
|
||||
await d.mail(
|
||||
`voip.ms balance dropped ${drop} in an hour`,
|
||||
`The voip.ms balance of ${creds.username} went from ${high.toFixed(2)} to ${v.toFixed(2)} ` +
|
||||
`within the last hour, more than the ${limit} set on Settings → Security → Fraud alerts.\n\n` +
|
||||
"If that isn't your own calling, check the call records at voip.ms, change the " +
|
||||
'sub-account and API passwords, and turn on IP restriction on the sub-account.',
|
||||
);
|
||||
}
|
||||
|
||||
const providerWatch = async () => {
|
||||
const checkCalls = await getSetting(d.db, 'alerts.providerCalls');
|
||||
const limit = await getSetting(d.db, 'alerts.balanceDropPerHour');
|
||||
if (!checkCalls && !limit) return;
|
||||
const rows = await d.db
|
||||
.select({
|
||||
name: trunks.name,
|
||||
username: trunks.username,
|
||||
apiUser: trunks.voipmsApiUsername,
|
||||
apiPass: trunks.voipmsApiPasswordEnc,
|
||||
})
|
||||
.from(trunks)
|
||||
.where(
|
||||
and(
|
||||
eq(trunks.enabled, true),
|
||||
eq(trunks.provider, 'voipms'),
|
||||
isNotNull(trunks.voipmsApiUsername),
|
||||
isNotNull(trunks.voipmsApiPasswordEnc),
|
||||
),
|
||||
);
|
||||
const balanceAsked = new Set<string>();
|
||||
for (const t of rows) {
|
||||
const creds = { username: t.apiUser!, password: decrypt(t.apiPass!) };
|
||||
try {
|
||||
if (checkCalls && t.username)
|
||||
await callCheck({ name: t.name, username: t.username }, creds);
|
||||
if (limit && !balanceAsked.has(creds.username)) {
|
||||
balanceAsked.add(creds.username);
|
||||
await balanceCheck(creds, limit);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof VoipmsError)) throw e;
|
||||
d.log.warn({ trunk: t.name, status: e.status }, 'voip.ms fraud check failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { riskyCallWatch, providerWatch };
|
||||
}
|
||||
@@ -121,11 +121,12 @@ async function transcribeMessage(d: VoicemailDeps, row: Row): Promise<string | n
|
||||
async function announce(d: VoicemailDeps, box: Box, found: Row) {
|
||||
const transcript = await transcribeMessage(d, found);
|
||||
const row = { ...found, transcript };
|
||||
// Every mailbox, with or without a user (the live message is only for the user's own).
|
||||
void enqueueWebhook(d.db, 'voicemail.new', {
|
||||
...toVoicemailDto(row),
|
||||
extension: box.number,
|
||||
}).catch((err: unknown) => d.log.error({ err }, 'webhook enqueue failed'));
|
||||
// Every mailbox, with or without a user (the live message is only for the user's own). Not
|
||||
// the transcript: webhooks go off this server, and transcripts never do (10 §1, 08 §7 #6).
|
||||
const { transcript: _words, ...hook } = toVoicemailDto(row);
|
||||
void enqueueWebhook(d.db, 'voicemail.new', { ...hook, extension: box.number }).catch(
|
||||
(err: unknown) => d.log.error({ err }, 'webhook enqueue failed'),
|
||||
);
|
||||
if (box.userId)
|
||||
d.live.emit('message', {
|
||||
type: 'voicemail.new',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
|
||||
// email. Never a new user (owner, 2026-10-25).
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, notExists } from 'drizzle-orm';
|
||||
import { deleteUserSessions } from '../auth/sessions.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { userIdentities, users } from '../db/schema.ts';
|
||||
@@ -22,7 +22,21 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
|
||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
|
||||
if (byId) return byId.u;
|
||||
if (!e.email) return null;
|
||||
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
|
||||
// Nobody with another way in either: an OpenID user's account is theirs to connect (08 §7 #1).
|
||||
const [byEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, e.email),
|
||||
notExists(
|
||||
db
|
||||
.select({ id: userIdentities.id })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, users.id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return byEmail && byEmailOk(byEmail) ? byEmail : null;
|
||||
}
|
||||
|
||||
@@ -35,19 +49,27 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
|
||||
const byEmailOk = (u: UserRow) =>
|
||||
u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
|
||||
|
||||
/** Connect a directory account the person proved (My settings, 12 §4.3). */
|
||||
/**
|
||||
* Connect a directory account the person proved (My settings, 12 §4.3). Not another one in place
|
||||
* of the one they have: the leaver check follows it, so swapping is an admin's job (08 §7 #5).
|
||||
*/
|
||||
export async function connectDirectory(
|
||||
db: Db,
|
||||
log: { info(o: object, m: string): void },
|
||||
userId: string,
|
||||
e: DirectoryEntry,
|
||||
ip: string,
|
||||
): Promise<'connected' | 'taken'> {
|
||||
): Promise<'connected' | 'taken' | 'connected_other'> {
|
||||
const [owner] = await db
|
||||
.select({ userId: userIdentities.userId })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
|
||||
if (owner && owner.userId !== userId) return 'taken';
|
||||
const [mine] = await db
|
||||
.select({ subject: userIdentities.subject })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
|
||||
if (mine && mine.subject !== e.id) return 'connected_other';
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(userIdentities)
|
||||
@@ -101,11 +123,8 @@ export async function directorySignIn(
|
||||
entityId: u.id,
|
||||
summary: `${u.email} connected directory account ${e.dn}`,
|
||||
});
|
||||
} else if (mine.subject !== e.id) {
|
||||
// Their email matches, but another directory account is already theirs: not this one.
|
||||
log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
|
||||
return null;
|
||||
} else
|
||||
// Found by this account's id (directoryUserFor never matches by email someone connected).
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email: e.email, lastUsedAt: new Date() })
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A new Asterisk in asterisk/Dockerfile must go through the upgrade check first (verified.ts).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { VERIFIED_ASTERISK, pinnedAsterisk } from './asterisk/verified.ts';
|
||||
|
||||
describe('the pinned Asterisk', () => {
|
||||
it('is the version the upgrade check last passed on', () => {
|
||||
expect(
|
||||
pinnedAsterisk(),
|
||||
`asterisk/Dockerfile pins ${pinnedAsterisk()}, the upgrade check last passed on ` +
|
||||
`${VERIFIED_ASTERISK}. Build the image, run \`pnpm test:asterisk-upgrade\` and ` +
|
||||
'`pnpm test:asterisk`, act on what moved, then set VERIFIED_ASTERISK in ' +
|
||||
'apps/api/test/asterisk/verified.ts',
|
||||
).toBe(VERIFIED_ASTERISK);
|
||||
});
|
||||
|
||||
it('is the one spec 01 §2 names, and its copy of the Dockerfile is the file', () => {
|
||||
const spec = readFileSync(
|
||||
new URL('../../../docs/spec/01-infrastructure.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
expect(spec).toContain(`**Pinned: \`${pinnedAsterisk()}\``);
|
||||
const copy = /^## 2\. [\s\S]*?^```dockerfile\n([\s\S]*?)^```$/m.exec(spec)?.[1];
|
||||
const file = readFileSync(new URL('../../../asterisk/Dockerfile', import.meta.url), 'utf8');
|
||||
expect(copy, 'spec 01 §2 has a copy of asterisk/Dockerfile: keep it the same').toBe(file);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const CALL_SECONDS = Number(process.env.CALL_SECONDS ?? 30);
|
||||
@@ -126,11 +127,8 @@ async function main() {
|
||||
spawnSync('mkdir', ['-p', conf]);
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
||||
// Test-only manager.conf: reachable through the published port (the real one is 127.0.0.1-only).
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
// The real manager.conf, reachable through the published port (test/asterisk/manager-conf.ts).
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
phoneConfig(dir, '101', 5070, 440);
|
||||
phoneConfig(dir, '102', 5072, 880);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { AmiMessage } from '../../src/ami/events.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot, SnapExtension } from '../../src/generator/load.ts';
|
||||
import { providerDialplan, providerPjsip } from './provider.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const OUT = fileURLToPath(new URL('../fixtures/cdr/', import.meta.url));
|
||||
@@ -243,10 +244,7 @@ async function main() {
|
||||
);
|
||||
}
|
||||
const runPhones = phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
port: AMI_PORT,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { providerPjsip } from './provider.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const NET = 'sp-cnam';
|
||||
@@ -223,10 +224,7 @@ async function main() {
|
||||
);
|
||||
}
|
||||
phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
const problems: string[] = [];
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The real manager.conf.template, read/write classes included, so a class the api needs and the
|
||||
// template lacks fails the tests. Only opened up for the host to reach AMI through the published
|
||||
// port: the real one is 127.0.0.1-only.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export function testManagerConf(): string {
|
||||
const real = readFileSync(
|
||||
new URL('../../../../asterisk/conf/manager.conf.template', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const open = real
|
||||
.replace(/^bindaddr = 127\.0\.0\.1$/m, 'bindaddr = 0.0.0.0')
|
||||
.replace(/^permit = 127\.0\.0\.1\/255\.255\.255\.255$/m, 'permit = 0.0.0.0/0.0.0.0');
|
||||
if (open.includes('127.0.0.1'))
|
||||
throw new Error('manager.conf.template changed: update manager-conf.ts');
|
||||
return open;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
|
||||
import { providerDialplan, providerPjsip } from './provider.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const RECORD = process.argv.includes('--record')
|
||||
@@ -200,10 +201,7 @@ async function main() {
|
||||
);
|
||||
}
|
||||
phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
|
||||
const problems: string[] = [];
|
||||
let recorder: Socket | undefined;
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Asterisk upgrade check (01 §2): boot the pinned image and measure each behaviour in verified.ts,
|
||||
// then list every one that moved and what depends on it. A second Asterisk plays a provider on
|
||||
// TLS with a certificate from our own CA for "provider.test", so the qualify check sees a real
|
||||
// host name. Needs Docker and the image built from asterisk/Dockerfile (ASTERISK_IMAGE, default
|
||||
// "selfphoneng-asterisk").
|
||||
// Usage: pnpm test:asterisk-upgrade
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { field, type AmiMessage } from '../../src/ami/events.ts';
|
||||
import { FACTS, VERIFIED_ASTERISK, pinnedAsterisk, type Fact } from './verified.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const GOLDEN = fileURLToPath(
|
||||
new URL('../../src/generator/__golden__/one-user/expected', import.meta.url),
|
||||
);
|
||||
const NET = 'sp-upgrade';
|
||||
const PBX = 'sp-upgrade-pbx';
|
||||
const PROVIDER = 'sp-upgrade-provider';
|
||||
const HOST = 'provider.test';
|
||||
const AMI_PORT = 15040;
|
||||
const AMI_SECRET = 'upgrade-test-secret';
|
||||
const SUB = { user: 'upgrade', pass: 'UpgradeSecret42' };
|
||||
const RISKY = ['app_system', 'func_shell', 'app_disa', 'res_agi', 'app_externalivr'];
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...args: string[]) => {
|
||||
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
||||
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
||||
};
|
||||
const log = (...a: unknown[]) => console.log('[upgrade-test]', ...a);
|
||||
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
if (await fn()) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
/** A CA, and a certificate from it for HOST: what the PBX trusts and the provider shows. */
|
||||
function certificates(dir: string) {
|
||||
const r = docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${dir}:/out`,
|
||||
'--entrypoint',
|
||||
'sh',
|
||||
IMAGE,
|
||||
'-c',
|
||||
[
|
||||
'cd /out',
|
||||
'openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=sp-upgrade-ca -keyout ca.key -out ca.pem',
|
||||
`openssl req -newkey rsa:2048 -nodes -subj /CN=${HOST} -keyout privkey.pem -out host.csr`,
|
||||
`printf 'subjectAltName=DNS:${HOST}' > host.ext`,
|
||||
'openssl x509 -req -in host.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile host.ext -out fullchain.pem',
|
||||
'chmod 644 *',
|
||||
].join(' && '),
|
||||
);
|
||||
if (!r.ok) throw new Error(`certificates: ${r.out}`);
|
||||
}
|
||||
|
||||
const tlsTransport = (name: string, port: number, client: boolean) => `
|
||||
[${name}]
|
||||
type = transport
|
||||
protocol = tls
|
||||
bind = 0.0.0.0:${port}
|
||||
cert_file = /tls/fullchain.pem
|
||||
priv_key_file = /tls/privkey.pem
|
||||
method = tlsv1_2
|
||||
${client ? 'verify_server = yes\nca_list_file = /tls/ca.pem\n' : ''}`;
|
||||
|
||||
/** The provider: TLS only, one account that may register. */
|
||||
const providerPjsip = `${tlsTransport('transport-tls', 5061, false)}
|
||||
[${SUB.user}]
|
||||
type = endpoint
|
||||
context = default
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
auth = ${SUB.user}-auth
|
||||
aors = ${SUB.user}
|
||||
|
||||
[${SUB.user}-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = ${SUB.user}
|
||||
password = ${SUB.pass}
|
||||
|
||||
[${SUB.user}]
|
||||
type = aor
|
||||
max_contacts = 1
|
||||
remove_existing = yes
|
||||
`;
|
||||
|
||||
/**
|
||||
* Added to the one-user golden config: a trunk to the provider by name over TLS. The registration
|
||||
* is the control (REGISTER verifies the name on every version we know); the AOR's qualify is the
|
||||
* fact being measured.
|
||||
*/
|
||||
const pbxPjsip = `${tlsTransport('sp-upgrade-tls', 5062, true)}
|
||||
[sp-upgrade-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = ${SUB.user}
|
||||
password = ${SUB.pass}
|
||||
|
||||
[sp-upgrade-reg]
|
||||
type = registration
|
||||
transport = sp-upgrade-tls
|
||||
outbound_auth = sp-upgrade-auth
|
||||
server_uri = sip:${HOST}:5061;transport=tls
|
||||
client_uri = sip:${SUB.user}@${HOST}:5061;transport=tls
|
||||
contact_user = ${SUB.user}
|
||||
retry_interval = 5
|
||||
|
||||
[sp-upgrade-trunk]
|
||||
type = aor
|
||||
contact = sip:${HOST}:5061;transport=tls
|
||||
qualify_frequency = 5
|
||||
|
||||
[sp-upgrade-trunk]
|
||||
type = endpoint
|
||||
transport = sp-upgrade-tls
|
||||
outbound_auth = sp-upgrade-auth
|
||||
aors = sp-upgrade-trunk
|
||||
context = sp-upgrade-test
|
||||
`;
|
||||
|
||||
const pbxDialplan = `
|
||||
[sp-upgrade-test]
|
||||
exten => s,1,Answer()
|
||||
same => n,Hangup()
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const pinned = pinnedAsterisk();
|
||||
const tls = mkdtempSync(join(tmpdir(), 'sp-upgrade-tls-'));
|
||||
const pbxConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-pbx-'));
|
||||
const provConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-prov-'));
|
||||
cpSync(GOLDEN, pbxConf, { recursive: true });
|
||||
appendFileSync(join(pbxConf, 'pjsip_selfphoneng.conf'), pbxPjsip);
|
||||
appendFileSync(join(pbxConf, 'extensions_selfphoneng.conf'), pbxDialplan);
|
||||
for (const f of readdirSync(GOLDEN))
|
||||
writeFileSync(join(provConf, f), f === 'pjsip_selfphoneng.conf' ? providerPjsip : '');
|
||||
writeFileSync(join(pbxConf, 'manager.conf.template'), testManagerConf());
|
||||
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
port: AMI_PORT,
|
||||
username: 'selfphoneng',
|
||||
secret: AMI_SECRET,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
backoffMs: [500],
|
||||
});
|
||||
const asterisk = (name: string, conf: string, extra: string[]) =>
|
||||
docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
name,
|
||||
'--network',
|
||||
NET,
|
||||
...extra,
|
||||
'-e',
|
||||
`AMI_SECRET=${AMI_SECRET}`,
|
||||
'-v',
|
||||
`${conf}:/etc/asterisk/selfphoneng`,
|
||||
'-v',
|
||||
`${tls}:/tls:ro`,
|
||||
IMAGE,
|
||||
);
|
||||
const booted = (name: string) =>
|
||||
waitFor(
|
||||
`${name} to boot`,
|
||||
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
|
||||
);
|
||||
|
||||
const seen = {} as Record<Fact, boolean>;
|
||||
const problems: string[] = [];
|
||||
try {
|
||||
docker('rm', '-f', PBX, PROVIDER);
|
||||
docker('network', 'create', NET);
|
||||
certificates(tls);
|
||||
const prov = asterisk(PROVIDER, provConf, ['--network-alias', HOST]);
|
||||
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
|
||||
await booted(PROVIDER);
|
||||
const pbx = asterisk(PBX, pbxConf, [
|
||||
'-p',
|
||||
`127.0.0.1:${AMI_PORT}:5038`,
|
||||
'-v',
|
||||
`${join(pbxConf, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
|
||||
]);
|
||||
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
|
||||
await booted(PBX);
|
||||
|
||||
const running = docker('exec', PBX, 'asterisk', '-V').out.trim();
|
||||
if (running !== `Asterisk ${pinned}`)
|
||||
throw new Error(
|
||||
`the image runs "${running}" but asterisk/Dockerfile pins ${pinned}: rebuild it ` +
|
||||
`(docker build -t ${IMAGE} asterisk)`,
|
||||
);
|
||||
log(`${running}, last verified on ${VERIFIED_ASTERISK}`);
|
||||
|
||||
ami.start();
|
||||
await new Promise((r) => ami.once('connected', r));
|
||||
const cli = (cmd: string) => ami.command(cmd);
|
||||
|
||||
const modules = await Promise.all(RISKY.map((m) => cli(`module show like ${m}`)));
|
||||
seen.riskyModulesGone = modules.every((o) => /\b0 modules loaded/.test(o));
|
||||
const ep101 = await cli('pjsip show endpoint 101');
|
||||
seen.mwiSubscribeReplaces = /mwi_subscribe_replaces_unsolicited\s*:\s*yes/.test(ep101);
|
||||
// set_var lines show as their own rows: " __SP_DIRECTION : internal".
|
||||
seen.setVarKeepsUnderscores = /^\s*__SP_DIRECTION\s*:\s*internal\s*$/m.test(ep101);
|
||||
const commands = await cli('manager show commands');
|
||||
seen.voicemailMoveRemove =
|
||||
/\bVoicemailMove\b/.test(commands) && /\bVoicemailRemove\b/.test(commands);
|
||||
|
||||
// CDR: one Local call through the test context.
|
||||
const cdr = new Promise<AmiMessage>((r) => ami.once('event:Cdr', r));
|
||||
await cli('channel originate Local/s@sp-upgrade-test application Wait 1');
|
||||
const m = await Promise.race([
|
||||
cdr,
|
||||
sleep(15_000).then(() => {
|
||||
throw new Error('no Cdr event within 15 s of a call');
|
||||
}),
|
||||
]);
|
||||
seen.cdrLinkedId = !!field(m, 'LinkedID');
|
||||
const start = field(m, 'StartTime') ?? '';
|
||||
seen.cdrTimesUtcNoZone =
|
||||
/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(start) &&
|
||||
Math.abs(Date.parse(`${start.replace(' ', 'T')}Z`) - Date.now()) < 120_000;
|
||||
|
||||
// TLS: the registration must work first, or the qualify answer says nothing.
|
||||
await waitFor(
|
||||
'the TLS registration to the provider (the control)',
|
||||
async () => /sp-upgrade-reg\S*\s.*Registered/.test(await cli('pjsip show registrations')),
|
||||
45,
|
||||
).catch(async (e: Error) => {
|
||||
throw new Error(`${e.message}:\n${await cli('pjsip show registrations')}`);
|
||||
});
|
||||
let contacts = '';
|
||||
for (let i = 0; i < 20; i++) {
|
||||
contacts = await cli('pjsip show contacts');
|
||||
if (/sp-upgrade-trunk\/\S+\s.*\b(Avail|Unavail)/.test(contacts)) break;
|
||||
await sleep(1000);
|
||||
}
|
||||
seen.tlsQualifyByHostname = /sp-upgrade-trunk\/\S+\s.*\bAvail\b/.test(contacts);
|
||||
if (!seen.tlsQualifyByHostname) {
|
||||
const why = docker('logs', PBX)
|
||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||
.out.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.split('\n');
|
||||
const line = contacts.split('\n').find((l) => l.includes('sp-upgrade-trunk'));
|
||||
log('qualify over TLS failed:', line);
|
||||
for (const l of why
|
||||
.filter((x) => /sp-upgrade-trunk|identity|EUNSUP|qualif/i.test(x))
|
||||
.slice(-4))
|
||||
log(l.trim());
|
||||
}
|
||||
|
||||
const listed = await ami.actionList({ Action: 'PJSIPShowContacts' });
|
||||
seen.staticContactsInShowContacts = listed.some((x) => x.Aor === 'sp-upgrade-trunk');
|
||||
const detail = await ami.actionList({
|
||||
Action: 'PJSIPShowEndpoint',
|
||||
Endpoint: 'sp-upgrade-trunk',
|
||||
});
|
||||
seen.endpointShowsContactStatus = detail.some(
|
||||
(x) => x.Event === 'ContactStatusDetail' && x.AOR === 'sp-upgrade-trunk',
|
||||
);
|
||||
} catch (e) {
|
||||
problems.push((e as Error).message);
|
||||
} finally {
|
||||
ami.stop();
|
||||
docker('rm', '-f', PBX, PROVIDER);
|
||||
docker('network', 'rm', NET);
|
||||
for (const d of [tls, pbxConf, provConf]) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const moved = (Object.keys(FACTS) as Fact[]).filter(
|
||||
(k) => k in seen && seen[k] !== FACTS[k].expect,
|
||||
);
|
||||
for (const k of Object.keys(FACTS) as Fact[])
|
||||
console.log(
|
||||
`${!(k in seen) ? '? ' : moved.includes(k) ? 'MOVED' : 'ok '} ${k}: ${k in seen ? seen[k] : 'not measured'}`,
|
||||
);
|
||||
for (const k of moved)
|
||||
problems.push(
|
||||
`${k} was ${FACTS[k].expect} on ${VERIFIED_ASTERISK}, is ${seen[k]} on ${pinned}: ` +
|
||||
`${FACTS[k].what}. Depends on it: ${FACTS[k].where}`,
|
||||
);
|
||||
if (problems.length) {
|
||||
console.log('FAIL asterisk upgrade');
|
||||
for (const p of problems) console.log(` ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
pinned === VERIFIED_ASTERISK
|
||||
? `ok asterisk ${pinned}: every fact as recorded`
|
||||
: `ok asterisk ${pinned}: nothing moved since ${VERIFIED_ASTERISK}; set VERIFIED_ASTERISK in verified.ts`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// What the code relies on Asterisk doing, as last measured by upgrade-test.ts, and on which
|
||||
// version. asterisk-version.test.ts fails when asterisk/Dockerfile pins another version: run
|
||||
// `pnpm test:asterisk-upgrade` on the new image, act on each change it lists, then update this.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const VERIFIED_ASTERISK = '24.0.0-rc3';
|
||||
|
||||
/** The version asterisk/Dockerfile builds. */
|
||||
export function pinnedAsterisk(): string {
|
||||
const file = readFileSync(new URL('../../../../asterisk/Dockerfile', import.meta.url), 'utf8');
|
||||
const v = /^ARG ASTERISK_VERSION=(\S+)$/m.exec(file)?.[1];
|
||||
if (!v) throw new Error('asterisk/Dockerfile has no ARG ASTERISK_VERSION');
|
||||
return v;
|
||||
}
|
||||
|
||||
/** `expect`: the answer on VERIFIED_ASTERISK. `where`: what depends on it, to change if it moves. */
|
||||
export const FACTS = {
|
||||
riskyModulesGone: {
|
||||
expect: true,
|
||||
what: 'app_system, func_shell, app_disa, res_agi and app_externalivr are not loaded',
|
||||
where: 'asterisk/conf/modules.conf noload lines (08 §6): a renamed module would load again',
|
||||
},
|
||||
mwiSubscribeReplaces: {
|
||||
expect: true,
|
||||
what: 'endpoints take mwi_subscribe_replaces_unsolicited',
|
||||
where: 'generator/pjsip.ts: without it a phone that SUBSCRIBEs for MWI gets a 500 (03 §5.2)',
|
||||
},
|
||||
setVarKeepsUnderscores: {
|
||||
expect: true,
|
||||
what: 'set_var = __SP_DIRECTION=… loads as "__SP_DIRECTION"',
|
||||
where: 'generator/pjsip.ts set_var lines; dialplan reads SP_DIRECTION on dialed legs (03 §5.2)',
|
||||
},
|
||||
staticContactsInShowContacts: {
|
||||
expect: false,
|
||||
what: 'PJSIPShowContacts lists static (contact =) AOR contacts',
|
||||
where: 'live/state.ts reads trunk reachability from PJSIPShowEndpoint instead (04 §5.1)',
|
||||
},
|
||||
endpointShowsContactStatus: {
|
||||
expect: true,
|
||||
what: 'PJSIPShowEndpoint has a ContactStatusDetail for a static contact',
|
||||
where: 'live/state.ts trunk reachability (04 §5.1)',
|
||||
},
|
||||
voicemailMoveRemove: {
|
||||
expect: true,
|
||||
what: 'the AMI actions VoicemailMove and VoicemailRemove exist',
|
||||
where: 'services/voicemail.ts mark read and delete (04 §5.6)',
|
||||
},
|
||||
cdrLinkedId: {
|
||||
expect: true,
|
||||
what: 'Cdr AMI events carry LinkedID (cdr_manager.conf mapping)',
|
||||
where: 'services/cdr.ts groups a call by it (01 §3.15, 04 §5.4)',
|
||||
},
|
||||
cdrTimesUtcNoZone: {
|
||||
expect: true,
|
||||
what: 'Cdr StartTime is "YYYY-MM-DD HH:MM:SS" in UTC, no zone',
|
||||
where: 'services/cdr.ts utc() adds the Z itself',
|
||||
},
|
||||
tlsQualifyByHostname: {
|
||||
expect: true,
|
||||
what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP',
|
||||
where:
|
||||
'generator/pjsip.ts qualifies TLS trunks and Test sends PJSIPQualify (08 §2.1). False on ' +
|
||||
'24.0.0-rc2: a TLS trunk then showed as unreachable, so it had qualify_frequency = 0',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Fact = keyof typeof FACTS;
|
||||
@@ -11,6 +11,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const NET = 'sp-vm';
|
||||
@@ -111,10 +112,7 @@ async function main() {
|
||||
spawnSync('mkdir', ['-p', conf]);
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
phoneConfig(dir, '101', 5070);
|
||||
phoneConfig(dir, '102', 5072);
|
||||
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5).
|
||||
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5); users' Report spam (05 §4).
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { callExtensions, calls, extensions } from '../src/db/schema.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { syncAstDb } from '../src/services/astdb.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let req: (
|
||||
method: string,
|
||||
url: string,
|
||||
payload?: unknown,
|
||||
as?: string,
|
||||
) => Promise<LightMyRequestResponse>;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, url, payload) =>
|
||||
req = (method, url, payload, as = cookie) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
headers: { ...H, cookie: as },
|
||||
});
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
@@ -59,4 +66,62 @@ describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
|
||||
});
|
||||
expect((await req('GET', '/api/blocked-numbers')).json().items).toEqual([]);
|
||||
});
|
||||
|
||||
it('Report spam: only on an outside call that rang me; Block clears it, Dismiss drops it', async () => {
|
||||
const sam = await t.addUser('sam@example.com', 'sam-password-123');
|
||||
await t.addUser('pat@example.com', 'pat-password-123');
|
||||
const [ext] = await t.db
|
||||
.insert(extensions)
|
||||
.values({
|
||||
number: '103',
|
||||
name: 'Sam',
|
||||
sipPasswordEnc: encrypt('x'),
|
||||
voicemailPinEnc: encrypt('4826'),
|
||||
userId: sam.id,
|
||||
})
|
||||
.returning();
|
||||
const call = async (linkedid: string, from: string | null, role: string) => {
|
||||
const [c] = await t.db
|
||||
.insert(calls)
|
||||
.values({
|
||||
linkedid,
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
fromNumber: from,
|
||||
startedAt: new Date(),
|
||||
endedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: ext!.id, role });
|
||||
return c!.id;
|
||||
};
|
||||
const robo = await call('r1', '13125550142', 'rang');
|
||||
const hidden = await call('r2', null, 'answered');
|
||||
const samC = await t.login('sam@example.com', 'sam-password-123');
|
||||
const patC = await t.login('pat@example.com', 'pat-password-123');
|
||||
const report = (callId: string, as: string, reason = 'Car warranty robocall') =>
|
||||
req('POST', '/api/me/spam-reports', { callId, reason }, as);
|
||||
|
||||
expect((await report(robo, patC)).statusCode).toBe(404); // not a call that rang Pat
|
||||
expect((await report(robo, samC, ' ')).statusCode).toBe(400); // no reason
|
||||
expect((await report(hidden, samC)).statusCode).toBe(400); // no number to block
|
||||
expect((await report(robo, samC)).statusCode).toBe(201);
|
||||
expect((await report(robo, samC)).statusCode).toBe(409); // once each
|
||||
expect((await req('GET', '/api/spam-reports', undefined, samC)).statusCode).toBe(403);
|
||||
const [r] = (await req('GET', '/api/spam-reports')).json().items;
|
||||
expect(r).toMatchObject({
|
||||
number: '13125550142',
|
||||
reason: 'Car warranty robocall',
|
||||
reportedBy: 'sam@example.com',
|
||||
});
|
||||
|
||||
expect((await req('DELETE', `/api/spam-reports/${r.id}`)).statusCode).toBe(200);
|
||||
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
|
||||
|
||||
expect((await report(robo, samC)).statusCode).toBe(201); // dismissed: may report again
|
||||
const b = await req('POST', '/api/blocked-numbers', { number: r.number, reason: r.reason });
|
||||
expect(b.statusCode).toBe(201);
|
||||
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
|
||||
expect((await report(robo, samC)).statusCode).toBe(409); // already blocked
|
||||
});
|
||||
});
|
||||
@@ -181,4 +181,43 @@ describe.skipIf(!hasTestDb)('call report email', () => {
|
||||
await run('2026-10-23T13:06:00Z');
|
||||
expect(t.mails[1]!.text).not.toContain('quote');
|
||||
});
|
||||
|
||||
it('no recording or summary of a call that only rang my phone (08 §7 #3)', async () => {
|
||||
await dan('PATCH', '/api/me', { callReportSummaries: true });
|
||||
const [rec] = await t.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
linkedid: 'r4',
|
||||
filePath: '2026/10/24/r4.wav',
|
||||
startedAt: new Date('2026-10-24T15:00:00Z'),
|
||||
summary: 'Secret pricing for Acme',
|
||||
aiDoneAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const [c] = await t.db
|
||||
.insert(calls)
|
||||
.values({
|
||||
linkedid: 'c4', // the ring group rang Dan; a coworker answered and talked
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-24T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-24T15:05:00Z'),
|
||||
fromNumber: '13125557777',
|
||||
talkSeconds: 300,
|
||||
recordingId: rec!.id,
|
||||
})
|
||||
.returning();
|
||||
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: extId, role: 'rang' });
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-24T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
t.mails.length = 0;
|
||||
expect(await run('2026-10-25T13:06:00Z')).toBe(1);
|
||||
const text = t.mails[0]!.text;
|
||||
expect(text).toContain('Incoming from (312) 555-7777');
|
||||
expect(text).not.toContain('Secret pricing');
|
||||
expect(text).not.toContain('recorded');
|
||||
expect(text).not.toContain('Summary not ready');
|
||||
});
|
||||
});
|
||||
@@ -460,6 +460,19 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
fake.state.readOnly.clear();
|
||||
expect(await run()).toBe(0);
|
||||
expect(jane).toHaveLength(8);
|
||||
|
||||
// Only rang Dan (a coworker took it): on his timeline, but not its recording or summary.
|
||||
const [k12] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k12', 1.5, { recordingId: done!.id }))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: k12!.id, extensionId: ext!.id, role: 'rang' });
|
||||
forgetBook(danId);
|
||||
expect(await run()).toBe(1);
|
||||
expect(jane.at(-1)!.content).not.toMatch(/recorded|Acme/);
|
||||
expect(jane.at(-1)).not.toHaveProperty('actionItem');
|
||||
});
|
||||
|
||||
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
|
||||
@@ -521,4 +534,35 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
]);
|
||||
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
|
||||
});
|
||||
|
||||
it('the saved tokens only go to the address they were saved for (08 §7 #7)', async () => {
|
||||
// Another machine on the network that answers like Contrack, and notes what it's sent.
|
||||
const heard: string[] = [];
|
||||
const other = createServer((req, res) => {
|
||||
heard.push(String(req.headers.authorization ?? ''));
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end('[]');
|
||||
});
|
||||
await new Promise<void>((ok) => other.listen(0, '127.0.0.1', ok));
|
||||
const otherUrl = `http://127.0.0.1:${(other.address() as AddressInfo).port}`;
|
||||
try {
|
||||
await admin('POST', '/api/contacts/contrack/test', { url: otherUrl });
|
||||
expect(heard.join(' ')).not.toContain('ctk_company');
|
||||
// A new address needs its token typed again…
|
||||
const kept = await admin('PATCH', '/api/settings', {
|
||||
'contacts.contrack': { url: otherUrl },
|
||||
});
|
||||
expect(kept.statusCode).toBe(400);
|
||||
expect(kept.json().error.message).toMatch(/token again/);
|
||||
// …and people's own tokens are for the old one: they connect again.
|
||||
const moved = await admin('PATCH', '/api/settings', {
|
||||
'contacts.contrack': { url: otherUrl, companyToken: 'ctk_new_place' },
|
||||
});
|
||||
expect(moved.statusCode, moved.body).toBe(200);
|
||||
const [d] = await t.db.select().from(users).where(eq(users.id, danId));
|
||||
expect(d!.contrackTokenEnc).toBeNull();
|
||||
} finally {
|
||||
other.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -154,4 +154,55 @@ describe.skipIf(!hasTestDb)('diagnostics', () => {
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
it('the probe key: made here, shown once, sent to the probe, never in settings (08 §7 #9)', async () => {
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
const as = (method: string, url: string, payload?: object) =>
|
||||
t.app.inject({ method: method as 'GET', url, payload, headers: { ...H, cookie } });
|
||||
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
|
||||
// No key yet: the inside checks say to make one.
|
||||
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'warn' });
|
||||
|
||||
const g = await as('POST', '/api/settings/network/probe-key');
|
||||
expect(g.statusCode, g.body).toBe(200);
|
||||
const { key } = g.json() as { key: string };
|
||||
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
const s = await as('GET', '/api/settings');
|
||||
expect(s.json()['network.hasProbeKey']).toBe(true);
|
||||
expect(s.body).not.toContain(key);
|
||||
expect(s.json()).not.toHaveProperty('network.probeKeyEnc');
|
||||
expect((await as('PATCH', '/api/settings', { 'network.probeKeyEnc': 'x' })).statusCode).toBe(
|
||||
400,
|
||||
);
|
||||
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'ok' });
|
||||
|
||||
let auth: string | null = null;
|
||||
await networkTest(
|
||||
{
|
||||
...deps,
|
||||
fetchProbe: (async (_u: URL, init: RequestInit) => {
|
||||
auth = new Headers(init.headers).get('authorization');
|
||||
return new Response(JSON.stringify({ ip: '203.0.113.10', results: [] }));
|
||||
}) as unknown as typeof fetch,
|
||||
},
|
||||
'quick',
|
||||
);
|
||||
expect(auth).toBe(`Bearer ${key}`);
|
||||
// The probe refusing it says what to do.
|
||||
const refused = await networkTest(
|
||||
{
|
||||
...deps,
|
||||
fetchProbe: (async () =>
|
||||
new Response(JSON.stringify({ error: 'a key is needed' }), {
|
||||
status: 401,
|
||||
})) as unknown as typeof fetch,
|
||||
},
|
||||
'quick',
|
||||
);
|
||||
expect(byId(refused.checks).probe!.fix).toContain('PROBE_KEY');
|
||||
|
||||
// Another address: the key was for the old probe, so it's gone.
|
||||
await as('PATCH', '/api/settings', { 'network.probeUrl': 'https://other-probe.test' });
|
||||
expect((await as('GET', '/api/settings')).json()['network.hasProbeKey']).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 §4.3: who a directory entry may connect to by email, who may disconnect it (§4.5), and a
|
||||
// directory that's down counting as a failed sign-in (§4.2).
|
||||
// directory that's down counting as a failed sign-in (§4.2). 08 §7: no OpenID user by email, no
|
||||
// swapping the connected account.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { userIdentities, users } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { directorySignIn } from '../src/sso/directory.ts';
|
||||
import { connectDirectory, directorySignIn } from '../src/sso/directory.ts';
|
||||
import type { DirectoryEntry } from '../src/sso/ldap.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
@@ -52,6 +53,38 @@ describe.skipIf(!hasTestDb)('directory accounts by email (12 §4.3)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('never connects by email someone who signs in with OpenID (08 §7 #1)', async () => {
|
||||
// A directory that lets people edit their own mail: Mallory's entry, the victim's email.
|
||||
const victim = await t.addUser('oidc-only@example.com', 'unused-pw-2');
|
||||
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, victim.id));
|
||||
await t.db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: victim.id, kind: 'oidc', subject: 'https://idp victim-sub' });
|
||||
expect(await directorySignIn(t.db, log, entry(victim.email), '127.0.0.1')).toBeNull();
|
||||
expect(await connected(victim.id)).toBe(1); // the OpenID account only
|
||||
});
|
||||
|
||||
it('a connected directory account is only swapped by an admin (08 §7 #5)', async () => {
|
||||
const u = await t.addUser('swapper@example.com', 'swapper-password-1');
|
||||
expect(await connectDirectory(t.db, log, u.id, entry(u.email), '127.0.0.1')).toBe('connected');
|
||||
const [first] = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
|
||||
// The same account again is fine; another one (a shared mailbox) is not.
|
||||
expect(
|
||||
await connectDirectory(
|
||||
t.db,
|
||||
log,
|
||||
u.id,
|
||||
{ ...entry(u.email), id: first!.subject },
|
||||
'127.0.0.1',
|
||||
),
|
||||
).toBe('connected');
|
||||
expect(await connectDirectory(t.db, log, u.id, entry('shared@example.com'), '127.0.0.1')).toBe(
|
||||
'connected_other',
|
||||
);
|
||||
const now = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
|
||||
expect(now.map((i) => i.subject)).toEqual([first!.subject]);
|
||||
});
|
||||
|
||||
it('only an admin can disconnect a directory account (a leaver must stay checked)', async () => {
|
||||
const u = await t.addUser('leaver@example.com', 'leaver-password-1');
|
||||
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T10: fraud alerts (08 §8.3): risky calls, voip.ms calls the PBX didn't make, balance drop.
|
||||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { calls, cdr, dids } from '../src/db/schema.ts';
|
||||
import { createFraudWatch, riskyDestination, trunkDialNumber } from '../src/services/fraud.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe('riskyDestination', () => {
|
||||
it('flags international, premium and NANP numbers billed like abroad', () => {
|
||||
expect(riskyDestination('01144207946095')).toBe('international');
|
||||
expect(riskyDestination('19005551234')).toBe('premium');
|
||||
expect(riskyDestination('13129765555')).toBe('premium'); // exchange 976
|
||||
expect(riskyDestination('18765551234')).toMatch(/international rates/); // Jamaica
|
||||
expect(riskyDestination('8095551234')).toMatch(/international rates/); // 10 digits
|
||||
expect(riskyDestination('13125551234')).toBeNull();
|
||||
expect(riskyDestination('18005551234')).toBeNull();
|
||||
expect(riskyDestination('101')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the number out of a trunk Dial', () => {
|
||||
expect(trunkDialNumber('PJSIP/13125550000@trunk-7d3e9a52,120')).toBe('13125550000');
|
||||
expect(trunkDialNumber('PJSIP/911@trunk-7d3e9a52')).toBe('911');
|
||||
expect(trunkDialNumber('PJSIP/101,30')).toBeNull();
|
||||
expect(trunkDialNumber(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('fraud watches', () => {
|
||||
const at = (iso: string) => new Date(iso);
|
||||
let clock = Date.parse('2026-10-29T12:00:00Z');
|
||||
let balance = '100.00';
|
||||
const cdrs: Record<string, unknown>[] = [];
|
||||
const seen: URLSearchParams[] = [];
|
||||
const fake = createServer((req, res) => {
|
||||
const q = new URL(req.url!, 'http://x').searchParams;
|
||||
seen.push(q);
|
||||
res.setHeader('content-type', 'application/json');
|
||||
const m = q.get('method');
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
m === 'getBalance'
|
||||
? { status: 'success', balance: { current_balance: balance } }
|
||||
: m === 'getCDR'
|
||||
? cdrs.length
|
||||
? { status: 'success', cdr: cdrs }
|
||||
: { status: 'no_cdr' }
|
||||
: { status: 'invalid_method' },
|
||||
),
|
||||
);
|
||||
});
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let trunkId: string;
|
||||
let url: string;
|
||||
const mails: { subject: string; text: string }[] = [];
|
||||
const watch = () =>
|
||||
createFraudWatch({
|
||||
db: t.db,
|
||||
voipmsUrl: url,
|
||||
mail: async (subject, text) => void mails.push({ subject, text }),
|
||||
log: { warn() {} },
|
||||
now: () => new Date(clock),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((r) => fake.listen(0, '127.0.0.1', r));
|
||||
url = `http://127.0.0.1:${(fake.address() as AddressInfo).port}/api/v1/rest.php`;
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, u, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url: u,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
const r = await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms',
|
||||
serverHost: '127.0.0.1',
|
||||
username: '123456_pbx',
|
||||
password: 'Sub-account pw!',
|
||||
});
|
||||
trunkId = r.json().id;
|
||||
await req('PATCH', `/api/trunks/${trunkId}`, {
|
||||
voipmsApiUsername: 'me@example.com',
|
||||
voipmsApiPassword: 'api-pw',
|
||||
});
|
||||
await t.db.insert(dids).values({
|
||||
number: '13125551000',
|
||||
trunkId,
|
||||
destination: { type: 'hangup' },
|
||||
} as typeof dids.$inferInsert);
|
||||
});
|
||||
afterAll(async () => {
|
||||
fake.close();
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it('riskyCallWatch emails each risky outside call once, and not when turned off', async () => {
|
||||
const w = watch();
|
||||
mails.length = 0;
|
||||
const call = (linkedid: string, to: string, created: string) => ({
|
||||
linkedid,
|
||||
direction: 'outbound' as const,
|
||||
status: 'answered' as const,
|
||||
startedAt: at(created),
|
||||
endedAt: at(created),
|
||||
fromNumber: '101',
|
||||
toNumber: to,
|
||||
talkSeconds: 125,
|
||||
createdAt: at(created),
|
||||
});
|
||||
await t.db
|
||||
.insert(calls)
|
||||
.values([
|
||||
call('r1', '01144207946095', '2026-10-29T12:00:20Z'),
|
||||
call('r2', '18765551234', '2026-10-29T12:00:30Z'),
|
||||
call('r3', '13125551234', '2026-10-29T12:00:40Z'),
|
||||
]);
|
||||
clock = Date.parse('2026-10-29T12:01:00Z');
|
||||
await w.riskyCallWatch();
|
||||
expect(mails.map((m) => m.subject)).toEqual(['Calls to risky numbers (2)']);
|
||||
expect(mails[0]!.text).toMatch(/from 101 to 01144207946095: international, answered/);
|
||||
expect(mails[0]!.text).toMatch(/\(876\) 555-1234: international rates/);
|
||||
expect(mails[0]!.text).not.toMatch(/\(312\) 555-1234/);
|
||||
clock += 60_000;
|
||||
await w.riskyCallWatch(); // nothing new
|
||||
expect(mails).toHaveLength(1);
|
||||
|
||||
await setSetting(t.db, 'alerts.riskyCalls', false);
|
||||
await t.db.insert(calls).values(call('r4', '19005551234', '2026-10-29T12:01:30Z'));
|
||||
clock += 60_000;
|
||||
await w.riskyCallWatch();
|
||||
expect(mails).toHaveLength(1);
|
||||
await setSetting(t.db, 'alerts.riskyCalls', true);
|
||||
});
|
||||
|
||||
it('providerWatch reports voip.ms calls the PBX never sent to the trunk, once', async () => {
|
||||
await setSetting(t.db, 'alerts.balanceDropPerHour', 0);
|
||||
const w = watch();
|
||||
mails.length = 0;
|
||||
clock = Date.parse('2026-10-29T14:00:00Z');
|
||||
// The PBX sent one call to the trunk at 13:00 (a forward would look the same).
|
||||
await t.db.insert(cdr).values({
|
||||
uniqueid: 'u1',
|
||||
linkedid: 'u1',
|
||||
startAt: at('2026-10-29T13:00:05Z'),
|
||||
lastdata: `PJSIP/13125550000@trunk-${trunkId},120`,
|
||||
raw: {},
|
||||
});
|
||||
const rec = (uniqueid: string, date: string, destination: string) => ({
|
||||
date,
|
||||
callerid: '"Office" <3125551000>',
|
||||
destination,
|
||||
description: 'Outgoing',
|
||||
account: '123456_pbx',
|
||||
disposition: 'ANSWERED',
|
||||
duration: '00:03:00',
|
||||
seconds: '180',
|
||||
rate: '0.25',
|
||||
total: '0.7500',
|
||||
uniqueid,
|
||||
});
|
||||
cdrs.push(
|
||||
rec('v1', '2026-10-29 13:00:09', '3125550000'), // ours
|
||||
rec('v2', '2026-10-29 13:10:00', '3125551000'), // incoming, to our DID
|
||||
rec('v3', '2026-10-29 13:20:00', '01144207946095'), // not ours
|
||||
rec('v4', '2026-10-29 11:00:00', '01144207946096'), // before the window
|
||||
{ ...rec('v5', '2026-10-29 13:30:00', '01144207946097'), account: '123456_other' },
|
||||
);
|
||||
await w.providerWatch();
|
||||
const q = seen.findLast((s) => s.get('method') === 'getCDR')!;
|
||||
expect(
|
||||
Object.fromEntries(['date_from', 'date_to', 'timezone', 'account'].map((k) => [k, q.get(k)])),
|
||||
).toEqual({
|
||||
date_from: '2026-10-29',
|
||||
date_to: '2026-10-29',
|
||||
timezone: '0',
|
||||
account: '123456_pbx',
|
||||
});
|
||||
expect(mails.map((m) => m.subject)).toEqual([
|
||||
"voip.ms calls this phone system didn't make (1)",
|
||||
]);
|
||||
expect(mails[0]!.text).toMatch(/to 01144207946095: answered, 180 s, cost 0\.7500/);
|
||||
expect(mails[0]!.text).not.toMatch(/3125550000|3125551000|946096|946097/);
|
||||
clock += 15 * 60_000;
|
||||
await w.providerWatch(); // v3 again: already reported
|
||||
expect(mails).toHaveLength(1);
|
||||
|
||||
await setSetting(t.db, 'alerts.providerCalls', false);
|
||||
cdrs.push(rec('v6', '2026-10-29 14:00:00', '01144207946098'));
|
||||
clock += 15 * 60_000;
|
||||
await w.providerWatch();
|
||||
expect(mails).toHaveLength(1);
|
||||
await setSetting(t.db, 'alerts.providerCalls', true);
|
||||
cdrs.length = 0;
|
||||
});
|
||||
|
||||
it('balanceWatch emails once an hour when the balance drops more than the limit', async () => {
|
||||
await setSetting(t.db, 'alerts.providerCalls', false);
|
||||
await setSetting(t.db, 'alerts.balanceDropPerHour', 10);
|
||||
const w = watch();
|
||||
mails.length = 0;
|
||||
clock = Date.parse('2026-10-29T16:00:00Z');
|
||||
balance = '100.00';
|
||||
await w.providerWatch();
|
||||
clock += 15 * 60_000;
|
||||
balance = '95.00';
|
||||
await w.providerWatch();
|
||||
expect(mails).toEqual([]);
|
||||
clock += 15 * 60_000;
|
||||
balance = '85.00';
|
||||
await w.providerWatch();
|
||||
expect(mails.map((m) => m.subject)).toEqual(['voip.ms balance dropped 15.00 in an hour']);
|
||||
clock += 15 * 60_000;
|
||||
balance = '70.00';
|
||||
await w.providerWatch(); // within the hour of the last email
|
||||
expect(mails).toHaveLength(1);
|
||||
clock += 60 * 60_000;
|
||||
balance = '200.00'; // a deposit
|
||||
await w.providerWatch();
|
||||
expect(mails).toHaveLength(1);
|
||||
await setSetting(t.db, 'alerts.providerCalls', true);
|
||||
});
|
||||
});
|
||||
@@ -207,6 +207,56 @@ for (const alg of ['RS256', 'ES256'] as const)
|
||||
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
|
||||
});
|
||||
|
||||
it('never matches by email an admin, or someone with a directory account (08 §7 #2)', async () => {
|
||||
const [boss, dir] = await t.db
|
||||
.insert(users)
|
||||
.values([
|
||||
{ email: 'diradmin@example.com', displayName: 'Boss', passwordHash: null, role: 'admin' },
|
||||
{ email: 'diruser@example.com', displayName: 'Dir', passwordHash: null },
|
||||
])
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: dir!.id, kind: 'ldap', subject: 'dir-user-id' });
|
||||
for (const u of [boss!, dir!]) {
|
||||
idp.st.user = { sub: `u-${u.email}`, email: u.email, email_verified: true };
|
||||
const { callback } = await flow();
|
||||
expect(errorOf(callback!)).toBe('not_connected');
|
||||
expect(session(callback!)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('connect finishes only in the browser whose session asked (08 §7 #8)', async () => {
|
||||
// Mallory starts Connect, then plants her sp_oidc cookie in Vic's browser and sends him on.
|
||||
await t.addUser('mallory@example.com', 'mallory-password-1');
|
||||
const [m] = await t.db.select().from(users).where(eq(users.email, 'mallory@example.com'));
|
||||
await t.addUser('vic@example.com', 'vic-password-123');
|
||||
const mallory = await t.login('mallory@example.com', 'mallory-password-1');
|
||||
const vic = await t.login('vic@example.com', 'vic-password-123');
|
||||
idp.st.user = { sub: 'u-vic', email: 'vic@example.com', email_verified: true };
|
||||
for (const theirs of [vic, '']) {
|
||||
const start = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/oidc/connect',
|
||||
headers: { ...H, cookie: mallory },
|
||||
payload: {},
|
||||
});
|
||||
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
||||
const atIdp = await fetch((start.json() as { url: string }).url, { redirect: 'manual' });
|
||||
const back = new URL(atIdp.headers.get('location')!);
|
||||
const callback = await t.app.inject({
|
||||
url: back.pathname + back.search,
|
||||
headers: { cookie: [`sp_oidc=${pending.value}`, theirs].filter(Boolean).join('; ') },
|
||||
});
|
||||
expect(errorOf(callback)).toBe('expired');
|
||||
const ids = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, m!.id));
|
||||
expect(ids).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('trustEmail matches an unverified email', async () => {
|
||||
await t.db
|
||||
.insert(users)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M7-T3: a recorded call (sp_recording) is linked to its calls row and playable by admins and the
|
||||
// people on the call; delete and retention remove file and row.
|
||||
// people on the call (not a phone that only rang, 08 §7 #3); delete and retention remove file and row.
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { AmiMessage } from '../src/ami/events.ts';
|
||||
import { calls, cdr, extensions, recordings } from '../src/db/schema.ts';
|
||||
import { callExtensions, calls, cdr, extensions, recordings, users } from '../src/db/schema.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { cleanupRecordings } from '../src/routes/recordings.ts';
|
||||
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
|
||||
@@ -56,6 +56,8 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
|
||||
};
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
const bobId = async () =>
|
||||
(await t.db.select().from(users).where(eq(users.email, 'bob@example.com')))[0]!.id;
|
||||
|
||||
it('the recording is linked to its call, with size and length', async () => {
|
||||
const [c] = await t.db.select().from(calls);
|
||||
@@ -91,6 +93,26 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
|
||||
expect((await as.alice('GET', '/api/recordings')).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('a phone that only rang (ring group, queue) gets neither audio nor transcript (08 §7 #3)', async () => {
|
||||
const [bob] = await t.db
|
||||
.insert(extensions)
|
||||
.values({
|
||||
number: '102',
|
||||
name: 'Bob',
|
||||
userId: await bobId(),
|
||||
sipPasswordEnc: encrypt('x'),
|
||||
voicemailPinEnc: encrypt('4826'),
|
||||
})
|
||||
.returning();
|
||||
const [c] = await t.db.select().from(calls);
|
||||
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: bob!.id, role: 'rang' });
|
||||
expect((await as.bob('GET', `/api/recordings/${recId}/audio`)).statusCode).toBe(403);
|
||||
expect((await as.bob('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(403);
|
||||
const mine = (await as.bob('GET', '/api/me/calls')).json();
|
||||
expect(mine.items).toEqual([expect.objectContaining({ id: c!.id, recordingId: null })]);
|
||||
expect((await as.alice('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('retention deletes recordings past their days; delete removes file and link', async () => {
|
||||
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
|
||||
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
|
||||
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone;
|
||||
// /api/me/reports/summary, the calls made to me.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { calls, queueCalls, queues } from '../src/db/schema.ts';
|
||||
import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
@@ -125,5 +128,74 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
|
||||
await t.addUser('pat@example.com', 'pat-password-123');
|
||||
const pat = await t.login('pat@example.com', 'pat-password-123');
|
||||
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
|
||||
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
|
||||
});
|
||||
|
||||
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
|
||||
const sam = await t.addUser('sam@example.com', 'sam-password-123');
|
||||
const [mine, other] = await t.db
|
||||
.insert(extensions)
|
||||
.values(
|
||||
(['103', '104'] as const).map((number) => ({
|
||||
number,
|
||||
name: number,
|
||||
sipPasswordEnc: encrypt('x'),
|
||||
voicemailPinEnc: encrypt('4826'),
|
||||
userId: number === '103' ? sam.id : null,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
await t.db.insert(calls).values({
|
||||
linkedid: 'g',
|
||||
direction: 'internal',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
|
||||
endedAt: new Date('2026-05-05T15:00:45Z'),
|
||||
talkSeconds: 40,
|
||||
answeredByExtensionId: mine!.id,
|
||||
});
|
||||
const ids = Object.fromEntries(
|
||||
(
|
||||
await t.db
|
||||
.select({ id: calls.id, linkedid: calls.linkedid })
|
||||
.from(calls)
|
||||
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
|
||||
).map((c) => [c.linkedid, c.id]),
|
||||
);
|
||||
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
|
||||
await t.db
|
||||
.update(calls)
|
||||
.set({ answeredByExtensionId: other!.id })
|
||||
.where(inArray(calls.id, [ids.a!]));
|
||||
await t.db.insert(callExtensions).values([
|
||||
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
|
||||
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
|
||||
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
|
||||
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
|
||||
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
|
||||
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
|
||||
]);
|
||||
const cookie = await t.login('sam@example.com', 'sam-password-123');
|
||||
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
const s = r.json();
|
||||
expect(s.days).toEqual([
|
||||
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
|
||||
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
|
||||
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
|
||||
]);
|
||||
expect(s.totals).toEqual({
|
||||
calls: 3,
|
||||
answered: 1,
|
||||
missed: 1,
|
||||
avgTalkSeconds: 40,
|
||||
talkSeconds: 40,
|
||||
outsideSeconds: { inbound: 95, outbound: 0 },
|
||||
});
|
||||
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
|
||||
// The company summary is unchanged by who rang.
|
||||
expect(
|
||||
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -117,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 47 tables', async () => {
|
||||
it('creates all 48 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 47 });
|
||||
expect(r.rows[0]).toEqual({ n: 48 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
@@ -105,7 +105,7 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
|
||||
expect(await warningCodes()).not.toContain('tls_cert');
|
||||
});
|
||||
|
||||
it('a TLS trunk: no qualify, Test does not send OPTIONS, TLS stays on while it exists', async () => {
|
||||
it('a TLS trunk: qualified like the others (24.0.0-rc3), TLS stays on while it exists', async () => {
|
||||
const trunk = await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms TLS',
|
||||
serverHost: '127.0.0.1',
|
||||
@@ -119,13 +119,15 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
|
||||
await t.app.generator.apply({ trigger: 'test' });
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const conf = await readFile(join(t.app.generator.dir, 'pjsip_selfphoneng.conf'), 'utf8');
|
||||
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 0\n/);
|
||||
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 60\n/);
|
||||
expect(conf).toMatch(/media_encryption = sdes/);
|
||||
expect(conf).toMatch(/verify_server = yes/);
|
||||
t.live.amiConnected = true;
|
||||
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
|
||||
expect(test.statusCode).toBe(200);
|
||||
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([]);
|
||||
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([
|
||||
expect.objectContaining({ Endpoint: `trunk-${trunk.json().id}` }),
|
||||
]);
|
||||
const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false });
|
||||
expect(off.statusCode).toBe(400);
|
||||
expect(off.json().error.message).toMatch(/uses TLS/);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { LiveMessage } from '@selfphoneng/shared';
|
||||
import { extensions, voicemailMessages } from '../src/db/schema.ts';
|
||||
import { extensions, voicemailMessages, webhookDeliveries, webhooks } from '../src/db/schema.ts';
|
||||
import { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
@@ -175,11 +175,21 @@ describe.skipIf(!hasTestDb)('voicemail watcher', () => {
|
||||
const url = `http://127.0.0.1:${(stt.address() as AddressInfo).port}/v1`;
|
||||
await setSetting(t.db, 'ai.stt', { url, model: 'whisper' });
|
||||
await setSetting(t.db, 'ai.voicemailTranscripts', true);
|
||||
await t.db
|
||||
.insert(webhooks)
|
||||
.values({ url: 'https://hooks.example.com/x', secretEnc: 'x', events: ['voicemail.new'] });
|
||||
try {
|
||||
leave('INBOX', 3, '1792300700-00000004', '<104>');
|
||||
await w.scan('101');
|
||||
const r = (await rows()).find((x) => x.msgId === '1792300700-00000004')!;
|
||||
expect(r.transcript).toBe('Please call me.');
|
||||
// A webhook goes off this server: the words stay here (10 §1, 08 §7 #6).
|
||||
const [hook] = await t.db
|
||||
.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(eq(webhookDeliveries.event, 'voicemail.new'));
|
||||
expect((hook!.payload as { data: object }).data).toMatchObject({ id: r.id });
|
||||
expect((hook!.payload as { data: object }).data).not.toHaveProperty('transcript');
|
||||
expect(t.mails.at(-1)!.text).toContain(
|
||||
'What they said (automatic, may have mistakes):\nPlease call me.',
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/probe",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
+14
-3
@@ -1,10 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
|
||||
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
|
||||
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
|
||||
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy on this machine that sets
|
||||
// X-Forwarded-For. HOST: where to listen; with TRUST_PROXY=true 127.0.0.1 (only the proxy may
|
||||
// reach it), else every address. PROBE_KEY: the key from SelfPhoneNG's Settings → Network;
|
||||
// without one anyone who finds the probe can use it.
|
||||
import { createProbe } from './server.ts';
|
||||
|
||||
const port = Number(process.env.PORT ?? 8080);
|
||||
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
|
||||
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
|
||||
const trustProxy = process.env.TRUST_PROXY === 'true';
|
||||
const host = process.env.HOST ?? (trustProxy ? '127.0.0.1' : '::');
|
||||
const key = process.env.PROBE_KEY || undefined;
|
||||
const probe = createProbe({ trustProxy, key });
|
||||
probe.listen(port, host, () => console.log(`selfphoneng-probe listening on ${host} ${port}`));
|
||||
if (!key)
|
||||
console.warn(
|
||||
'No PROBE_KEY: anyone who finds this probe can use it. Make one in SelfPhoneNG ' +
|
||||
'(Settings → Network → Generate key) and set PROBE_KEY.',
|
||||
);
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
|
||||
@@ -1,12 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
|
||||
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits.
|
||||
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits,
|
||||
// believes X-Forwarded-For only from a proxy on this machine (08 §7 #4), and with PROBE_KEY only
|
||||
// answers who sends it (#9).
|
||||
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
|
||||
import { createServer as httpServer, type Server as HttpServer } from 'node:http';
|
||||
import {
|
||||
createServer as httpServer,
|
||||
type IncomingMessage,
|
||||
type Server as HttpServer,
|
||||
} from 'node:http';
|
||||
import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runTest } from './checks.ts';
|
||||
import { createProbe, parseCheck } from './server.ts';
|
||||
import { clientIp, createProbe, parseCheck } from './server.ts';
|
||||
|
||||
const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
|
||||
const listen = <T extends Server | HttpServer>(s: T) =>
|
||||
@@ -165,3 +171,38 @@ describe('probe server', () => {
|
||||
target.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the probe key', () => {
|
||||
it('with a key, only a request that sends it gets tested (08 §7 #9)', async () => {
|
||||
const key = 'k3y'.repeat(15);
|
||||
const probe = await listen(createProbe({ key }));
|
||||
const post = (authorization?: string) =>
|
||||
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(authorization && { authorization }) },
|
||||
body: JSON.stringify({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 1 }] }),
|
||||
});
|
||||
expect((await post()).status).toBe(401);
|
||||
expect((await post('Bearer wrong')).status).toBe(401);
|
||||
expect((await post(`Bearer ${key}x`)).status).toBe(401);
|
||||
expect((await post(`Bearer ${key}`)).status).toBe(200);
|
||||
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
|
||||
probe.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the address that asked', () => {
|
||||
const req = (peer: string, xff?: string) =>
|
||||
({
|
||||
headers: xff ? { 'x-forwarded-for': xff } : {},
|
||||
socket: { remoteAddress: peer },
|
||||
}) as unknown as IncomingMessage;
|
||||
|
||||
it('takes X-Forwarded-For only from the local proxy (08 §7 #4)', () => {
|
||||
// Reached straight from the internet: the header is whatever the caller wrote.
|
||||
expect(clientIp(req('203.0.113.5', '198.51.100.9'), true)).toBe('203.0.113.5');
|
||||
for (const proxy of ['127.0.0.1', '::ffff:127.0.0.1', '::1'])
|
||||
expect(clientIp(req(proxy, '10.0.0.1, 198.51.100.9'), true)).toBe('198.51.100.9');
|
||||
expect(clientIp(req('::ffff:203.0.113.5', '198.51.100.9'), false)).toBe('203.0.113.5');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
|
||||
// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe
|
||||
// from being aimed at someone else; each address gets a few checks per 10 minutes.
|
||||
// from being aimed at someone else; each address gets a few checks per 10 minutes. With a key
|
||||
// (PROBE_KEY, made in SelfPhoneNG's Settings → Network), only who sends it is tested (08 §7 #9).
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||
import { isIP } from 'node:net';
|
||||
import { runTest, type Result, type Test } from './checks.ts';
|
||||
@@ -9,6 +11,8 @@ import { runTest, type Result, type Test } from './checks.ts';
|
||||
export type ProbeOptions = {
|
||||
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
|
||||
trustProxy?: boolean;
|
||||
/** Only requests with `Authorization: Bearer <key>` are tested. None: anyone may ask. */
|
||||
key?: string;
|
||||
/** Checks per address per window. */
|
||||
maxChecks?: number;
|
||||
windowMs?: number;
|
||||
@@ -48,15 +52,27 @@ export function parseCheck(body: unknown): { host: string; tests: Test[] } | str
|
||||
return { host: b.host, tests };
|
||||
}
|
||||
|
||||
const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
|
||||
/**
|
||||
* The address to test. X-Forwarded-For only counts when the request came from a proxy on this
|
||||
* machine: reached directly, the header is whatever the caller wrote (08 §7 #4).
|
||||
*/
|
||||
export const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
|
||||
const peer = req.socket.remoteAddress!.replace(/^::ffff:/, '');
|
||||
const xff = req.headers['x-forwarded-for'];
|
||||
const raw =
|
||||
trustProxy && typeof xff === 'string'
|
||||
? xff.split(',').at(-1)!.trim()
|
||||
: req.socket.remoteAddress!;
|
||||
return raw.replace(/^::ffff:/, '');
|
||||
const local = peer === '127.0.0.1' || peer === '::1';
|
||||
return trustProxy && local && typeof xff === 'string'
|
||||
? xff
|
||||
.split(',')
|
||||
.at(-1)!
|
||||
.trim()
|
||||
.replace(/^::ffff:/, '')
|
||||
: peer;
|
||||
};
|
||||
|
||||
/** Equal, in the same time whatever was sent (hashes, so lengths don't show either). */
|
||||
const sameKey = (a: string, b: string) =>
|
||||
timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
@@ -78,7 +94,7 @@ function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
}
|
||||
|
||||
export function createProbe(opts: ProbeOptions = {}): Server {
|
||||
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
|
||||
const { trustProxy = false, key, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
|
||||
// ponytail: in-process memory; addresses past the window are swept on each check.
|
||||
const seen = new Map<string, number[]>();
|
||||
const allow = (ip: string, now = Date.now()) => {
|
||||
@@ -95,6 +111,8 @@ export function createProbe(opts: ProbeOptions = {}): Server {
|
||||
if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
|
||||
if (req.method !== 'POST' || req.url !== '/v1/check')
|
||||
return send(res, 404, { error: 'not found' });
|
||||
if (key && !sameKey(req.headers.authorization ?? '', `Bearer ${key}`))
|
||||
return send(res, 401, { error: 'this probe needs its key' });
|
||||
const ip = clientIp(req, trustProxy);
|
||||
void readJson(req)
|
||||
.then(async (body) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/web",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
@@ -13,6 +13,7 @@
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.3",
|
||||
"lucide-react": "^1.56.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.90.0",
|
||||
|
||||
@@ -26,6 +26,8 @@ import type {
|
||||
AnnouncementUpdate,
|
||||
BlockedCreate,
|
||||
BlockedDto,
|
||||
SpamReportCreate,
|
||||
SpamReportDto,
|
||||
CallDetailDto,
|
||||
CallDto,
|
||||
CallFilter,
|
||||
@@ -293,6 +295,9 @@ export const systemApi = {
|
||||
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
|
||||
detectPublicIp: () =>
|
||||
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
|
||||
/** A new key for the network test probe, shown this once (08 §7 #9). */
|
||||
probeKey: () =>
|
||||
api<{ key: string }>('POST', '/api/settings/network/probe-key').then((r) => r.key),
|
||||
tls: () => api<TlsCert>('GET', '/api/system/tls'),
|
||||
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
|
||||
networkTest: (mode: 'quick' | 'complete') =>
|
||||
@@ -306,6 +311,9 @@ export const systemApi = {
|
||||
export const meApi = {
|
||||
calls: (cursor?: string) =>
|
||||
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
|
||||
reportSpam: (r: SpamReportCreate) => api<{ ok: true }>('POST', '/api/me/spam-reports', r),
|
||||
/** The summary of the calls made to me. */
|
||||
report: (r: ReportRange) => api<ReportSummary>('GET', `/api/me/reports/summary?${callQuery(r)}`),
|
||||
get: () => api<MeDto>('GET', '/api/me'),
|
||||
/** 14 §5.1: only for a caller ringing me now. */
|
||||
callerCheck: (number: string) =>
|
||||
@@ -505,6 +513,8 @@ export const blockedApi = {
|
||||
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
|
||||
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/blocked-numbers/${id}`),
|
||||
reports: () => api<ListResponse<SpamReportDto>>('GET', '/api/spam-reports').then((r) => r.items),
|
||||
dismiss: (id: string) => api<{ ok: true }>('DELETE', `/api/spam-reports/${id}`),
|
||||
};
|
||||
|
||||
/** Filters as a query string (empty values left out). */
|
||||
|
||||
@@ -26,7 +26,7 @@ export function RequireAuth({
|
||||
}) {
|
||||
const me = useMe();
|
||||
const loc = useLocation();
|
||||
if (me.isPending) return <p className="p-6 text-sm text-zinc-500">Loading…</p>;
|
||||
if (me.isPending) return <p className="p-6 text-sm text-muted">Loading…</p>;
|
||||
if (me.isError) {
|
||||
if (me.error instanceof ApiError && me.error.status === 401)
|
||||
return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname)}`} replace />;
|
||||
|
||||
@@ -143,7 +143,7 @@ export function DestinationPicker({
|
||||
</p>
|
||||
)}
|
||||
{isIdKind(kind) && all[kind] && targets(kind).length === 0 && (
|
||||
<p className="text-xs text-zinc-500">Nothing to pick yet ({what}).</p>
|
||||
<p className="text-xs text-muted">Nothing to pick yet ({what}).</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Native <dialog> (focus trap + Esc for free) as a centered modal or a right-hand drawer.
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { Button } from './ui.tsx';
|
||||
|
||||
@@ -28,27 +29,27 @@ export function Dialog({
|
||||
|
||||
const shape = drawer
|
||||
? 'ml-auto mr-0 h-dvh max-h-dvh w-full max-w-md'
|
||||
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-lg';
|
||||
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-xl';
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
onClick={(e) => e.target === ref.current && onClose()}
|
||||
aria-labelledby="dialog-title"
|
||||
className={`${shape} bg-white p-0 text-zinc-900 shadow-xl backdrop:bg-black/40 dark:bg-zinc-900 dark:text-zinc-100`}
|
||||
className={`${shape} bg-surface p-0 text-fg shadow-xl backdrop:bg-black/40`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
||||
<header className="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<h2 id="dialog-title" className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<Button variant="ghost" onClick={onClose} aria-label="Close" className="px-2 py-1">
|
||||
✕
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto p-4">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex justify-end gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
||||
<footer className="flex justify-end gap-2 border-t border-line px-4 py-3">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
|
||||
@@ -15,13 +15,13 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
|
||||
Parked calls
|
||||
</h2>
|
||||
{parked.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
None. Transfer a call to 700 to park it; dial the slot to pick it up.
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
aria-label="Parked calls"
|
||||
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
|
||||
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
|
||||
>
|
||||
{parked.map((p) => (
|
||||
<li
|
||||
@@ -31,14 +31,14 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
|
||||
<span className="w-10 font-mono font-semibold">{p.slot}</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.callerName ? `${p.callerName} ` : ''}
|
||||
<span className="text-zinc-500">{fmtNumber(p.callerNumber)}</span>
|
||||
{p.parkedBy && <span className="text-zinc-500"> · parked by {p.parkedBy}</span>}
|
||||
<span className="text-muted">{fmtNumber(p.callerNumber)}</span>
|
||||
{p.parkedBy && <span className="text-muted"> · parked by {p.parkedBy}</span>}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums">{since(p.since, now)}</span>
|
||||
{onPickUp && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-zinc-300 px-2 py-1 dark:border-zinc-700"
|
||||
className="rounded-md border border-line-strong bg-surface px-2 py-1"
|
||||
onClick={() => onPickUp(p.slot)}
|
||||
>
|
||||
Pick up {p.slot}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
|
||||
code: in the app, choose <em>Fetch remote configuration</em> (or <em>Scan QR code</em>) and
|
||||
point the camera at the code below. On a computer, paste the link instead.
|
||||
</p>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-muted">
|
||||
<strong>MicroSIP</strong> (Windows) and <strong>Baresip</strong> (Linux) work too, set up by
|
||||
hand: an admin can see the server, user name and password.
|
||||
</p>
|
||||
@@ -32,14 +32,14 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
|
||||
className="rounded bg-white"
|
||||
/>
|
||||
<p className="font-mono text-xs break-all">{qr.data.url}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Works until {new Date(qr.data.expiresAt).toLocaleString()}. It carries your phone
|
||||
password: don't share it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{qr.error && <Alert>{qr.error.message}</Alert>}
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
On a mobile phone, apps can miss calls while they're in the background (iPhones and
|
||||
battery-saving Android phones stop them). For calls you can't miss, use a desk phone or this
|
||||
browser, or forward unanswered calls to your mobile number.
|
||||
|
||||
@@ -56,11 +56,11 @@ export function SignInCard() {
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<h3 className="font-medium">Password</h3>
|
||||
{ways.password ? (
|
||||
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password">
|
||||
<Link className="text-link underline" to="/change-password">
|
||||
Change password
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-zinc-500">
|
||||
<p className="text-muted">
|
||||
You have no password here. An admin can give you one with Reset password.
|
||||
</p>
|
||||
)}
|
||||
@@ -92,7 +92,7 @@ export function SignInCard() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-zinc-500">
|
||||
<p className="text-muted">
|
||||
Connect your {oidc.label} account to sign in with it as well.
|
||||
</p>
|
||||
<div>
|
||||
@@ -133,7 +133,7 @@ function DirectoryConnect() {
|
||||
}}
|
||||
>
|
||||
<h3 className="font-medium">Company directory</h3>
|
||||
<p className="text-zinc-500">
|
||||
<p className="text-muted">
|
||||
Connect your directory account to sign in with its password as well.
|
||||
</p>
|
||||
<Field label="Directory username or email">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SoundPicker (05 §3): pick a recorded prompt, play it, or upload a new one right here.
|
||||
import { Play } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useId, useRef } from 'react';
|
||||
import { soundsApi } from '../api/client.ts';
|
||||
@@ -63,7 +64,7 @@ export function SoundPicker({
|
||||
void a.play().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
▶
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
<Button onClick={() => file.current?.click()} disabled={upload.isPending}>
|
||||
{upload.isPending ? 'Uploading…' : 'Upload new'}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SpamNote({ check }: { check: SpamCheck | null | undefined }) {
|
||||
>
|
||||
{high ? 'Likely spam' : 'Possible spam'}
|
||||
</span>
|
||||
<span className="text-zinc-600 dark:text-zinc-400">{spamLine(check)}</span>
|
||||
<span className="text-muted">{spamLine(check)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toggleTheme } from '../theme.ts';
|
||||
import { Button } from './ui.tsx';
|
||||
@@ -12,7 +13,7 @@ export function ThemeToggle() {
|
||||
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={() => setDark(toggleTheme())}
|
||||
>
|
||||
{dark ? '☀' : '☾'}
|
||||
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,14 @@
|
||||
import { useId, type ComponentProps, type ReactNode } from 'react';
|
||||
import { APP_NAME } from '@selfphoneng/shared';
|
||||
|
||||
const focus =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500';
|
||||
export const focusRing =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-sky-600 text-white hover:bg-sky-700 disabled:bg-sky-600/50',
|
||||
secondary:
|
||||
'border border-zinc-300 bg-white hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800',
|
||||
primary: 'bg-primary text-white hover:bg-primary-hover disabled:bg-primary/50',
|
||||
secondary: 'border border-line-strong bg-surface hover:bg-subtle',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-red-600/50',
|
||||
ghost: 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
|
||||
ghost: 'hover:bg-subtle',
|
||||
};
|
||||
|
||||
/** The icon (public/icon-192.png, cut from public/logo.png) and the product name. */
|
||||
@@ -32,13 +31,40 @@ export function Button({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed ${variants[variant]} ${focus} ${className}`}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed pointer-coarse:min-h-11 pointer-coarse:min-w-11 ${variants[variant]} ${focusRing} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const inputClass = `w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900 ${focus} aria-invalid:border-red-500`;
|
||||
/** A titled card: heading, an optional link or button on the right, then the body. */
|
||||
export function Panel({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export const inputClass = `w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm ${focusRing} aria-invalid:border-red-500`;
|
||||
|
||||
/** Label + control + error text. `children` gets the id and aria props to spread on the control. */
|
||||
export function Field({
|
||||
@@ -67,7 +93,7 @@ export function Field({
|
||||
{(error || hint) && (
|
||||
<p
|
||||
id={msgId}
|
||||
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-zinc-500'}`}
|
||||
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-muted'}`}
|
||||
>
|
||||
{error ?? hint}
|
||||
</p>
|
||||
|
||||
@@ -1,2 +1,66 @@
|
||||
@import 'tailwindcss';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Theme colors: the logo's blue and navy. Pages use these names (bg-surface, text-muted,
|
||||
border-line...), not palette colors, so light and dark are paired here once. Every text pair
|
||||
is at least 4.5:1 in both themes. */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--sp-page: #f8fafc;
|
||||
--sp-surface: #ffffff;
|
||||
--sp-subtle: #f1f5f9;
|
||||
--sp-line: #e2e8f0;
|
||||
--sp-line-strong: #cbd5e1;
|
||||
--sp-fg: #0f172a;
|
||||
--sp-muted: #475569;
|
||||
--sp-primary: #2563eb;
|
||||
--sp-primary-hover: #1d4ed8;
|
||||
--sp-primary-soft: #eff6ff;
|
||||
--sp-primary-line: #bfdbfe;
|
||||
--sp-primary-ink: #1e3a8a;
|
||||
--sp-link: #1d4ed8;
|
||||
--sp-focus: #2563eb;
|
||||
}
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--sp-page: #0b1120;
|
||||
--sp-surface: #131c2e;
|
||||
--sp-subtle: #1e293b;
|
||||
--sp-line: #253349;
|
||||
--sp-line-strong: #334155;
|
||||
--sp-fg: #f1f5f9;
|
||||
--sp-muted: #94a3b8;
|
||||
--sp-primary: #2563eb;
|
||||
--sp-primary-hover: #1d4ed8;
|
||||
--sp-primary-soft: #172554;
|
||||
--sp-primary-line: #1e3a8a;
|
||||
--sp-primary-ink: #dbeafe;
|
||||
--sp-link: #60a5fa;
|
||||
--sp-focus: #60a5fa;
|
||||
}
|
||||
@theme inline {
|
||||
--color-page: var(--sp-page);
|
||||
--color-surface: var(--sp-surface);
|
||||
--color-subtle: var(--sp-subtle);
|
||||
--color-line: var(--sp-line);
|
||||
--color-line-strong: var(--sp-line-strong);
|
||||
--color-fg: var(--sp-fg);
|
||||
--color-muted: var(--sp-muted);
|
||||
--color-primary: var(--sp-primary);
|
||||
--color-primary-hover: var(--sp-primary-hover);
|
||||
--color-primary-soft: var(--sp-primary-soft);
|
||||
--color-primary-line: var(--sp-primary-line);
|
||||
--color-primary-ink: var(--sp-primary-ink);
|
||||
--color-link: var(--sp-link);
|
||||
--color-focus: var(--sp-focus);
|
||||
}
|
||||
|
||||
/* iOS zooms into any field under 16px on focus and stays zoomed, which left the incoming call
|
||||
dialog off screen. Unlayered, so it beats text-sm. */
|
||||
@media (pointer: coarse) {
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ const router = createBrowserRouter([
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
document.body.className = 'bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100';
|
||||
document.body.className = 'bg-page text-fg';
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Link } from 'react-router';
|
||||
import { aboutApi } from '../api/client.ts';
|
||||
import { Alert, Brand } from '../components/ui.tsx';
|
||||
|
||||
const linkClass = 'text-sky-700 underline dark:text-sky-400';
|
||||
const linkClass = 'text-link underline';
|
||||
|
||||
export function AboutPage() {
|
||||
const about = useQuery({ queryKey: ['about'], queryFn: aboutApi.get });
|
||||
@@ -44,18 +44,18 @@ export function AboutPage() {
|
||||
<a
|
||||
href="/api/about/source"
|
||||
download="selfphone.tar.gz"
|
||||
className="rounded-md bg-sky-600 px-4 py-2 font-medium text-white hover:bg-sky-700"
|
||||
className="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover"
|
||||
>
|
||||
Code v{a.version}
|
||||
</a>
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-muted">
|
||||
selfphone.tar.gz, {Math.max(1, Math.round(a.source.bytes / 1e6))} MB, built{' '}
|
||||
{new Date(a.source.builtAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
a && (
|
||||
<p className="text-zinc-500">
|
||||
<p className="text-muted">
|
||||
This is a development build: the source archive is only in builds made with Docker.
|
||||
The project's repository is below.
|
||||
</p>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ChangePasswordPage() {
|
||||
return (
|
||||
<AuthCard title="Change your password">
|
||||
{forced && (
|
||||
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p className="mb-4 text-sm text-muted">
|
||||
You signed in with a temporary password. Choose a new one to continue.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -13,12 +13,12 @@ import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
|
||||
export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="w-full max-w-sm rounded-xl border border-line bg-surface p-6 shadow-sm">
|
||||
<img src="/logo-512.png" alt={APP_NAME} className="mx-auto mb-4 size-32 rounded-xl" />
|
||||
<h1 className="mb-5 text-xl font-semibold">{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
<Link to="/about" className="mt-3 text-xs text-zinc-500 hover:underline">
|
||||
<Link to="/about" className="mt-3 text-xs text-muted hover:underline">
|
||||
About SelfPhoneNG · source code
|
||||
</Link>
|
||||
</main>
|
||||
@@ -73,12 +73,12 @@ export function LoginPage() {
|
||||
{/* A full page load: the browser goes to the provider and comes back signed in. */}
|
||||
<a
|
||||
href={`/api/auth/oidc/start${next ? `?next=${encodeURIComponent(next)}` : ''}`}
|
||||
className="rounded-md bg-sky-700 px-4 py-2 text-center text-sm font-medium text-white hover:bg-sky-800"
|
||||
className="rounded-md bg-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-primary-hover"
|
||||
>
|
||||
Sign in with {oidc.label}
|
||||
</a>
|
||||
{showForm && (
|
||||
<p className="text-center text-xs text-zinc-500" aria-hidden>
|
||||
<p className="text-center text-xs text-muted" aria-hidden>
|
||||
or with your password
|
||||
</p>
|
||||
)}
|
||||
@@ -87,7 +87,7 @@ export function LoginPage() {
|
||||
{!showForm && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-center text-xs text-zinc-500 hover:underline"
|
||||
className="w-full text-center text-xs text-muted hover:underline"
|
||||
onClick={() => setAdminForm(true)}
|
||||
>
|
||||
Admin sign-in
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// First-run setup wizard (05 §2.1). Every step saves before moving on; some can be skipped.
|
||||
// M3 also brings steps 7 (users) and 8 (main number routing) because "calls work using only the
|
||||
// wizard" needs them; M6 adds "Ring everyone" to step 8 (an IVR needs a recorded greeting first).
|
||||
import { Check } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
@@ -70,7 +71,7 @@ export function SetupPage() {
|
||||
type="button"
|
||||
onClick={() => go(i + 1)}
|
||||
aria-current={step === i + 1 ? 'step' : undefined}
|
||||
className={`rounded-full px-2 py-1 ${step === i + 1 ? 'bg-sky-600 text-white' : 'bg-zinc-100 dark:bg-zinc-800'}`}
|
||||
className={`rounded-full px-2 py-1 ${step === i + 1 ? 'bg-primary text-white' : 'bg-subtle'}`}
|
||||
>
|
||||
{i + 1}. {s}
|
||||
</button>
|
||||
@@ -109,7 +110,7 @@ function Nav({
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
|
||||
<Button variant="primary" onClick={next} disabled={!canNext}>
|
||||
Next
|
||||
</Button>
|
||||
@@ -118,7 +119,7 @@ function Nav({
|
||||
{skip}
|
||||
</Button>
|
||||
)}
|
||||
{hint && <span className="text-sm text-zinc-500">{hint}</span>}
|
||||
{hint && <span className="text-sm text-muted">{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,7 +128,10 @@ function PasswordStep({ next }: { next: () => void }) {
|
||||
// Signing in with the initial password already forced a change (RequireAuth).
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm">✓ The initial admin password has been changed.</p>
|
||||
<p className="text-sm">
|
||||
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
|
||||
The initial admin password has been changed.
|
||||
</p>
|
||||
<Nav next={next} />
|
||||
</>
|
||||
);
|
||||
@@ -196,7 +200,7 @@ function CompanyForm({
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="border-t border-line pt-4">
|
||||
<Button variant="primary" type="submit" disabled={save.isPending || !name.trim()}>
|
||||
Save and continue
|
||||
</Button>
|
||||
@@ -343,7 +347,7 @@ function NetworkForm(p: {
|
||||
I've done this
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="border-t border-line pt-4">
|
||||
<Button variant="primary" type="submit" disabled={save.isPending || !done}>
|
||||
{save.isPending ? 'Saving…' : 'Save and continue'}
|
||||
</Button>
|
||||
@@ -399,7 +403,7 @@ function TrunkStep({ next }: { next: () => void }) {
|
||||
const ok = status?.label === 'Registered' || status?.label === 'Reachable';
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Connect your voip.ms sub-account. Follow the checklist, save, and wait for{' '}
|
||||
<strong>Registered</strong>.
|
||||
</p>
|
||||
@@ -457,7 +461,7 @@ function NumbersStep({ next }: { next: () => void }) {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 9.
|
||||
</p>
|
||||
{trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />}
|
||||
@@ -465,8 +469,8 @@ function NumbersStep({ next }: { next: () => void }) {
|
||||
<ul className="text-sm">
|
||||
{dids.data.map((d) => (
|
||||
<li key={d.id}>
|
||||
✓ {formatNumber(d.number)}{' '}
|
||||
{d.label && <span className="text-zinc-500">{d.label}</span>}
|
||||
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
|
||||
{formatNumber(d.number)} {d.label && <span className="text-muted">{d.label}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -554,7 +558,7 @@ function UsersStep({ next }: { next: () => void }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Add the people who get a phone (up to 10 here; more later under Users & Extensions).
|
||||
Each gets the extension you type (any free number, like 1), or the next free one, and a
|
||||
temporary password, shown once.
|
||||
@@ -563,7 +567,8 @@ function UsersStep({ next }: { next: () => void }) {
|
||||
<ul className="text-sm">
|
||||
{withExt.map((u) => (
|
||||
<li key={u.id}>
|
||||
✓ {u.extension!.number} {u.displayName}
|
||||
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
|
||||
{u.extension!.number} {u.displayName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -614,7 +619,7 @@ function UsersStep({ next }: { next: () => void }) {
|
||||
</form>
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
{created.length > 0 && (
|
||||
<div className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<div className="rounded-md border border-line p-3 text-sm">
|
||||
<p className="mb-2 font-medium">
|
||||
Give each person their temporary password (shown only now):
|
||||
</p>
|
||||
@@ -624,7 +629,7 @@ function UsersStep({ next }: { next: () => void }) {
|
||||
<tr key={c.email}>
|
||||
<td>{c.number}</td>
|
||||
<td>{c.name}</td>
|
||||
<td className="text-zinc-500">{c.email}</td>
|
||||
<td className="text-muted">{c.email}</td>
|
||||
<td className="font-mono">{c.password}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -682,7 +687,7 @@ function RoutingStep({ next }: { next: () => void }) {
|
||||
const incomplete = Object.values(dest).some((d) => d === null);
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
A digital receptionist ("press 1 for sales…") needs a recorded greeting: build it under
|
||||
Digital receptionist later and pick it here or on the Phone numbers page.
|
||||
</p>
|
||||
@@ -715,7 +720,7 @@ function RoutingStep({ next }: { next: () => void }) {
|
||||
})}
|
||||
{everyone.isError && <Alert>{everyone.error.message}</Alert>}
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="border-t border-line pt-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => save.mutate()}
|
||||
@@ -740,7 +745,7 @@ function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
|
||||
<li>From a desk phone, call your mobile: it shows your main number as caller ID.</li>
|
||||
<li>Everything here can be changed later in the admin pages.</li>
|
||||
</ul>
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="border-t border-line pt-4">
|
||||
<Button variant="primary" onClick={onFinish} disabled={busy}>
|
||||
Finish
|
||||
</Button>
|
||||
@@ -753,6 +758,6 @@ function Loading({ q }: { q: { isError: boolean; error: Error | null } }): React
|
||||
return q.isError ? (
|
||||
<Alert>{q.error!.message}</Alert>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Loading…</p>
|
||||
<p className="text-sm text-muted">Loading…</p>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ function TwoStep() {
|
||||
<h2 id="two-step-h" className="font-medium">
|
||||
Two-step sign-in
|
||||
</h2>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-muted">
|
||||
Besides your password, signing in asks for a code from an authenticator app on your phone
|
||||
(any app that does TOTP, such as Aegis, 2FAS, Google or Microsoft Authenticator).
|
||||
</p>
|
||||
@@ -115,7 +115,7 @@ function TwoStep() {
|
||||
height={240}
|
||||
className="rounded bg-white"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Or type this key: <span className="font-mono break-all">{setup.data.secret}</span>
|
||||
</p>
|
||||
<Field label="2. Enter the 6-digit code it shows">
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
|
||||
// Admin shell (05 §2): sidebar grouped by section; unbuilt pages say which milestone brings them.
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Activity,
|
||||
AudioLines,
|
||||
Ban,
|
||||
BellRing,
|
||||
BookUser,
|
||||
Cable,
|
||||
ChartColumn,
|
||||
Clock,
|
||||
Hash,
|
||||
Headset,
|
||||
HistoryIcon,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
ListOrdered,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
MonitorSmartphone,
|
||||
Music,
|
||||
PhoneOutgoing,
|
||||
Settings,
|
||||
Split,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
|
||||
import type { AuthUser } from '@selfphoneng/shared';
|
||||
@@ -9,28 +33,65 @@ import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
||||
import { Brand, Button } from '../../components/ui.tsx';
|
||||
import { subscribeLive, useLive } from '../../live/store.ts';
|
||||
|
||||
// Grouped by job rather than spec order, so the 21 pages scan as six short lists.
|
||||
export const ADMIN_PAGES = [
|
||||
{ path: '', label: 'Dashboard', milestone: null },
|
||||
{ path: 'users', label: 'Users & Extensions', milestone: null },
|
||||
{ path: 'trunks', label: 'Trunks', milestone: null },
|
||||
{ path: 'numbers', label: 'Phone numbers', milestone: null },
|
||||
{ path: 'outbound', label: 'Outbound rules', milestone: null },
|
||||
{ path: 'ring-groups', label: 'Ring groups', milestone: null },
|
||||
{ path: 'queues', label: 'Queues', milestone: null },
|
||||
{ path: 'ivrs', label: 'Digital receptionist', milestone: null },
|
||||
{ path: 'hours', label: 'Office hours', milestone: null },
|
||||
{ path: 'conferences', label: 'Conferences', milestone: null },
|
||||
{ path: 'paging', label: 'Paging', milestone: null },
|
||||
{ path: 'sounds', label: 'Sounds & music', milestone: null },
|
||||
{ path: 'devices', label: 'Phones', milestone: null },
|
||||
{ path: 'contacts', label: 'Contacts', milestone: null },
|
||||
{ path: 'calls', label: 'Call history', milestone: null },
|
||||
{ path: 'recordings', label: 'Recordings', milestone: null },
|
||||
{ path: 'reports', label: 'Reports', milestone: null },
|
||||
{ path: 'sms', label: 'SMS', milestone: 'M9' },
|
||||
{ path: 'blocked', label: 'Blocked numbers', milestone: null },
|
||||
{ path: 'settings', label: 'Settings', milestone: null },
|
||||
{ path: 'system', label: 'System', milestone: null },
|
||||
{ path: '', label: 'Dashboard', milestone: null, section: '', icon: LayoutDashboard },
|
||||
{ path: 'trunks', label: 'Trunks', milestone: null, section: 'Call routing', icon: Cable },
|
||||
{ path: 'numbers', label: 'Phone numbers', milestone: null, section: 'Call routing', icon: Hash },
|
||||
{
|
||||
path: 'outbound',
|
||||
label: 'Outbound rules',
|
||||
milestone: null,
|
||||
section: 'Call routing',
|
||||
icon: PhoneOutgoing,
|
||||
},
|
||||
{
|
||||
path: 'ring-groups',
|
||||
label: 'Ring groups',
|
||||
milestone: null,
|
||||
section: 'Call routing',
|
||||
icon: BellRing,
|
||||
},
|
||||
{ path: 'queues', label: 'Queues', milestone: null, section: 'Call routing', icon: ListOrdered },
|
||||
{
|
||||
path: 'ivrs',
|
||||
label: 'Digital receptionist',
|
||||
milestone: null,
|
||||
section: 'Call routing',
|
||||
icon: Split,
|
||||
},
|
||||
{ path: 'hours', label: 'Office hours', milestone: null, section: 'Call routing', icon: Clock },
|
||||
{
|
||||
path: 'blocked',
|
||||
label: 'Blocked numbers',
|
||||
milestone: null,
|
||||
section: 'Call routing',
|
||||
icon: Ban,
|
||||
},
|
||||
{ path: 'users', label: 'Users & Extensions', milestone: null, section: 'People', icon: Users },
|
||||
{ path: 'devices', label: 'Phones', milestone: null, section: 'People', icon: MonitorSmartphone },
|
||||
{ path: 'contacts', label: 'Contacts', milestone: null, section: 'People', icon: BookUser },
|
||||
{
|
||||
path: 'conferences',
|
||||
label: 'Conferences',
|
||||
milestone: null,
|
||||
section: 'Features',
|
||||
icon: Headset,
|
||||
},
|
||||
{ path: 'paging', label: 'Paging', milestone: null, section: 'Features', icon: Megaphone },
|
||||
{ path: 'sounds', label: 'Sounds & music', milestone: null, section: 'Features', icon: Music },
|
||||
{ path: 'sms', label: 'SMS', milestone: 'M9', section: 'Features', icon: MessageSquare },
|
||||
{ path: 'calls', label: 'Call history', milestone: null, section: 'History', icon: HistoryIcon },
|
||||
{
|
||||
path: 'recordings',
|
||||
label: 'Recordings',
|
||||
milestone: null,
|
||||
section: 'History',
|
||||
icon: AudioLines,
|
||||
},
|
||||
{ path: 'reports', label: 'Reports', milestone: null, section: 'History', icon: ChartColumn },
|
||||
{ path: 'settings', label: 'Settings', milestone: null, section: 'System', icon: Settings },
|
||||
{ path: 'system', label: 'System', milestone: null, section: 'System', icon: Activity },
|
||||
] as const;
|
||||
|
||||
export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
@@ -52,29 +113,39 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
|
||||
const links = (
|
||||
<nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{ADMIN_PAGES.map((p) => (
|
||||
<li key={p.path}>
|
||||
<NavLink
|
||||
to={`/admin${p.path ? `/${p.path}` : ''}`}
|
||||
end={p.path === ''}
|
||||
onClick={() => setNavOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center justify-between rounded-md px-3 py-1.5 text-sm ${isActive ? 'bg-sky-100 font-medium text-sky-900 dark:bg-sky-950 dark:text-sky-100' : 'hover:bg-zinc-200/60 dark:hover:bg-zinc-800'} ${p.milestone ? 'text-zinc-500' : ''}`
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
{p.milestone && <span className="text-[10px] text-zinc-400">{p.milestone}</span>}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{[...new Set(ADMIN_PAGES.map((p) => p.section))].map((section) => (
|
||||
<div key={section} className="mt-3 first:mt-0">
|
||||
{section && (
|
||||
<h2 className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted uppercase">
|
||||
{section}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{ADMIN_PAGES.filter((p) => p.section === section).map((p) => (
|
||||
<li key={p.path}>
|
||||
<NavLink
|
||||
to={`/admin${p.path ? `/${p.path}` : ''}`}
|
||||
end={p.path === ''}
|
||||
onClick={() => setNavOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`relative flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm pointer-coarse:py-2.5 ${isActive ? 'bg-primary-soft font-medium text-primary-ink before:absolute before:inset-y-1.5 before:left-0 before:w-1 before:rounded-full before:bg-primary' : 'hover:bg-subtle'} ${p.milestone ? 'text-muted' : ''}`
|
||||
}
|
||||
>
|
||||
<p.icon className="size-4 shrink-0" />
|
||||
<span className="flex-1">{p.label}</span>
|
||||
{p.milestone && <span className="text-xs text-muted">{p.milestone}</span>}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh">
|
||||
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-zinc-200 bg-zinc-50 md:flex dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-line bg-surface md:flex">
|
||||
<p className="px-4 py-4 text-lg">
|
||||
<Brand />
|
||||
</p>
|
||||
@@ -82,7 +153,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-1 border-b border-zinc-200 px-2 py-2 sm:gap-2 sm:px-4 dark:border-zinc-800">
|
||||
<header className="flex items-center gap-1 border-b border-line px-2 py-2 sm:gap-2 sm:px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="md:hidden"
|
||||
@@ -91,13 +162,11 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
aria-label="Menu"
|
||||
onClick={() => setNavOpen((o) => !o)}
|
||||
>
|
||||
☰
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
<Brand className="md:hidden" />
|
||||
<div className="ml-auto flex items-center gap-1 whitespace-nowrap sm:gap-2">
|
||||
<span className="hidden text-sm text-zinc-600 sm:inline dark:text-zinc-400">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
|
||||
<ThemeToggle />
|
||||
{/* On phones this lives in the menu instead. */}
|
||||
<span className="hidden sm:contents">
|
||||
@@ -111,12 +180,9 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
</div>
|
||||
</header>
|
||||
{navOpen && (
|
||||
<div
|
||||
id="mobile-nav"
|
||||
className="border-b border-zinc-200 bg-zinc-50 pt-2 md:hidden dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<div id="mobile-nav" className="border-b border-line bg-surface pt-2 md:hidden">
|
||||
{links}
|
||||
<div className="border-t border-zinc-200 px-2 py-2 dark:border-zinc-800">
|
||||
<div className="border-t border-line px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
@@ -148,7 +214,7 @@ export function ComingSoon({ label, milestone }: { label: string; milestone: str
|
||||
return (
|
||||
<section>
|
||||
<h1 className="mb-2 text-xl font-semibold">{label}</h1>
|
||||
<p className="text-sm text-zinc-500">Coming in {milestone}.</p>
|
||||
<p className="text-sm text-muted">Coming in {milestone}.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings.
|
||||
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings. Users'
|
||||
// Report spam waits at the top for Block or Dismiss.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import { blockedApi } from '../../api/client.ts';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
import { Alert, Button, Field, Panel, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export function BlockedPage() {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list });
|
||||
const reports = useQuery({ queryKey: ['blocked', 'reports'], queryFn: blockedApi.reports });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] });
|
||||
const [number, setNumber] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
@@ -24,14 +26,56 @@ export function BlockedPage() {
|
||||
mutationFn: (id: string) => blockedApi.remove(id),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
// Blocking a number clears every report of it (api).
|
||||
const block = useMutation({
|
||||
mutationFn: (r: { number: string; reason: string }) => blockedApi.create(r),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const dismiss = useMutation({
|
||||
mutationFn: (id: string) => blockedApi.dismiss(id),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const error = add.error ?? remove.error ?? block.error ?? dismiss.error ?? reports.error;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h1 className="text-xl font-semibold">Blocked numbers</h1>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Calls from these numbers are rejected before any phone rings. Emergency calls you make are
|
||||
never affected.
|
||||
</p>
|
||||
{!!reports.data?.length && (
|
||||
<Panel title="Reported as spam">
|
||||
<ul className="divide-y divide-line">
|
||||
{reports.data.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
|
||||
<span className="font-mono">{formatNumber(r.number)}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{r.reason}
|
||||
<span className="block text-xs text-muted">
|
||||
{r.reportedBy ?? 'A former user'}, {new Date(r.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label={`Block ${formatNumber(r.number)}`}
|
||||
disabled={block.isPending}
|
||||
onClick={() => block.mutate({ number: r.number, reason: r.reason })}
|
||||
>
|
||||
Block
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Dismiss the report of ${formatNumber(r.number)}`}
|
||||
onClick={() => dismiss.mutate(r.id)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
@@ -66,14 +110,14 @@ export function BlockedPage() {
|
||||
Block
|
||||
</Button>
|
||||
</form>
|
||||
{(add.error ?? remove.error) && <Alert>{(add.error ?? remove.error)!.message}</Alert>}
|
||||
{list.data?.length === 0 && <p className="text-sm text-zinc-500">Nobody is blocked.</p>}
|
||||
{error && <Alert>{error.message}</Alert>}
|
||||
{list.data?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>}
|
||||
{list.data && list.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{list.data.map((b) => (
|
||||
<li key={b.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm">
|
||||
<span className="font-mono">{formatNumber(b.number)}</span>
|
||||
<span className="flex-1 text-zinc-500">{b.reason}</span>
|
||||
<span className="flex-1 text-muted">{b.reason}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Unblock ${formatNumber(b.number)}`}
|
||||
|
||||
@@ -21,10 +21,10 @@ export const fmtDuration = (s: number) =>
|
||||
export const STATUS_LOOK: Record<CallDto['status'], string> = {
|
||||
answered: 'bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100',
|
||||
missed: 'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100',
|
||||
voicemail: 'bg-sky-100 text-sky-900 dark:bg-sky-900 dark:text-sky-100',
|
||||
voicemail: 'bg-primary-soft text-primary-ink',
|
||||
abandoned: 'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
|
||||
busy: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
|
||||
failed: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
|
||||
busy: 'bg-line text-fg',
|
||||
failed: 'bg-line text-fg',
|
||||
};
|
||||
export const DIRECTION_TEXT = { inbound: 'In', outbound: 'Out', internal: 'Internal' };
|
||||
|
||||
@@ -71,7 +71,7 @@ export function CallsPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Call history</h1>
|
||||
<a
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||
className="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm font-medium hover:bg-subtle"
|
||||
href={callsApi.csvUrl(filter)}
|
||||
>
|
||||
Export CSV
|
||||
@@ -148,10 +148,10 @@ export function CallsPage() {
|
||||
</div>
|
||||
</form>
|
||||
{list.isError && <Alert>{list.error.message}</Alert>}
|
||||
{list.data && calls.length === 0 && <p className="text-sm text-zinc-500">No calls match.</p>}
|
||||
{list.data && calls.length === 0 && <p className="text-sm text-muted">No calls match.</p>}
|
||||
<ul
|
||||
aria-label="Calls"
|
||||
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
|
||||
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
|
||||
>
|
||||
{calls.map((c) => (
|
||||
<li key={c.id} className="flex flex-col gap-2 px-3 py-2 text-sm">
|
||||
@@ -161,15 +161,15 @@ export function CallsPage() {
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-left"
|
||||
onClick={() => setOpen(open === c.id ? null : c.id)}
|
||||
>
|
||||
<span className="w-36 text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>
|
||||
<span className="w-36 text-muted">{new Date(c.startedAt).toLocaleString()}</span>
|
||||
<span className="w-14">{DIRECTION_TEXT[c.direction]}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium">{c.fromName ?? fmtNumber(c.fromNumber)}</span>
|
||||
{c.fromName && (
|
||||
<span className="text-zinc-500"> {fmtNumber(c.fromNumber)}</span>
|
||||
<span className="text-muted"> {fmtNumber(c.fromNumber)}</span>
|
||||
)} → {fmtNumber(c.toNumber)}
|
||||
{c.answeredBy && (
|
||||
<span className="text-zinc-500"> · answered by {c.answeredBy.name}</span>
|
||||
<span className="text-muted"> · answered by {c.answeredBy.name}</span>
|
||||
)}
|
||||
<SpamNote check={c.spam} />
|
||||
</span>
|
||||
@@ -202,12 +202,12 @@ export function CallsPage() {
|
||||
|
||||
function Legs({ id }: { id: string }) {
|
||||
const d = useQuery({ queryKey: ['call', id], queryFn: () => callsApi.get(id) });
|
||||
if (!d.data) return <p className="text-xs text-zinc-500">Loading…</p>;
|
||||
if (!d.data) return <p className="text-xs text-muted">Loading…</p>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<caption className="sr-only">Call legs</caption>
|
||||
<thead className="text-left text-zinc-500">
|
||||
<thead className="text-left text-muted">
|
||||
<tr>
|
||||
<th className="pr-3 font-normal">Time</th>
|
||||
<th className="pr-3 font-normal">From channel</th>
|
||||
|
||||
@@ -35,13 +35,13 @@ export function ConferencesPage() {
|
||||
</div>
|
||||
{rooms.isError && <Alert>{rooms.error.message}</Alert>}
|
||||
{rooms.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No conference rooms yet. A room is a number (like 800) that several people dial to talk
|
||||
together, with an optional PIN.
|
||||
</p>
|
||||
)}
|
||||
{rooms.data && rooms.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{rooms.data.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
@@ -49,7 +49,7 @@ export function ConferencesPage() {
|
||||
>
|
||||
<span className="font-mono">{c.number}</span>
|
||||
<span className="font-medium">{c.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-500">
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{c.pin ? `PIN ${c.pin}` : 'No PIN'}
|
||||
{c.adminPin ? ` · admin PIN ${c.adminPin}` : ''} · up to {c.maxMembers}
|
||||
{c.record ? ' · recorded' : ''}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
|
||||
// queue tiles, parked calls and extensions online.
|
||||
// queue tiles, parked calls and extensions online, spam reports waiting.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
|
||||
import { Link } from 'react-router';
|
||||
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
|
||||
import { blockedApi, liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
|
||||
import { ConfirmDialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, inputClass } from '../../components/ui.tsx';
|
||||
import { fmtNumber } from './CallsPage.tsx';
|
||||
@@ -15,8 +15,8 @@ import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
||||
|
||||
function Card({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<h2 className="text-sm text-zinc-500">{title}</h2>
|
||||
<div className="rounded-xl border border-line bg-surface shadow-sm p-4">
|
||||
<h2 className="text-sm text-muted">{title}</h2>
|
||||
<div className="mt-1 text-2xl font-semibold">{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -31,6 +31,11 @@ export function DashboardPage() {
|
||||
const onCall = list.filter((e) =>
|
||||
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
|
||||
).length;
|
||||
const reports = useQuery({
|
||||
queryKey: ['blocked', 'reports'],
|
||||
queryFn: blockedApi.reports,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
@@ -40,7 +45,7 @@ export function DashboardPage() {
|
||||
(t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'),
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<Card title="Extensions online">
|
||||
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
|
||||
</Card>
|
||||
@@ -55,12 +60,18 @@ export function DashboardPage() {
|
||||
<Card title="Telephony engine">
|
||||
<span
|
||||
className={
|
||||
amiConnected ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
amiConnected ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
>
|
||||
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
|
||||
</span>
|
||||
</Card>
|
||||
<Card title="Spam reports">
|
||||
<Link to="/admin/blocked" className="hover:underline">
|
||||
{reports.data?.length ?? '…'}
|
||||
<span className="sr-only"> waiting on Blocked numbers</span>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<QueueTiles />
|
||||
@@ -72,19 +83,19 @@ export function DashboardPage() {
|
||||
<div>
|
||||
<h2 className="mb-2 font-medium">Extensions</h2>
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No extensions yet. Give a user a number under Users & Extensions.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{list.map((e) => (
|
||||
<li key={e.number} className="flex items-center gap-3 px-3 py-2 text-sm">
|
||||
<StatusDot ext={e} />
|
||||
<span className="w-14 font-mono">{e.number}</span>
|
||||
<span className="flex-1 truncate">{e.name}</span>
|
||||
<span className="text-zinc-500">{statusLabel(e)}</span>
|
||||
<span className="text-muted">{statusLabel(e)}</span>
|
||||
{e.contacts && (
|
||||
<span className="hidden w-24 text-right text-zinc-500 sm:inline">
|
||||
<span className="hidden w-24 text-right text-muted sm:inline">
|
||||
{e.contacts.length} device{e.contacts.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
@@ -118,15 +129,12 @@ function QueueTiles() {
|
||||
null,
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={q.number}
|
||||
className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
|
||||
>
|
||||
<Link className="text-sm text-zinc-500 hover:underline" to={`queues/${q.id}`}>
|
||||
<li key={q.number} className="rounded-xl border border-line bg-surface shadow-sm p-4">
|
||||
<Link className="text-sm text-muted hover:underline" to={`queues/${q.id}`}>
|
||||
{q.number} {q.name}
|
||||
</Link>
|
||||
<div className="mt-1 text-2xl font-semibold">{q.waiting.length} waiting</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<div className="text-sm text-muted">
|
||||
Longest {oldest ? since(oldest, now) : '—'} · {available(q)} of {q.members.length}{' '}
|
||||
agents free
|
||||
</div>
|
||||
@@ -258,11 +266,11 @@ function LiveCalls() {
|
||||
</p>
|
||||
)}
|
||||
{calls.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">No calls right now.</p>
|
||||
<p className="text-sm text-muted">No calls right now.</p>
|
||||
) : (
|
||||
<ul
|
||||
aria-label="Calls now"
|
||||
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
|
||||
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
|
||||
>
|
||||
{calls.map((c) => (
|
||||
<li
|
||||
@@ -272,15 +280,15 @@ function LiveCalls() {
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium">{c.from.name ?? fmtNumber(c.from.number)}</span>
|
||||
{c.from.name && <span className="text-zinc-500"> {fmtNumber(c.from.number)}</span>}
|
||||
{c.from.name && <span className="text-muted"> {fmtNumber(c.from.number)}</span>}
|
||||
{' → '}
|
||||
{fmtNumber(c.to.number)}
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-muted">
|
||||
{' '}
|
||||
· {STATE_TEXT[c.state]} {since(c.answeredAt ?? c.startedAt, now)}
|
||||
{c.recording && ' · recording'}
|
||||
</span>
|
||||
<span className="block font-mono text-xs text-zinc-500" data-channels>
|
||||
<span className="block font-mono text-xs text-muted" data-channels>
|
||||
{c.channels.join(' ')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -42,14 +42,14 @@ export function IvrsPage() {
|
||||
</div>
|
||||
{list.isError && <Alert>{list.error.message}</Alert>}
|
||||
{list.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No menus yet. A menu greets callers ("Thanks for calling. For sales press 1…") and sends
|
||||
them where they choose. Upload the greeting under Sounds & music, or right in the
|
||||
form.
|
||||
</p>
|
||||
)}
|
||||
{list.data && list.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{list.data.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
@@ -57,7 +57,7 @@ export function IvrsPage() {
|
||||
>
|
||||
<span className="font-mono">{r.number}</span>
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-500">
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{r.options.map((o) => `${o.digit}: ${o.summary}`).join(' · ') || 'No keys yet'}
|
||||
</span>
|
||||
<Button onClick={() => setEditing(r)}>Edit</Button>
|
||||
@@ -230,10 +230,10 @@ function IvrForm({
|
||||
aria-label={`Key ${d}`}
|
||||
aria-pressed={selected === d}
|
||||
onClick={() => setSelected(d)}
|
||||
className={`flex min-h-16 flex-col items-start rounded-md border p-2 text-left text-xs ${selected === d ? 'border-sky-500 ring-2 ring-sky-500' : 'border-zinc-300 dark:border-zinc-700'} ${dest ? 'bg-sky-50 dark:bg-sky-950' : ''}`}
|
||||
className={`flex min-h-16 flex-col items-start rounded-md border p-2 text-left text-xs ${selected === d ? 'border-primary ring-2 ring-primary' : 'border-line-strong'} ${dest ? 'bg-primary-soft' : ''}`}
|
||||
>
|
||||
<span className="text-lg font-semibold">{d}</span>
|
||||
<span className="line-clamp-2 text-zinc-600 dark:text-zinc-400">
|
||||
<span className="line-clamp-2 text-muted">
|
||||
{dest ? describe(dest) : dest === null ? 'Not finished' : 'Unused'}
|
||||
</span>
|
||||
</button>
|
||||
@@ -242,7 +242,7 @@ function IvrForm({
|
||||
</div>
|
||||
{errors.options && <Alert>{errors.options}</Alert>}
|
||||
{selected && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-zinc-200 p-3 dark:border-zinc-800">
|
||||
<div className="flex flex-col gap-2 rounded-md border border-line p-3">
|
||||
{keys[selected] === undefined ? (
|
||||
<Button onClick={() => setKeys((k) => ({ ...k, [selected]: { type: 'hangup' } }))}>
|
||||
Use key {selected}
|
||||
@@ -281,7 +281,7 @@ function IvrForm({
|
||||
/>
|
||||
<span>
|
||||
Callers may dial an extension directly (e.g. 101)
|
||||
<span className="block text-xs text-zinc-500">
|
||||
<span className="block text-xs text-muted">
|
||||
A key that is also the first digit of an extension waits 3 seconds for more digits.
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -37,26 +37,26 @@ export function NumbersPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{noTrunk && (
|
||||
<p className="text-sm text-zinc-500">Add a trunk first: numbers arrive through one.</p>
|
||||
<p className="text-sm text-muted">Add a trunk first: numbers arrive through one.</p>
|
||||
)}
|
||||
{dids.isError && <Alert>{dids.error.message}</Alert>}
|
||||
{dids.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
|
||||
{dids.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{dids.data?.length === 0 && !noTrunk && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No numbers yet. Add the numbers (DIDs) you bought at voip.ms.
|
||||
</p>
|
||||
)}
|
||||
{dids.data && dids.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{dids.data.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-mono">{formatNumber(d.number)}</span>
|
||||
{d.label && <span className="text-zinc-500">{d.label}</span>}
|
||||
{d.label && <span className="text-muted">{d.label}</span>}
|
||||
<span className="flex-1 truncate">→ {d.destinationSummary}</span>
|
||||
<span className="hidden text-zinc-500 sm:inline">{trunkName(d.trunkId)}</span>
|
||||
<span className="hidden text-muted sm:inline">{trunkName(d.trunkId)}</span>
|
||||
<Button onClick={() => setEditing(d)}>Edit</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -235,7 +235,7 @@ function DidDrawer({
|
||||
<input type="checkbox" className="mt-1" {...form.register('recordInbound')} />
|
||||
<span>
|
||||
Record every call to this number
|
||||
<span className="block text-xs text-zinc-500">
|
||||
<span className="block text-xs text-muted">
|
||||
Callers hear the recording notice from Settings → Recording first, if you set one.
|
||||
Check the consent rules where you and your callers are.
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Office hours (05 §2, 03 §6.10): when calls go one way or another. A weekly grid (click and drag
|
||||
// half hours), the exact ranges as a list, holidays, and force open / closed / automatic.
|
||||
import { X } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
@@ -36,11 +37,11 @@ const rangeText = (r: OpenRange) =>
|
||||
export function OfficeHoursPage() {
|
||||
const [tab, setTab] = useState<'hours' | 'holidays'>('hours');
|
||||
const tabClass = (on: boolean) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium ${on ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
|
||||
`rounded-md px-3 py-2 text-sm font-medium ${on ? 'bg-subtle' : 'hover:bg-subtle'}`;
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h1 className="text-xl font-semibold">Office hours</h1>
|
||||
<div role="tablist" className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800">
|
||||
<div role="tablist" className="flex gap-1 border-b border-line pb-2">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'hours'}
|
||||
@@ -108,7 +109,7 @@ function HoursTab() {
|
||||
{list.isError && <Alert>{list.error.message}</Alert>}
|
||||
{override.error && <Alert>{override.error.message}</Alert>}
|
||||
{list.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No office hours yet. Set your opening hours, then point your phone number at them: callers
|
||||
go one way when you're open and another when you're closed.
|
||||
</p>
|
||||
@@ -117,13 +118,13 @@ function HoursTab() {
|
||||
<article
|
||||
key={tc.id}
|
||||
aria-label={tc.name}
|
||||
className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
|
||||
className="flex flex-col gap-3 rounded-xl border border-line bg-surface shadow-sm p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="flex-1 font-semibold">{tc.name}</h2>
|
||||
<StatusChip tc={tc} />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-sm text-muted">
|
||||
{tc.openRanges.map(rangeText).join('; ') || 'Never open'} · {tc.timezone}
|
||||
{tc.useHolidays ? ' · closed on holidays' : ''}
|
||||
{tc.overrideDigit !== null &&
|
||||
@@ -199,7 +200,7 @@ function WeekGrid({
|
||||
onPointerUp={() => setPaint(null)}
|
||||
onPointerLeave={() => setPaint(null)}
|
||||
>
|
||||
<div className="grid w-max grid-cols-[2.5rem_repeat(48,0.75rem)] gap-px text-[10px] text-zinc-500">
|
||||
<div className="grid w-max grid-cols-[2.5rem_repeat(48,0.75rem)] gap-px text-[10px] text-muted">
|
||||
<span />
|
||||
{Array.from({ length: SLOTS }, (_, i) => (
|
||||
<span key={i} className="text-center">
|
||||
@@ -213,7 +214,7 @@ function WeekGrid({
|
||||
<span
|
||||
key={i}
|
||||
data-cell={`${d}-${i}`}
|
||||
className={`h-5 cursor-pointer touch-none rounded-sm ${on ? 'bg-green-500' : 'bg-zinc-200 dark:bg-zinc-800'}`}
|
||||
className={`h-5 cursor-pointer touch-none rounded-sm ${on ? 'bg-green-500' : 'bg-line'}`}
|
||||
onPointerDown={(e) => {
|
||||
// Touch captures the pointer to this cell; let go so dragging paints the others.
|
||||
const el = e.target as Element;
|
||||
@@ -254,14 +255,14 @@ function RangeList({
|
||||
aria-label={`Remove ${rangeText(r)}`}
|
||||
onClick={() => onChange(ranges.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
{ranges.length === 0 && <li className="text-zinc-500">No open hours: always closed.</li>}
|
||||
{ranges.length === 0 && <li className="text-muted">No open hours: always closed.</li>}
|
||||
</ul>
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2 rounded-md border border-zinc-200 p-2 dark:border-zinc-800">
|
||||
<legend className="px-1 text-xs text-zinc-500">Add hours</legend>
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2 rounded-md border border-line p-2">
|
||||
<legend className="px-1 text-xs text-muted">Add hours</legend>
|
||||
{DAYS.map((d) => (
|
||||
<label key={d} className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
@@ -417,7 +418,7 @@ function HoursForm({
|
||||
</Field>
|
||||
<fieldset className="flex min-w-0 flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Open hours</legend>
|
||||
<p className="text-xs text-zinc-500">Click or drag across the grid; green is open.</p>
|
||||
<p className="text-xs text-muted">Click or drag across the grid; green is open.</p>
|
||||
<WeekGrid ranges={ranges} onChange={setRanges} />
|
||||
{errors.openRanges && <Alert>{errors.openRanges}</Alert>}
|
||||
<RangeList ranges={ranges} onChange={setRanges} />
|
||||
@@ -544,18 +545,18 @@ function HolidaysTab() {
|
||||
Add US federal holidays for {year}
|
||||
</Button>
|
||||
{importUs.data && (
|
||||
<span role="status" className="text-sm text-zinc-500">
|
||||
<span role="status" className="text-sm text-muted">
|
||||
Added {importUs.data.added}.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No holidays yet.</p>}
|
||||
{list.data?.length === 0 && <p className="text-sm text-muted">No holidays yet.</p>}
|
||||
{list.data && list.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{list.data.map((h) => (
|
||||
<li key={h.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm">
|
||||
<span className="flex-1 font-medium">{h.name}</span>
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-muted">
|
||||
{fmt(h.startDate)}
|
||||
{h.endDate !== h.startDate && ` – ${fmt(h.endDate)}`}
|
||||
{h.recurringYearly && ' · every year'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Outbound rules (05 §2, 03 §6.12): ordered list (first match wins), form, and a test box.
|
||||
// ponytail: ↑/↓ buttons instead of drag-to-reorder; keyboard-friendly, no drag library.
|
||||
import { ArrowDown, ArrowUp, X } from 'lucide-react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
@@ -56,19 +57,17 @@ export function OutboundPage() {
|
||||
New rule
|
||||
</Button>
|
||||
</div>
|
||||
<p className="max-w-prose text-sm text-zinc-500">
|
||||
<p className="max-w-prose text-sm text-muted">
|
||||
When someone dials an outside number, the first rule whose pattern matches decides how the
|
||||
call goes out. Put more specific rules (like toll-free) above general ones.
|
||||
</p>
|
||||
{routes.isError && <Alert>{routes.error.message}</Alert>}
|
||||
{move.isError && <Alert>{move.error.message}</Alert>}
|
||||
{routes.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No rules yet. They are created with your first trunk.
|
||||
</p>
|
||||
<p className="text-sm text-muted">No rules yet. They are created with your first trunk.</p>
|
||||
)}
|
||||
{routes.data && routes.data.length > 0 && (
|
||||
<ol className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ol className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{routes.data.map((r, i) => (
|
||||
<li
|
||||
key={r.id}
|
||||
@@ -82,7 +81,7 @@ export function OutboundPage() {
|
||||
disabled={i === 0 || move.isPending}
|
||||
onClick={() => move.mutate({ from: i, to: i - 1 })}
|
||||
>
|
||||
↑
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -91,18 +90,18 @@ export function OutboundPage() {
|
||||
disabled={i === routes.data.length - 1 || move.isPending}
|
||||
onClick={() => move.mutate({ from: i, to: i + 1 })}
|
||||
>
|
||||
↓
|
||||
<ArrowDown className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<span className="w-5 text-zinc-500">{i + 1}.</span>
|
||||
<span className={`font-medium ${r.enabled ? '' : 'text-zinc-400 line-through'}`}>
|
||||
<span className="w-5 text-muted">{i + 1}.</span>
|
||||
<span className={`font-medium ${r.enabled ? '' : 'text-muted line-through'}`}>
|
||||
{r.name}
|
||||
</span>
|
||||
{!r.enabled && <span className="text-xs text-zinc-500">off</span>}
|
||||
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
|
||||
{!r.enabled && <span className="text-xs text-muted">off</span>}
|
||||
<span className="flex-1 truncate font-mono text-xs text-muted">
|
||||
{r.patterns.map((p) => p.match).join(' ')}
|
||||
</span>
|
||||
<span className="hidden text-zinc-500 sm:inline">
|
||||
<span className="hidden text-muted sm:inline">
|
||||
{CLASS_LABEL[r.requiredClass]} · {r.trunkIds.map(trunkName).join(' → ')}
|
||||
</span>
|
||||
<Button onClick={() => setEditing(r)}>Edit</Button>
|
||||
@@ -162,7 +161,7 @@ function TestBox() {
|
||||
if (!exts.data?.length) return null;
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
|
||||
className="flex flex-col gap-3 rounded-xl border border-line bg-surface shadow-sm p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (number.trim()) test.mutate();
|
||||
@@ -303,7 +302,7 @@ function RouteDrawer({
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Patterns</legend>
|
||||
<details className="text-xs text-zinc-500">
|
||||
<details className="text-xs text-muted">
|
||||
<summary className="cursor-pointer">Pattern help</summary>
|
||||
<p className="mt-1">
|
||||
<code>X</code> any digit, <code>Z</code> 1–9, <code>N</code> 2–9, <code>[1-5]</code>{' '}
|
||||
@@ -342,7 +341,7 @@ function RouteDrawer({
|
||||
onClick={() => patterns.remove(i)}
|
||||
disabled={patterns.fields.length === 1}
|
||||
>
|
||||
✕
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -375,7 +374,7 @@ function RouteDrawer({
|
||||
<ol className="flex flex-col gap-1">
|
||||
{chosen.map((id, i) => (
|
||||
<li key={id} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-5 text-zinc-500">{i + 1}.</span>
|
||||
<span className="w-5 text-muted">{i + 1}.</span>
|
||||
<span className="flex-1">{trunks.find((t) => t.id === id)?.name ?? id}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -387,14 +386,14 @@ function RouteDrawer({
|
||||
setTrunks(ids);
|
||||
}}
|
||||
>
|
||||
↑
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Remove trunk"
|
||||
onClick={() => setTrunks(chosen.filter((x) => x !== id))}
|
||||
>
|
||||
✕
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -38,14 +38,14 @@ export function PagingPage() {
|
||||
</div>
|
||||
{groups.isError && <Alert>{groups.error.message}</Alert>}
|
||||
{groups.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No paging groups yet. Dialing a paging group's number (like 850) makes its desk phones
|
||||
answer by themselves, so everyone near them hears you. To talk to one desk phone this way,
|
||||
dial *80 and its extension (like *80101).
|
||||
</p>
|
||||
)}
|
||||
{groups.data && groups.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{groups.data.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
@@ -53,7 +53,7 @@ export function PagingPage() {
|
||||
>
|
||||
<span className="font-mono">{g.number}</span>
|
||||
<span className="font-medium">{g.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-500">
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{g.duplex ? 'Two-way' : 'One-way'}:{' '}
|
||||
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'}
|
||||
</span>
|
||||
@@ -193,7 +193,7 @@ function PagingForm({
|
||||
{x.number} {x.name}
|
||||
</label>
|
||||
))}
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Only desk phones are paged, not the browser phone, and phones already on a call are
|
||||
skipped. Most Yealink, Grandstream, Polycom and Snom phones answer pages by themselves.
|
||||
</p>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function PhonesPage() {
|
||||
Add phone
|
||||
</Button>
|
||||
</div>
|
||||
<p className="max-w-prose text-sm text-zinc-500">
|
||||
<p className="max-w-prose text-sm text-muted">
|
||||
Add a desk phone by its MAC address (on a sticker under the phone) and it sets itself up:
|
||||
account, voicemail and pickup keys.
|
||||
</p>
|
||||
@@ -58,11 +58,11 @@ export function PhonesPage() {
|
||||
</Alert>
|
||||
)}
|
||||
{phones.isError && <Alert>{phones.error.message}</Alert>}
|
||||
{phones.data?.length === 0 && <p className="text-sm text-zinc-500">No phones yet.</p>}
|
||||
{phones.data?.length === 0 && <p className="text-sm text-muted">No phones yet.</p>}
|
||||
{phones.data && phones.data.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="overflow-x-auto rounded-xl border border-line bg-surface shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-zinc-500">
|
||||
<thead className="text-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">MAC</th>
|
||||
<th className="px-3 py-2 font-medium">Phone</th>
|
||||
@@ -71,18 +71,18 @@ export function PhonesPage() {
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
<tbody className="divide-y divide-line">
|
||||
{phones.data.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="px-3 py-2 font-mono">{fmtMac(d.mac)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{VENDOR[d.vendor].replace(' (set up by hand)', '')} {d.model}
|
||||
{d.label && <span className="text-zinc-500"> · {d.label}</span>}
|
||||
{d.label && <span className="text-muted"> · {d.label}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{d.extensionNumber} {d.extensionName}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-500">
|
||||
<td className="px-3 py-2 text-muted">
|
||||
{d.lastProvisionedAt
|
||||
? `${new Date(d.lastProvisionedAt).toLocaleString()} from ${d.lastProvisionedIp}`
|
||||
: 'Never'}
|
||||
@@ -248,7 +248,7 @@ function PhoneDrawer({
|
||||
)}
|
||||
</Field>
|
||||
{form.vendor === 'grandstream' && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Grandstream line keys differ by model, so busy-lamp keys aren't set: add them on the
|
||||
phone.
|
||||
</p>
|
||||
@@ -273,16 +273,13 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
|
||||
});
|
||||
const row = (k: string, v: string) => (
|
||||
<div className="contents">
|
||||
<dt className="text-zinc-500">{k}</dt>
|
||||
<dt className="text-muted">{k}</dt>
|
||||
<dd className="font-mono break-all">{v}</dd>
|
||||
</div>
|
||||
);
|
||||
if (phone.vendor === 'generic')
|
||||
return (
|
||||
<section
|
||||
aria-label="Manual settings"
|
||||
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
|
||||
>
|
||||
<section aria-label="Manual settings" className="rounded-md bg-subtle p-3 text-sm">
|
||||
<p className="mb-2">Enter these on the phone:</p>
|
||||
{creds.data && (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
@@ -298,10 +295,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section
|
||||
aria-label="Set up the phone"
|
||||
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
|
||||
>
|
||||
<section aria-label="Set up the phone" className="rounded-md bg-subtle p-3 text-sm">
|
||||
<p className="mb-2">
|
||||
In the phone's web page (Settings → Auto Provision), or with DHCP option 66, set:
|
||||
</p>
|
||||
@@ -310,7 +304,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
|
||||
{row('Username', phone.mac)}
|
||||
{row('Password', phone.provisioningPassword)}
|
||||
</dl>
|
||||
<p className="mt-2 text-zinc-500">
|
||||
<p className="mt-2 text-muted">
|
||||
Then reboot the phone.{' '}
|
||||
{phone.lastProvisionedAt
|
||||
? 'It last fetched its settings ' +
|
||||
@@ -327,7 +321,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
|
||||
{preview && (
|
||||
<pre
|
||||
aria-label="Config preview"
|
||||
className="mt-2 max-h-80 overflow-auto rounded bg-white p-2 text-xs dark:bg-zinc-950"
|
||||
className="mt-2 max-h-80 overflow-auto rounded bg-surface p-2 text-xs"
|
||||
>
|
||||
{preview}
|
||||
</pre>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Queues (05 §2, 03 §6.8): callers wait with music until an agent is free. The list shows live
|
||||
// counts; a queue's page shows who is waiting, the agents (pause/unpause) and today's numbers.
|
||||
import { ArrowLeft, X } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
@@ -59,13 +60,13 @@ function QueueList() {
|
||||
</div>
|
||||
{queues.isError && <Alert>{queues.error.message}</Alert>}
|
||||
{queues.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No queues yet. In a queue (like 550 for Support) callers wait with music until one of the
|
||||
agents is free.
|
||||
</p>
|
||||
)}
|
||||
{queues.data && queues.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{queues.data.map((q) => {
|
||||
const l = live[q.number];
|
||||
return (
|
||||
@@ -77,7 +78,7 @@ function QueueList() {
|
||||
<Link className="font-medium underline-offset-2 hover:underline" to={q.id}>
|
||||
{q.name}
|
||||
</Link>
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-500">
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{q.members.length} agent{q.members.length === 1 ? '' : 's'}
|
||||
{l ? ` · ${available(l)} available · ${l.waiting.length} waiting` : ''}
|
||||
</span>
|
||||
@@ -152,13 +153,14 @@ function QueueDetail({ id }: { id: string }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Link className="text-sm text-zinc-500 hover:underline" to="..">
|
||||
← Queues
|
||||
<Link className="inline-flex items-center gap-1 text-sm text-muted hover:underline" to="..">
|
||||
<ArrowLeft className="size-4" />
|
||||
Queues
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold">
|
||||
<span className="font-mono">{q.number}</span> {q.name}
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
{STRATEGY_LABEL[q.strategy]} · waits up to {q.maxWaitSeconds} s, then {q.timeoutSummary}
|
||||
</p>
|
||||
</div>
|
||||
@@ -166,18 +168,18 @@ function QueueDetail({ id }: { id: string }) {
|
||||
<div>
|
||||
<h2 className="mb-2 font-medium">Waiting</h2>
|
||||
{!live?.waiting.length ? (
|
||||
<p className="text-sm text-zinc-500">Nobody is waiting.</p>
|
||||
<p className="text-sm text-muted">Nobody is waiting.</p>
|
||||
) : (
|
||||
<ol
|
||||
aria-label="Waiting callers"
|
||||
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
|
||||
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
|
||||
>
|
||||
{live.waiting.map((w, i) => (
|
||||
<li key={w.linkedid} className="flex items-center gap-3 px-3 py-2 text-sm">
|
||||
<span className="w-5 text-zinc-500">{i + 1}.</span>
|
||||
<span className="w-5 text-muted">{i + 1}.</span>
|
||||
<span className="flex-1">
|
||||
{w.callerName ? `${w.callerName} ` : ''}
|
||||
<span className="text-zinc-500">{fmtNumber(w.callerNumber)}</span>
|
||||
<span className="text-muted">{fmtNumber(w.callerNumber)}</span>
|
||||
</span>
|
||||
<span className="font-mono tabular-nums">{since(w.since, now)}</span>
|
||||
</li>
|
||||
@@ -190,11 +192,11 @@ function QueueDetail({ id }: { id: string }) {
|
||||
<h2 className="mb-2 font-medium">Agents</h2>
|
||||
{toggle.error && <Alert>{toggle.error.message}</Alert>}
|
||||
{q.members.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">No agents yet. Add some with Edit.</p>
|
||||
<p className="text-sm text-muted">No agents yet. Add some with Edit.</p>
|
||||
) : (
|
||||
<ul
|
||||
aria-label="Agents"
|
||||
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
|
||||
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
|
||||
>
|
||||
{q.members.map((m) => {
|
||||
const e = exts.data?.find((x) => x.id === m.extensionId);
|
||||
@@ -207,7 +209,7 @@ function QueueDetail({ id }: { id: string }) {
|
||||
>
|
||||
<span className="w-12 font-mono">{e?.number}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{e?.name}</span>
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-muted">
|
||||
{paused ? 'Paused' : STATE_LABEL[l?.state ?? 'unavailable']}
|
||||
{l ? ` · ${l.callsTaken} call${l.callsTaken === 1 ? '' : 's'}` : ''}
|
||||
</span>
|
||||
@@ -222,7 +224,7 @@ function QueueDetail({ id }: { id: string }) {
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Agents can also log in and out from a phone with *45{q.number}.
|
||||
</p>
|
||||
</div>
|
||||
@@ -246,8 +248,8 @@ function QueueDetail({ id }: { id: string }) {
|
||||
],
|
||||
] as const
|
||||
).map(([k, v]) => (
|
||||
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
|
||||
<dt className="text-zinc-500">{k}</dt>
|
||||
<div key={k} className="rounded-xl border border-line bg-surface shadow-sm p-3">
|
||||
<dt className="text-muted">{k}</dt>
|
||||
<dd className="text-lg font-semibold">{v ?? '…'}</dd>
|
||||
</div>
|
||||
))}
|
||||
@@ -418,7 +420,7 @@ function QueueForm({
|
||||
{members.map((m) => (
|
||||
<li key={m.extensionId} className="flex items-center gap-2 text-sm">
|
||||
<span className="flex-1">{label(m.extensionId)}</span>
|
||||
<label className="flex items-center gap-1 text-zinc-500">
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
Priority
|
||||
<select
|
||||
aria-label={`Priority of ${label(m.extensionId)}`}
|
||||
@@ -449,7 +451,7 @@ function QueueForm({
|
||||
setMembers((ms) => ms.filter((x) => x.extensionId !== m.extensionId))
|
||||
}
|
||||
>
|
||||
✕
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
@@ -472,7 +474,7 @@ function QueueForm({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Priority 0 gets calls first; higher numbers only when the lower ones are all busy.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
@@ -15,19 +15,19 @@ const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).
|
||||
function Transcript({ id }: { id: string }) {
|
||||
const t = useQuery({ queryKey: ['transcript', id], queryFn: () => recordingsApi.transcript(id) });
|
||||
if (t.error) return <Alert>{t.error.message}</Alert>;
|
||||
if (!t.data) return <p className="text-zinc-500">Loading…</p>;
|
||||
if (!t.data) return <p className="text-muted">Loading…</p>;
|
||||
return (
|
||||
<ol aria-label="Transcript" className="mt-1 flex flex-col gap-1">
|
||||
{t.data.lines.map((l, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="w-10 shrink-0 text-zinc-500 tabular-nums">{mmss(l.at)}</span>
|
||||
<span className="w-10 shrink-0 text-muted tabular-nums">{mmss(l.at)}</span>
|
||||
<span className="w-20 shrink-0 font-medium">
|
||||
{l.side === 'caller' ? 'Caller' : 'Other side'}
|
||||
</span>
|
||||
<span>{l.text}</span>
|
||||
</li>
|
||||
))}
|
||||
{!t.data.lines.length && <li className="text-zinc-500">No words.</li>}
|
||||
{!t.data.lines.length && <li className="text-muted">No words.</li>}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function RecordingsPage() {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h1 className="text-xl font-semibold">Recordings</h1>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Calls are recorded when a user's record mode or a phone number says so. How long they're
|
||||
kept is under Settings → Recording. With call transcripts on (Settings → AI), each call is
|
||||
written out a minute or two after it ends.
|
||||
@@ -90,21 +90,21 @@ export function RecordingsPage() {
|
||||
</form>
|
||||
{list.isError && <Alert>{list.error.message}</Alert>}
|
||||
{list.data && items.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">{q ? 'No call mentions that.' : 'No recordings.'}</p>
|
||||
<p className="text-sm text-muted">{q ? 'No call mentions that.' : 'No recordings.'}</p>
|
||||
)}
|
||||
<ul
|
||||
aria-label="Recordings"
|
||||
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
|
||||
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
|
||||
>
|
||||
{items.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2 text-sm">
|
||||
<span className="w-36 text-zinc-500">{new Date(r.startedAt).toLocaleString()}</span>
|
||||
<span className="w-36 text-muted">{new Date(r.startedAt).toLocaleString()}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{r.call
|
||||
? `${r.call.fromName ?? fmtNumber(r.call.fromNumber)} → ${fmtNumber(r.call.toNumber)}`
|
||||
: 'Call not found'}
|
||||
{r.durationMs !== null && (
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-muted">
|
||||
{' '}
|
||||
· {fmtDuration(Math.round(r.durationMs / 1000))}
|
||||
</span>
|
||||
@@ -119,7 +119,7 @@ export function RecordingsPage() {
|
||||
/>
|
||||
<a
|
||||
href={recordingsApi.audioUrl(r.id, true)}
|
||||
className="rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
className="rounded-md px-3 py-2 text-sm font-medium hover:bg-subtle"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
@@ -127,15 +127,12 @@ export function RecordingsPage() {
|
||||
Delete
|
||||
</Button>
|
||||
{r.summary && (
|
||||
<p
|
||||
aria-label="Summary"
|
||||
className="w-full whitespace-pre-line text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
<p aria-label="Summary" className="w-full whitespace-pre-line text-fg">
|
||||
{r.summary}
|
||||
</p>
|
||||
)}
|
||||
{r.aiError && (
|
||||
<p className="w-full text-xs text-zinc-500">
|
||||
<p className="w-full text-xs text-muted">
|
||||
{r.hasTranscript ? '' : 'No transcript: '}
|
||||
{r.aiError}
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
|
||||
// day, missed per day, busiest hours, queue table.
|
||||
// day, missed per day, busiest hours, queue table. `mine` (05 §4): the calls made to me, without
|
||||
// the company's outside minutes, balance and queues.
|
||||
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
|
||||
import { reportsApi } from '../../api/client.ts';
|
||||
import { meApi, reportsApi } from '../../api/client.ts';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
import { fmtDuration } from './CallsPage.tsx';
|
||||
|
||||
@@ -45,14 +46,14 @@ function Bars({
|
||||
{label}
|
||||
{segments.length > 1 &&
|
||||
segments.map(([k, text, color]) => (
|
||||
<span key={k} className="flex items-center gap-1 text-xs font-normal text-zinc-500">
|
||||
<span key={k} className="flex items-center gap-1 text-xs font-normal text-muted">
|
||||
<span className={`inline-block size-2.5 rounded-sm ${color}`} />
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs font-normal text-zinc-500">most in a day: {max}</span>
|
||||
<span className="text-xs font-normal text-muted">most in a day: {max}</span>
|
||||
</figcaption>
|
||||
<div className="flex h-40 items-end gap-px border-b border-zinc-300 dark:border-zinc-700">
|
||||
<div className="flex h-40 items-end gap-px border-b border-line-strong">
|
||||
{days.map((d) => (
|
||||
<div
|
||||
key={d.date}
|
||||
@@ -67,7 +68,7 @@ function Bars({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-zinc-500">
|
||||
<div className="flex justify-between text-xs text-muted">
|
||||
<span>{shortDate(days[0]!.date)}</span>
|
||||
<span>{shortDate(days.at(-1)!.date)}</span>
|
||||
</div>
|
||||
@@ -84,7 +85,7 @@ function Heatmap({ hours }: { hours: number[][] }) {
|
||||
<tr>
|
||||
<th />
|
||||
{hours[0]!.map((_, h) => (
|
||||
<th key={h} scope="col" className="w-6 font-normal text-zinc-500">
|
||||
<th key={h} scope="col" className="w-6 font-normal text-muted">
|
||||
{h % 3 === 0 ? h : <span className="sr-only">{h}</span>}
|
||||
</th>
|
||||
))}
|
||||
@@ -93,14 +94,14 @@ function Heatmap({ hours }: { hours: number[][] }) {
|
||||
<tbody>
|
||||
{hours.map((row, d) => (
|
||||
<tr key={d}>
|
||||
<th scope="row" className="pr-1 text-left font-normal text-zinc-500">
|
||||
<th scope="row" className="pr-1 text-left font-normal text-muted">
|
||||
{WEEKDAYS[d]}
|
||||
</th>
|
||||
{row.map((n, h) => (
|
||||
<td
|
||||
key={h}
|
||||
title={`${WEEKDAYS[d]} ${h}:00: ${n} calls`}
|
||||
className="h-6 w-6 rounded-sm bg-zinc-100 dark:bg-zinc-800"
|
||||
className="h-6 w-6 rounded-sm bg-subtle"
|
||||
style={
|
||||
n ? { backgroundColor: `rgb(14 165 233 / ${0.15 + (0.85 * n) / max})` } : {}
|
||||
}
|
||||
@@ -116,18 +117,23 @@ function Heatmap({ hours }: { hours: number[][] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsPage() {
|
||||
export function ReportsPage({ mine = false }: { mine?: boolean }) {
|
||||
const [draft, setDraft] = useState<ReportRange>({});
|
||||
const [range, setRange] = useState<ReportRange>({});
|
||||
const summary = useQuery({
|
||||
queryKey: ['reports', 'summary', range],
|
||||
queryFn: () => reportsApi.summary(range),
|
||||
queryKey: [mine ? 'me' : 'reports', 'summary', range],
|
||||
queryFn: () => (mine ? meApi.report(range) : reportsApi.summary(range)),
|
||||
});
|
||||
const queues = useQuery({
|
||||
queryKey: ['reports', 'queues', range],
|
||||
queryFn: () => reportsApi.queues(range),
|
||||
enabled: !mine,
|
||||
});
|
||||
const balance = useQuery({
|
||||
queryKey: ['reports', 'balance'],
|
||||
queryFn: reportsApi.balance,
|
||||
enabled: !mine,
|
||||
});
|
||||
const balance = useQuery({ queryKey: ['reports', 'balance'], queryFn: reportsApi.balance });
|
||||
const s = summary.data;
|
||||
const date = (k: 'from' | 'to', label: string) => (
|
||||
<Field label={label}>
|
||||
@@ -145,7 +151,8 @@ export function ReportsPage() {
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<h1 className="text-xl font-semibold">Reports</h1>
|
||||
<h1 className="text-xl font-semibold">{mine ? 'My reports' : 'Reports'}</h1>
|
||||
{mine && <p className="text-sm text-muted">Calls that rang you, not the ones you made.</p>}
|
||||
<form
|
||||
aria-label="Dates"
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
@@ -160,7 +167,7 @@ export function ReportsPage() {
|
||||
Show
|
||||
</Button>
|
||||
{s && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
{shortDate(s.from)} – {shortDate(s.to)}
|
||||
{!range.from && !range.to && ' (last 30 days)'}
|
||||
</p>
|
||||
@@ -174,19 +181,33 @@ export function ReportsPage() {
|
||||
{(
|
||||
[
|
||||
['Calls', s.totals.calls, null],
|
||||
['Answered', s.totals.answered, null],
|
||||
[
|
||||
mine ? 'You answered' : 'Answered',
|
||||
s.totals.answered,
|
||||
mine ? 'not the ones a coworker picked up' : null,
|
||||
],
|
||||
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
|
||||
[
|
||||
'Average talk time',
|
||||
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
|
||||
null,
|
||||
],
|
||||
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
|
||||
[
|
||||
'Outside minutes',
|
||||
fmtMinutes(s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound),
|
||||
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
|
||||
'Talk time',
|
||||
fmtMinutes(s.totals.talkSeconds),
|
||||
mine ? null : 'people talking, inside calls too',
|
||||
],
|
||||
...(mine
|
||||
? []
|
||||
: ([
|
||||
[
|
||||
'Outside minutes',
|
||||
fmtMinutes(
|
||||
s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound,
|
||||
),
|
||||
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
|
||||
],
|
||||
] as const)),
|
||||
...(balance.data ?? []).map(
|
||||
(b) =>
|
||||
[
|
||||
@@ -197,19 +218,25 @@ export function ReportsPage() {
|
||||
),
|
||||
] as const
|
||||
).map(([k, v, note]) => (
|
||||
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
|
||||
<dt className="text-zinc-500">{k}</dt>
|
||||
<div key={k} className="rounded-xl border border-line bg-surface shadow-sm p-3">
|
||||
<dt className="text-muted">{k}</dt>
|
||||
<dd className="text-lg font-semibold tabular-nums">{v}</dd>
|
||||
{note && <dd className="text-xs text-zinc-500">{note}</dd>}
|
||||
{note && <dd className="text-xs text-muted">{note}</dd>}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Bars label="Calls per day" days={s.days} segments={SERIES} />
|
||||
<Bars
|
||||
label="Outside minutes per day"
|
||||
label="Calls per day"
|
||||
days={s.days}
|
||||
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
|
||||
segments={mine ? SERIES.filter(([k]) => k !== 'outbound') : SERIES}
|
||||
/>
|
||||
{!mine && (
|
||||
<Bars
|
||||
label="Outside minutes per day"
|
||||
days={s.days}
|
||||
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
|
||||
/>
|
||||
)}
|
||||
<Bars
|
||||
label="Missed per day"
|
||||
days={s.days}
|
||||
@@ -222,53 +249,55 @@ export function ReportsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-medium">Queues</h2>
|
||||
{queues.error && <Alert>{queues.error.message}</Alert>}
|
||||
{queues.data && !queues.data.length && <p className="text-sm text-zinc-500">No queues.</p>}
|
||||
{!!queues.data?.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table aria-label="Queue report" className="w-full text-left text-sm">
|
||||
<thead className="text-zinc-500">
|
||||
<tr>
|
||||
{[
|
||||
'Queue',
|
||||
'Calls',
|
||||
'Answered',
|
||||
'Hung up waiting',
|
||||
'Timed out',
|
||||
'Average wait',
|
||||
'Answered in time',
|
||||
].map((h) => (
|
||||
<th key={h} scope="col" className="px-2 py-1 font-normal">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{queues.data.map((q) => (
|
||||
<tr key={q.id} className="tabular-nums">
|
||||
<td className="px-2 py-1">
|
||||
<span className="font-mono">{q.number}</span> {q.name}
|
||||
</td>
|
||||
<td className="px-2 py-1">{q.offered}</td>
|
||||
<td className="px-2 py-1">{q.answered}</td>
|
||||
<td className="px-2 py-1">{q.abandoned}</td>
|
||||
<td className="px-2 py-1">{q.timedOut}</td>
|
||||
<td className="px-2 py-1">
|
||||
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
|
||||
</td>
|
||||
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
|
||||
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
|
||||
</td>
|
||||
{!mine && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-medium">Queues</h2>
|
||||
{queues.error && <Alert>{queues.error.message}</Alert>}
|
||||
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
|
||||
{!!queues.data?.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table aria-label="Queue report" className="w-full text-left text-sm">
|
||||
<thead className="text-muted">
|
||||
<tr>
|
||||
{[
|
||||
'Queue',
|
||||
'Calls',
|
||||
'Answered',
|
||||
'Hung up waiting',
|
||||
'Timed out',
|
||||
'Average wait',
|
||||
'Answered in time',
|
||||
].map((h) => (
|
||||
<th key={h} scope="col" className="px-2 py-1 font-normal">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{queues.data.map((q) => (
|
||||
<tr key={q.id} className="tabular-nums">
|
||||
<td className="px-2 py-1">
|
||||
<span className="font-mono">{q.number}</span> {q.name}
|
||||
</td>
|
||||
<td className="px-2 py-1">{q.offered}</td>
|
||||
<td className="px-2 py-1">{q.answered}</td>
|
||||
<td className="px-2 py-1">{q.abandoned}</td>
|
||||
<td className="px-2 py-1">{q.timedOut}</td>
|
||||
<td className="px-2 py-1">
|
||||
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
|
||||
</td>
|
||||
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
|
||||
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Ring groups (05 §2, 03 §6.7): one number that rings several people, everyone at once or one at
|
||||
// a time, then goes somewhere else when nobody answers.
|
||||
import { ArrowDown, ArrowUp, X } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
@@ -40,13 +41,13 @@ export function RingGroupsPage() {
|
||||
</div>
|
||||
{groups.isError && <Alert>{groups.error.message}</Alert>}
|
||||
{groups.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No ring groups yet. A ring group is one number (like 500 for Sales) that rings several
|
||||
people.
|
||||
</p>
|
||||
)}
|
||||
{groups.data && groups.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{groups.data.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
@@ -54,7 +55,7 @@ export function RingGroupsPage() {
|
||||
>
|
||||
<span className="font-mono">{g.number}</span>
|
||||
<span className="font-medium">{g.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-500">
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{g.strategy === 'ringall' ? 'Everyone at once' : 'One at a time'}:{' '}
|
||||
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'} · then {g.noAnswerSummary}
|
||||
</span>
|
||||
@@ -238,7 +239,7 @@ function RingGroupForm({
|
||||
<ol className="flex flex-col gap-1">
|
||||
{members.map((m, i) => (
|
||||
<li key={m} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-5 text-zinc-500">{i + 1}.</span>
|
||||
<span className="w-5 text-muted">{i + 1}.</span>
|
||||
<span className="flex-1">{label(m)}</span>
|
||||
<Button
|
||||
className="px-2 py-1"
|
||||
@@ -246,7 +247,7 @@ function RingGroupForm({
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
↑
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="px-2 py-1"
|
||||
@@ -254,7 +255,7 @@ function RingGroupForm({
|
||||
disabled={i === members.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
↓
|
||||
<ArrowDown className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -262,7 +263,7 @@ function RingGroupForm({
|
||||
aria-label={`Remove ${label(m)}`}
|
||||
onClick={() => setMembers((ms) => ms.filter((x) => x !== m))}
|
||||
>
|
||||
✕
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
@@ -282,7 +283,7 @@ function RingGroupForm({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Members' call forwarding and voicemail don't apply here; Do not disturb does.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
@@ -52,16 +52,13 @@ export function SettingsPage() {
|
||||
return (
|
||||
<section className="flex max-w-2xl flex-col gap-4">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<nav
|
||||
aria-label="Settings"
|
||||
className="flex flex-wrap gap-1 border-b border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<nav aria-label="Settings" className="flex flex-wrap gap-1 border-b border-line">
|
||||
{TABS.map((t) => (
|
||||
<NavLink
|
||||
key={t.id}
|
||||
to={`/admin/settings/${t.id}`}
|
||||
className={({ isActive }) =>
|
||||
`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium ${isActive || (t.id === 'email' && tab === 'email') ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`
|
||||
`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium ${isActive || (t.id === 'email' && tab === 'email') ? 'border-primary' : 'border-transparent hover:border-line-strong'}`
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
@@ -128,7 +125,7 @@ export function EmailTab({ s }: { s: SettingsDto }) {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
The mail server used for emergency alerts and voicemail emails. Leave the server empty to
|
||||
turn email off.
|
||||
</p>
|
||||
@@ -209,7 +206,7 @@ export function EmailTab({ s }: { s: SettingsDto }) {
|
||||
</div>
|
||||
|
||||
{s.smtp && (
|
||||
<div className="flex flex-col gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="flex flex-col gap-2 border-t border-line pt-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<Field label="Send a test email to">
|
||||
{(a) => (
|
||||
@@ -317,7 +314,7 @@ export function EmergencyTab({ s }: { s: SettingsDto }) {
|
||||
</Field>
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="mb-1 text-sm font-medium">Ring these phones</legend>
|
||||
{exts.data?.length === 0 && <p className="text-sm text-zinc-500">No extensions yet.</p>}
|
||||
{exts.data?.length === 0 && <p className="text-sm text-muted">No extensions yet.</p>}
|
||||
{exts.data?.map((x) => (
|
||||
<label key={x.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
@@ -340,7 +337,7 @@ export function EmergencyTab({ s }: { s: SettingsDto }) {
|
||||
{test.isPending ? 'Testing…' : 'Test notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
The test sends the email and rings the phones above with "TEST" in front. It never
|
||||
dials 911. Never test by calling 911.
|
||||
</p>
|
||||
@@ -398,7 +395,7 @@ function RecordingTab({ s }: { s: SettingsDto }) {
|
||||
onChange={setNotice}
|
||||
noneLabel="No notice"
|
||||
/>
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Played to callers of phone numbers set to record (Phone numbers → Record every call).
|
||||
</p>
|
||||
<Field label="Keep recordings for (days)" hint="0 keeps them forever">
|
||||
@@ -427,7 +424,7 @@ function RecordingTab({ s }: { s: SettingsDto }) {
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
@@ -462,7 +459,7 @@ function NumberingTab({ s }: { s: SettingsDto }) {
|
||||
save.mutate(Object.fromEntries(RANGES.map(([k]) => [k, v[k].map(Number)])));
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-sm text-muted">
|
||||
Where <strong>Next free</strong> looks when you add a user, ring group, queue, digital
|
||||
receptionist, conference room or paging group. You can always type any other free number of
|
||||
1 to 6 digits instead, for example <strong>1</strong> for the first user: SelfPhoneNG checks
|
||||
@@ -471,8 +468,8 @@ function NumberingTab({ s }: { s: SettingsDto }) {
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-2 text-sm">
|
||||
<span />
|
||||
<span className="text-zinc-500">From</span>
|
||||
<span className="text-zinc-500">To</span>
|
||||
<span className="text-muted">From</span>
|
||||
<span className="text-muted">To</span>
|
||||
{RANGES.map(([k, label]) => (
|
||||
<Fragment key={k}>
|
||||
<span>{label}</span>
|
||||
@@ -495,7 +492,7 @@ function NumberingTab({ s }: { s: SettingsDto }) {
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
@@ -508,6 +505,11 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
const [watch, setWatch] = useState(s['network.publicIpWatch']);
|
||||
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
|
||||
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
|
||||
const qc = useQueryClient();
|
||||
const makeKey = useMutation({
|
||||
mutationFn: systemApi.probeKey,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
@@ -539,7 +541,7 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
<input type="checkbox" checked={watch} onChange={(e) => setWatch(e.target.checked)} />
|
||||
Re-check the public IP every 5 minutes (dynamic IP)
|
||||
</label>
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
When it changes, the new IP is saved and admins get an email asking for a telephony restart.
|
||||
Asks api.ipify.org (or ifconfig.me) for the address.
|
||||
</p>
|
||||
@@ -558,13 +560,61 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{s['network.probeUrl'] && (
|
||||
<section
|
||||
aria-labelledby="probe-key-h"
|
||||
className="flex flex-col gap-2 rounded-lg border border-line p-3 text-sm"
|
||||
>
|
||||
<h3 id="probe-key-h" className="font-medium">
|
||||
Probe key
|
||||
</h3>
|
||||
{s['network.hasProbeKey'] ? (
|
||||
<p className="text-muted">Set: the probe only answers this server.</p>
|
||||
) : (
|
||||
<p className="text-amber-800 dark:text-amber-200">
|
||||
Not set: anyone who finds your probe can use it. Strongly recommended: generate a key
|
||||
and set it on the probe.
|
||||
</p>
|
||||
)}
|
||||
{makeKey.data ? (
|
||||
<>
|
||||
<p>
|
||||
Set this on the probe (its compose file or service), then restart it. It’s
|
||||
shown only now.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="min-w-0 flex-1 rounded bg-subtle p-2 font-mono text-xs break-all select-all">
|
||||
PROBE_KEY={makeKey.data}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard.writeText(makeKey.data)}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<Button type="button" onClick={() => makeKey.mutate()} disabled={makeKey.isPending}>
|
||||
{s['network.hasProbeKey'] ? 'Generate a new key' : 'Generate key'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted">
|
||||
A new probe address drops the key: generate one for the new probe.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{(save.error ?? detect.error) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
|
||||
{(save.error ?? detect.error ?? makeKey.error) && (
|
||||
<Alert>{(save.error ?? detect.error ?? makeKey.error)!.message}</Alert>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -638,7 +688,7 @@ function AiTab({ s }: { s: SettingsDto }) {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-sm text-muted">
|
||||
AI runs on this server or your local network only: an address must be localhost or a local
|
||||
IP (127.x, 10.x, 172.16–31.x, 192.168.x). Calls and text never go anywhere else. Leave an
|
||||
address empty to turn that part off.
|
||||
@@ -648,7 +698,7 @@ function AiTab({ s }: { s: SettingsDto }) {
|
||||
return (
|
||||
<fieldset key={kind} className="flex flex-col gap-2">
|
||||
<legend className="mb-1 font-medium">{title}</legend>
|
||||
<p className="text-xs text-zinc-500">{hint}</p>
|
||||
<p className="text-xs text-muted">{hint}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
aria-label={`${title} address`}
|
||||
@@ -700,7 +750,7 @@ function AiTab({ s }: { s: SettingsDto }) {
|
||||
<input type="checkbox" checked={vmText} onChange={(e) => setVmText(e.target.checked)} />
|
||||
Voicemail transcripts (speech to text)
|
||||
</label>
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
New messages are written out before the voicemail email goes, and the text is in the email
|
||||
and on the voicemail page.
|
||||
</p>
|
||||
@@ -708,7 +758,7 @@ function AiTab({ s }: { s: SettingsDto }) {
|
||||
<input type="checkbox" checked={callText} onChange={(e) => setCallText(e.target.checked)} />
|
||||
Call transcripts (speech to text)
|
||||
</label>
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Recorded calls are written out a minute or two after they end, the caller and the other side
|
||||
apart, and can be searched on the Recordings page. Only calls recorded while this is on.
|
||||
</p>
|
||||
@@ -725,7 +775,7 @@ function AiTab({ s }: { s: SettingsDto }) {
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
@@ -746,6 +796,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
const [provOn, setProvOn] = useState(s['provisioning.enabled']);
|
||||
const [perExt, setPerExt] = useState(String(s['calls.maxPerExtension']));
|
||||
const [total, setTotal] = useState(String(s['calls.maxTotal']));
|
||||
const [riskyCalls, setRiskyCalls] = useState(s['alerts.riskyCalls']);
|
||||
const [providerCalls, setProviderCalls] = useState(s['alerts.providerCalls']);
|
||||
const [balanceDrop, setBalanceDrop] = useState(String(s['alerts.balanceDropPerHour']));
|
||||
const box = (checked: boolean, set: (v: boolean) => void, label: string) => (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => set(e.target.checked)} />
|
||||
@@ -764,6 +817,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
'provisioning.enabled': provOn,
|
||||
'calls.maxPerExtension': Number(perExt),
|
||||
'calls.maxTotal': Number(total),
|
||||
'alerts.riskyCalls': riskyCalls,
|
||||
'alerts.providerCalls': providerCalls,
|
||||
'alerts.balanceDropPerHour': Number(balanceDrop),
|
||||
'sip.udpPort': Number(sipPort),
|
||||
'sip.tcpEnabled': tcpOn,
|
||||
'sip.tlsEnabled': tlsOn,
|
||||
@@ -773,7 +829,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
>
|
||||
<h2 className="font-medium">Desk phones</h2>
|
||||
{box(restrict, setRestrict, 'Only accept desk phones from the networks below')}
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Local networks: {s['network.localNets'].join(', ')}. Phones anywhere else (at home, on a
|
||||
mobile network) can't sign in while this is on. Browser phones still work from anywhere:
|
||||
they come through this website.
|
||||
@@ -792,7 +848,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
)}
|
||||
</Field>
|
||||
<h2 className="font-medium">Outside calls at once</h2>
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Limits the damage if someone steals a phone's password. Forwarded calls count for the person
|
||||
who set the forward. 911 is never limited. Callers over the limit hear "all circuits are
|
||||
busy". 0 = no limit.
|
||||
@@ -823,6 +879,36 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<h2 className="font-medium">Fraud alerts</h2>
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Emails to every admin. The last two need the voip.ms API login on the trunk (Trunks).
|
||||
</p>
|
||||
{box(riskyCalls, setRiskyCalls, 'Calls to international, premium and Caribbean numbers')}
|
||||
{box(
|
||||
providerCalls,
|
||||
setProviderCalls,
|
||||
"Calls voip.ms billed that this phone system didn't make",
|
||||
)}
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Checked every 15 minutes. That's what a stolen trunk password looks like: the calls
|
||||
never pass through this server, so no limit here can stop them.
|
||||
</p>
|
||||
<Field
|
||||
label="voip.ms balance drop in an hour"
|
||||
hint="In your account's currency. 0 = no email."
|
||||
>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={`${inputClass} max-w-32`}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={balanceDrop}
|
||||
onChange={(e) => setBalanceDrop(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<h2 className="font-medium">Phone setup files</h2>
|
||||
{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')}
|
||||
{box(lanOnly, setLanOnly, 'Only to phones on the local networks')}
|
||||
@@ -842,7 +928,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
)}
|
||||
</Field>
|
||||
{box(tcpOn, setTcpOn, 'Accept SIP over TCP')}
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
For phones and trunks set to TCP. Phones set up by this server use UDP.
|
||||
</p>
|
||||
<h2 className="font-medium">Encrypted SIP (TLS)</h2>
|
||||
@@ -852,7 +938,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
: (cert.data?.error ?? '…')}
|
||||
</p>
|
||||
{box(tlsOn, setTlsOn, 'Accept SIP over TLS')}
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Needed for an encrypted voip.ms trunk. Turning it on or changing the port needs a telephony
|
||||
restart. After renewing the certificate, run scripts/tls-cert.sh.
|
||||
</p>
|
||||
@@ -871,7 +957,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
@@ -938,7 +1024,7 @@ function BackupsTab({ s }: { s: SettingsDto }) {
|
||||
)}
|
||||
</Field>
|
||||
{box(recs, setRecs, 'Include call recordings (they can be large)')}
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Backups hold the database, voicemail, sounds and music on hold, in data/backups on the
|
||||
server. Copy them somewhere else too. Passwords inside are encrypted with APP_SECRET from
|
||||
.env: keep a copy of .env, or a backup can't be restored. To restore, run
|
||||
@@ -948,7 +1034,7 @@ function BackupsTab({ s }: { s: SettingsDto }) {
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
@@ -960,16 +1046,16 @@ function BackupsTab({ s }: { s: SettingsDto }) {
|
||||
</Button>
|
||||
</div>
|
||||
{now.error && <Alert>{now.error.message}</Alert>}
|
||||
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No backups yet.</p>}
|
||||
<ul aria-label="Backups" className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{list.data?.length === 0 && <p className="text-sm text-muted">No backups yet.</p>}
|
||||
<ul aria-label="Backups" className="divide-y divide-line">
|
||||
{list.data?.map((b) => (
|
||||
<li key={b.name} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<span>
|
||||
{new Date(b.createdAt).toLocaleString()}{' '}
|
||||
<span className="text-zinc-500">({(b.size / 1048576).toFixed(1)} MB)</span>
|
||||
<span className="text-muted">({(b.size / 1048576).toFixed(1)} MB)</span>
|
||||
</span>
|
||||
<a
|
||||
className="text-sky-700 hover:underline dark:text-sky-400"
|
||||
className="text-link hover:underline"
|
||||
href={`/api/system/backups/${b.name}/download`}
|
||||
download
|
||||
>
|
||||
@@ -1054,12 +1140,12 @@ function WebhooksTab({ s }: { s: SettingsDto }) {
|
||||
/>
|
||||
Allow addresses on private networks (10.x, 192.168.x, localhost …)
|
||||
</label>
|
||||
<p className="-mt-4 text-xs text-zinc-500">
|
||||
<p className="-mt-4 text-xs text-muted">
|
||||
Off by default, so a webhook can't reach services inside your network. Turn it on for a
|
||||
receiver in your own network (home lab).
|
||||
</p>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Each POST has the headers X-SelfPhoneNG-Event, X-SelfPhoneNG-Timestamp and
|
||||
X-SelfPhoneNG-Signature: sha256= the hex HMAC-SHA256 of timestamp + "." + body
|
||||
with the webhook's secret. A failed POST is tried again after 1 and 5 minutes, half an
|
||||
@@ -1084,12 +1170,12 @@ function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
|
||||
});
|
||||
const remove = useMutation({ mutationFn: () => webhooksApi.remove(w.id), onSuccess: onChange });
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<li className="flex flex-col gap-2 rounded-md border border-line p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium break-all">{w.url}</span>
|
||||
<span className="text-zinc-500">{w.enabled ? 'On' : 'Off'}</span>
|
||||
<span className="text-muted">{w.enabled ? 'On' : 'Off'}</span>
|
||||
</div>
|
||||
<p className="text-zinc-500">{w.events.join(', ')}</p>
|
||||
<p className="text-muted">{w.events.join(', ')}</p>
|
||||
<p>
|
||||
Secret: <code className="break-all">{w.secret}</code>
|
||||
</p>
|
||||
@@ -1113,7 +1199,7 @@ function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
|
||||
</p>
|
||||
)}
|
||||
{showLog && (
|
||||
<ul aria-label="Deliveries" className="text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<ul aria-label="Deliveries" className="text-xs text-muted">
|
||||
{log.data?.length === 0 && <li>Nothing sent yet.</li>}
|
||||
{log.data?.map((d) => (
|
||||
<li key={d.id}>
|
||||
@@ -1195,19 +1281,19 @@ function TokensTab() {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Send it as the header Authorization: Bearer <token>. A token can do everything its
|
||||
admin can, except manage tokens. It stops working when revoked or when the admin is
|
||||
disabled.
|
||||
</p>
|
||||
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No tokens.</p>}
|
||||
<ul aria-label="API tokens" className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{list.data?.length === 0 && <p className="text-sm text-muted">No tokens.</p>}
|
||||
<ul aria-label="API tokens" className="divide-y divide-line">
|
||||
{list.data?.map((t) => (
|
||||
<li key={t.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<span>
|
||||
<span className="font-medium">{t.name}</span>{' '}
|
||||
<code className="text-zinc-500">{t.tokenPrefix}…</code>
|
||||
<span className="block text-xs text-zinc-500">
|
||||
<code className="text-muted">{t.tokenPrefix}…</code>
|
||||
<span className="block text-xs text-muted">
|
||||
By {t.createdBy}, last used{' '}
|
||||
{t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'}
|
||||
</span>
|
||||
@@ -1353,7 +1439,7 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Each person then connects their own Contrack account in My settings, Contacts. If the AI
|
||||
inside Contrack uses a cloud provider, what is stored there reaches that provider: turn
|
||||
it off in Contrack, or point it at your local AI.
|
||||
@@ -1409,7 +1495,7 @@ function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) {
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<p className="text-zinc-500">
|
||||
<p className="text-muted">
|
||||
{st.ftcCursor
|
||||
? `Downloading: at the complaints of ${st.ftcCursor.day}.`
|
||||
: st.ftcLastSyncAt
|
||||
@@ -1511,7 +1597,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
|
||||
else submit();
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
When an outside caller from the US isn’t in anyone’s contacts, look the number
|
||||
up and show what’s known about them while it rings. Information only: calls are never
|
||||
blocked or sent elsewhere. IPQualityScore answers first when it’s set up; the FTC list
|
||||
@@ -1588,7 +1674,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">Desk phones</legend>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Shown in front of the caller’s name when the answer comes in before the phone rings.
|
||||
Letters, digits, spaces and . ' & @ - only.
|
||||
</p>
|
||||
@@ -1742,16 +1828,16 @@ function SignInTab({ s }: { s: SettingsDto }) {
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">OpenID Connect</legend>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Keycloak, Authentik, Authelia, Zitadel, Entra ID, Google and others. At the provider, make
|
||||
a confidential client using the standard (authorization code) flow, and add these:
|
||||
</p>
|
||||
<dl className="flex flex-col gap-2 text-sm">
|
||||
{uris.map(([label, uri]) => (
|
||||
<div key={label} className="flex flex-wrap items-center gap-2">
|
||||
<dt className="w-48 text-zinc-500">{label}</dt>
|
||||
<dt className="w-48 text-muted">{label}</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<code className="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-800">{uri}</code>
|
||||
<code className="rounded bg-subtle px-2 py-1">{uri}</code>
|
||||
<Button onClick={() => void navigator.clipboard?.writeText(uri)}>Copy</Button>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -1834,7 +1920,7 @@ function SignInTab({ s }: { s: SettingsDto }) {
|
||||
)}
|
||||
{test.error && <Alert>{test.error.message}</Alert>}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Then connect your own account in My account, and sign in from a private window.
|
||||
</p>
|
||||
</fieldset>
|
||||
@@ -1910,12 +1996,12 @@ function DirectoryForm({ s }: { s: SettingsDto }) {
|
||||
>
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">Directory (LDAP)</legend>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
When a password isn't the one set here, SelfPhoneNG asks the directory. People sign
|
||||
in with their directory username or email; the first time, they're matched to their
|
||||
user here by email. Nobody gets a user just for being in the directory.
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
Every hour, someone no longer in the directory (or no longer matched by the user filter)
|
||||
is disabled here, password and OpenID sign-in included. It never enables anyone again: do
|
||||
that on Users.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements.
|
||||
// Upload by picking or dropping a file; play, rename, delete. Music-on-hold classes and their
|
||||
// files (03 §8) and announcements live here too.
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
@@ -68,7 +69,7 @@ export function SoundsPage() {
|
||||
<h1 className="text-xl font-semibold">Sounds & music</h1>
|
||||
<form
|
||||
aria-label="Upload a sound"
|
||||
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-sky-500 bg-sky-50 dark:bg-sky-950' : 'border-zinc-300 dark:border-zinc-700'}`}
|
||||
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-primary bg-primary-soft' : 'border-line-strong'}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
@@ -122,19 +123,19 @@ export function SoundsPage() {
|
||||
{add.isPending ? 'Converting…' : 'Upload'}
|
||||
</Button>
|
||||
</div>
|
||||
{file && <p className="text-sm text-zinc-500">Selected: {file.name}</p>}
|
||||
{file && <p className="text-sm text-muted">Selected: {file.name}</p>}
|
||||
{tooBig && <Alert>That file is larger than 20 MB.</Alert>}
|
||||
{add.error && <Alert>{add.error.message}</Alert>}
|
||||
</form>
|
||||
|
||||
{sounds.isError && <Alert>{sounds.error.message}</Alert>}
|
||||
{sounds.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
No sounds yet. Upload greetings for your digital receptionist and announcements.
|
||||
</p>
|
||||
)}
|
||||
{sounds.data && sounds.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{sounds.data.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
@@ -142,7 +143,7 @@ export function SoundsPage() {
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium">{s.name}</span>{' '}
|
||||
<span className="text-zinc-500">{secs(s.durationMs)}</span>
|
||||
<span className="text-muted">{secs(s.durationMs)}</span>
|
||||
</span>
|
||||
<audio
|
||||
controls
|
||||
@@ -210,7 +211,7 @@ function MusicOnHold() {
|
||||
<h2 id="moh-h" className="text-lg font-semibold">
|
||||
Music on hold
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
What callers hear on hold or waiting in a queue. Default music is built in. Make your own
|
||||
set here, then choose it for a user (Phone tab) or a queue.
|
||||
</p>
|
||||
@@ -297,7 +298,7 @@ function MohClassCard({
|
||||
<div
|
||||
aria-label={`Music on hold ${cls.name}`}
|
||||
role="group"
|
||||
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
|
||||
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1 font-medium">{cls.name}</span>
|
||||
@@ -306,7 +307,7 @@ function MohClassCard({
|
||||
</Button>
|
||||
</div>
|
||||
{cls.files.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No music yet: callers hear the default music.</p>
|
||||
<p className="text-sm text-muted">No music yet: callers hear the default music.</p>
|
||||
)}
|
||||
{cls.files.length > 1 && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
@@ -317,7 +318,7 @@ function MohClassCard({
|
||||
onChange={(e) => update.mutate({ shuffle: e.target.checked })}
|
||||
/>
|
||||
Shuffle
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-muted">
|
||||
{cls.shuffle
|
||||
? '(plays in random order)'
|
||||
: '(each caller on hold starts at the top, in this order)'}
|
||||
@@ -329,7 +330,7 @@ function MohClassCard({
|
||||
<li key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{cls.files.length > 1 && !cls.shuffle && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="w-5 text-right text-zinc-500 tabular-nums">{i + 1}.</span>
|
||||
<span className="w-5 text-right text-muted tabular-nums">{i + 1}.</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Move ${f.name} up`}
|
||||
@@ -337,7 +338,7 @@ function MohClassCard({
|
||||
disabled={i === 0 || update.isPending}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
↑
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -346,12 +347,12 @@ function MohClassCard({
|
||||
disabled={i === cls.files.length - 1 || update.isPending}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
↓
|
||||
<ArrowDown className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
{f.name} <span className="text-zinc-500">{secs(f.durationMs)}</span>
|
||||
{f.name} <span className="text-muted">{secs(f.durationMs)}</span>
|
||||
</span>
|
||||
<audio
|
||||
controls
|
||||
@@ -425,19 +426,19 @@ function Announcements() {
|
||||
</h2>
|
||||
<Button onClick={() => setEditing('new')}>Add announcement</Button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
An announcement plays a sound ("Our hours are…") and then sends the caller on, e.g. back to
|
||||
a menu. Use them from phone numbers, menus and office hours.
|
||||
</p>
|
||||
{list.data && list.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{list.data.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">{a.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-500">
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
plays "{a.soundName}", then {a.destinationSummary}
|
||||
</span>
|
||||
<Button onClick={() => setEditing(a)}>Edit</Button>
|
||||
|
||||
@@ -33,12 +33,9 @@ function Panel({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900/60"
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
||||
<span className="text-sky-600 dark:text-sky-400">{icon(path)}</span>
|
||||
<section aria-label={title} className="rounded-xl border border-line bg-surface shadow-sm">
|
||||
<header className="flex items-center gap-2 border-b border-line px-4 py-3">
|
||||
<span className="text-link">{icon(path)}</span>
|
||||
<h2 className="flex-1 font-medium">{title}</h2>
|
||||
{action}
|
||||
</header>
|
||||
@@ -54,7 +51,7 @@ function Copy({ text, label }: { text: string; label: string }) {
|
||||
type="button"
|
||||
aria-label={`Copy ${label}`}
|
||||
title={done ? 'Copied' : `Copy ${label}`}
|
||||
className="rounded p-0.5 text-sky-600 hover:bg-zinc-100 dark:text-sky-400 dark:hover:bg-zinc-800"
|
||||
className="rounded p-0.5 text-link hover:bg-subtle"
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setDone(true);
|
||||
@@ -70,8 +67,8 @@ function Copy({ text, label }: { text: string; label: string }) {
|
||||
/** One "label value" cell of the information grid. */
|
||||
function Item({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-zinc-100 py-2 dark:border-zinc-800">
|
||||
<dt className="text-zinc-500">{label}</dt>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-line py-2">
|
||||
<dt className="text-muted">{label}</dt>
|
||||
<dd className="flex items-center gap-1.5 font-semibold">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -87,7 +84,7 @@ const STATUS: Record<DiagnosticCheck['status'], [string, string]> = {
|
||||
|
||||
function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] }) {
|
||||
return (
|
||||
<ul aria-label={label} className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
<ul aria-label={label} className="divide-y divide-line">
|
||||
{checks.map((c) => (
|
||||
<li key={c.id} className="flex gap-3 px-4 py-2 text-sm">
|
||||
<span
|
||||
@@ -97,9 +94,9 @@ function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] })
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>
|
||||
<span className="font-medium">{c.label}</span>
|
||||
<span className="text-zinc-500"> · {STATUS[c.status][0]}</span>
|
||||
<span className="text-muted"> · {STATUS[c.status][0]}</span>
|
||||
</p>
|
||||
<p className="break-words text-zinc-600 dark:text-zinc-400">{c.detail}</p>
|
||||
<p className="break-words text-muted">{c.detail}</p>
|
||||
{c.fix && (
|
||||
<p className="mt-1 rounded-md bg-amber-50 px-2 py-1 break-words text-amber-900 dark:bg-amber-950/50 dark:text-amber-100">
|
||||
<strong>How to fix:</strong> {c.fix}
|
||||
@@ -128,12 +125,12 @@ function Diagnostics() {
|
||||
}
|
||||
>
|
||||
<h3 className="px-4 pt-3 text-sm font-medium">This server</h3>
|
||||
{inside.isPending && <p className="px-4 py-2 text-sm text-zinc-500">Checking…</p>}
|
||||
{inside.isPending && <p className="px-4 py-2 text-sm text-muted">Checking…</p>}
|
||||
{inside.error && <Alert>{inside.error.message}</Alert>}
|
||||
{inside.data && <Checks label="Server checks" checks={inside.data.checks} />}
|
||||
<div className="flex flex-col gap-2 border-t border-zinc-200 px-4 py-3 text-sm dark:border-zinc-800">
|
||||
<div className="flex flex-col gap-2 border-t border-line px-4 py-3 text-sm">
|
||||
<h3 className="font-medium">From the internet</h3>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-muted">
|
||||
The network test probe connects back to this server from outside, the way a phone on
|
||||
mobile data or your SIP provider does. Quick: ports 80, 443 and SIP. Complete: also SIP
|
||||
over TLS, the browser phone and the call audio ports.
|
||||
@@ -151,16 +148,16 @@ function Diagnostics() {
|
||||
</Button>
|
||||
</div>
|
||||
{inside.data && !hasProbe && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Set a probe first:{' '}
|
||||
<Link to="/admin/settings/network" className="text-sky-700 underline dark:text-sky-400">
|
||||
<Link to="/admin/settings/network" className="text-link underline">
|
||||
Settings → Network
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{test.isPending && (
|
||||
<p role="status" className="text-zinc-500">
|
||||
<p role="status" className="text-muted">
|
||||
Testing… (up to half a minute)
|
||||
</p>
|
||||
)}
|
||||
@@ -203,7 +200,7 @@ export function SystemPage() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">System</h1>
|
||||
<Link to="/about" className="text-sm text-sky-700 hover:underline dark:text-sky-400">
|
||||
<Link to="/about" className="text-sm text-link hover:underline">
|
||||
About and source code
|
||||
</Link>
|
||||
</div>
|
||||
@@ -222,7 +219,7 @@ export function SystemPage() {
|
||||
<Item label="Company">{s.companyName}</Item>
|
||||
<Item label="Public IP">
|
||||
{s.publicIp ?? 'not set'}
|
||||
<span className="font-normal text-zinc-500">
|
||||
<span className="font-normal text-muted">
|
||||
{s.publicIpDynamic ? 'dynamic, re-checked' : 'static'}
|
||||
</span>
|
||||
{s.publicIp && <Copy text={s.publicIp} label="public IP" />}
|
||||
@@ -231,7 +228,7 @@ export function SystemPage() {
|
||||
<Item label="Ports">
|
||||
<span>
|
||||
SIP {p.sip}
|
||||
<span className="font-normal text-zinc-500">{p.sipTcp ? ' UDP+TCP' : ' UDP'}</span>
|
||||
<span className="font-normal text-muted">{p.sipTcp ? ' UDP+TCP' : ' UDP'}</span>
|
||||
{p.sips && ` · SIPS ${p.sips}`} · Media {p.rtp} · HTTPS {p.https}
|
||||
</span>
|
||||
</Item>
|
||||
@@ -266,13 +263,13 @@ export function SystemPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<pre
|
||||
aria-label="Restart command"
|
||||
className="flex-1 overflow-x-auto rounded-md bg-zinc-100 px-3 py-2 font-mono dark:bg-zinc-950"
|
||||
className="flex-1 overflow-x-auto rounded-md bg-subtle px-3 py-2 font-mono"
|
||||
>
|
||||
{RESTART}
|
||||
</pre>
|
||||
<Copy text={RESTART} label="restart command" />
|
||||
</div>
|
||||
<p className="text-zinc-500">
|
||||
<p className="text-muted">
|
||||
Calls in progress drop, so do it when nobody is on the phone. Phones and trunks sign in
|
||||
again by themselves within a minute; the web app, voicemail and history aren't
|
||||
affected.
|
||||
@@ -288,7 +285,7 @@ export function SystemPage() {
|
||||
action={
|
||||
<a
|
||||
href="/api/system/events.csv"
|
||||
className="flex items-center gap-1.5 rounded-md border border-zinc-300 px-2.5 py-1 text-sm hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||
className="flex items-center gap-1.5 rounded-md border border-line-strong bg-surface px-2.5 py-1 text-sm hover:bg-subtle"
|
||||
>
|
||||
{icon(DOWNLOAD)} Download
|
||||
</a>
|
||||
@@ -296,12 +293,12 @@ export function SystemPage() {
|
||||
>
|
||||
{events.error && <Alert>{events.error.message}</Alert>}
|
||||
{events.data && !rows.length && (
|
||||
<p className="px-4 py-3 text-sm text-zinc-500">Nothing logged yet.</p>
|
||||
<p className="px-4 py-3 text-sm text-muted">Nothing logged yet.</p>
|
||||
)}
|
||||
{!!rows.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table aria-label="Events" className="w-full text-left text-sm">
|
||||
<thead className="text-zinc-500">
|
||||
<thead className="text-muted">
|
||||
<tr>
|
||||
{['Severity', 'Event', 'Time', 'Source', 'Details'].map((h) => (
|
||||
<th key={h} scope="col" className="px-4 py-2 font-medium whitespace-nowrap">
|
||||
@@ -310,7 +307,7 @@ export function SystemPage() {
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
<tbody className="divide-y divide-line">
|
||||
{rows.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="px-4 py-1.5 whitespace-nowrap">
|
||||
@@ -332,7 +329,7 @@ export function SystemPage() {
|
||||
</div>
|
||||
)}
|
||||
{events.hasNextPage && (
|
||||
<div className="border-t border-zinc-100 py-2 text-center dark:border-zinc-800">
|
||||
<div className="border-t border-line py-2 text-center">
|
||||
<Button
|
||||
onClick={() => void events.fetchNextPage()}
|
||||
disabled={events.isFetchingNextPage}
|
||||
|
||||
@@ -20,8 +20,7 @@ import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
import { useLive } from '../../live/store.ts';
|
||||
|
||||
export function trunkStatus(t: Pick<TrunkDto, 'enabled' | 'authMode'>, live?: TrunkLive) {
|
||||
if (!t.enabled)
|
||||
return { label: 'Off', color: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' };
|
||||
if (!t.enabled) return { label: 'Off', color: 'bg-line text-fg' };
|
||||
const ok = 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200';
|
||||
const bad = 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200';
|
||||
const wait = 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200';
|
||||
@@ -65,22 +64,22 @@ export function TrunksPage() {
|
||||
New trunk
|
||||
</Button>
|
||||
</div>
|
||||
<p className="max-w-prose text-sm text-zinc-500">
|
||||
<p className="max-w-prose text-sm text-muted">
|
||||
A trunk connects the phone system to your phone company (voip.ms or any SIP provider), so
|
||||
calls can go to and come from the outside world.
|
||||
</p>
|
||||
{trunks.isError && <Alert>{trunks.error.message}</Alert>}
|
||||
{trunks.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
|
||||
{trunks.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{trunks.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No trunks yet. Add your voip.ms sub-account.</p>
|
||||
<p className="text-sm text-muted">No trunks yet. Add your voip.ms sub-account.</p>
|
||||
)}
|
||||
{trunks.data && trunks.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{trunks.data.map((t) => (
|
||||
<li key={t.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm">
|
||||
<StatusPill t={t} />
|
||||
<span className="font-medium">{t.name}</span>
|
||||
<span className="flex-1 truncate text-zinc-500">
|
||||
<span className="flex-1 truncate text-muted">
|
||||
{t.username ? `${t.username}@` : ''}
|
||||
{t.serverHost}
|
||||
</span>
|
||||
@@ -404,7 +403,7 @@ export function TrunkForm({
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<details className="rounded-md border border-line p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">Advanced</summary>
|
||||
<div className="mt-3 flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
@@ -434,10 +433,10 @@ export function TrunkForm({
|
||||
</details>
|
||||
|
||||
{voipms && (
|
||||
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<details className="rounded-md border border-line p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">voip.ms API (optional)</summary>
|
||||
<div className="mt-3 flex flex-col gap-4">
|
||||
<p className="text-zinc-500">
|
||||
<p className="text-muted">
|
||||
Needed for SMS and importing your numbers. In the voip.ms portal: Main Menu → SOAP and
|
||||
REST/JSON API. Enable it, set an API password, and allow this server's public IP
|
||||
address.
|
||||
@@ -506,9 +505,9 @@ export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 font-medium">Your voip.ms numbers</legend>
|
||||
{list.isPending && <p className="text-zinc-500">Asking voip.ms…</p>}
|
||||
{list.isPending && <p className="text-muted">Asking voip.ms…</p>}
|
||||
{list.isError && <Alert>{list.error.message}</Alert>}
|
||||
{list.data?.length === 0 && <p className="text-zinc-500">voip.ms lists no numbers.</p>}
|
||||
{list.data?.length === 0 && <p className="text-muted">voip.ms lists no numbers.</p>}
|
||||
{list.data?.map((d) => (
|
||||
<label key={d.number} className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -522,8 +521,8 @@ export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
|
||||
}
|
||||
/>
|
||||
{formatNumber(d.number)}
|
||||
{d.description && <span className="text-zinc-500">{d.description}</span>}
|
||||
{d.added && <span className="text-zinc-500">(added)</span>}
|
||||
{d.description && <span className="text-muted">{d.description}</span>}
|
||||
{d.added && <span className="text-muted">(added)</span>}
|
||||
</label>
|
||||
))}
|
||||
{fresh.length > 0 && (
|
||||
@@ -558,7 +557,7 @@ function TestPanel({ trunk }: { trunk: TrunkDto }) {
|
||||
const live = useLive((s) => s.trunks[trunk.id]);
|
||||
const status = trunkStatus(trunk, live);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900">
|
||||
<div className="flex flex-col gap-2 rounded-md bg-subtle p-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.color}`}>
|
||||
{status.label}
|
||||
@@ -582,7 +581,7 @@ function TestPanel({ trunk }: { trunk: TrunkDto }) {
|
||||
/** What the customer sets in the voip.ms portal (06 §1). */
|
||||
function VoipmsChecklist() {
|
||||
return (
|
||||
<details className="rounded-md border border-sky-200 bg-sky-50 p-3 text-sm dark:border-sky-900 dark:bg-sky-950/40">
|
||||
<details className="rounded-md border border-primary-line bg-primary-soft p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">Set up voip.ms first (checklist)</summary>
|
||||
<ol className="mt-2 flex list-decimal flex-col gap-1.5 pl-5">
|
||||
<li>
|
||||
@@ -592,6 +591,11 @@ function VoipmsChecklist() {
|
||||
<em>G.711u</em>, DTMF <em>RFC 2833</em>, NAT <em>Yes</em>, CallerID number = your main
|
||||
number, international calling <em>off</em> unless you need it.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Turn on IP restriction</strong> (edit the sub-account → Security) and allow only
|
||||
this server's public IP. A stolen password then can't make calls from anywhere
|
||||
else. If your public IP changes, update it there too.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Pick a POP server</strong> close to you (e.g. chicago1.voip.ms). Enter the same
|
||||
one below.
|
||||
|
||||
@@ -82,7 +82,7 @@ export function UserDrawer({
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="User settings"
|
||||
className="mb-4 flex gap-1 border-b border-zinc-200 dark:border-zinc-800"
|
||||
className="mb-4 flex gap-1 border-b border-line"
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
@@ -93,7 +93,7 @@ export function UserDrawer({
|
||||
disabled={!t.enabled}
|
||||
title={t.enabled ? undefined : 'Give the user an extension number first'}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium disabled:cursor-not-allowed disabled:text-zinc-400 ${tab === t.id ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`}
|
||||
className={`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium disabled:cursor-not-allowed disabled:text-muted ${tab === t.id ? 'border-primary' : 'border-transparent hover:border-line-strong'}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
@@ -243,9 +243,9 @@ function GeneralTab({
|
||||
</label>
|
||||
)}
|
||||
{user && (
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="border-t border-line pt-4">
|
||||
<Button onClick={() => onResetPassword(user)}>Reset password</Button>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Creates a new temporary password and signs them out everywhere.
|
||||
</p>
|
||||
{user.twoStep && <ResetTwoStep user={user} />}
|
||||
@@ -413,7 +413,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
|
||||
};
|
||||
const row = (label: string, value: ReactNode) => (
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||||
<dt className="w-28 shrink-0 text-zinc-500">{label}</dt>
|
||||
<dt className="w-28 shrink-0 text-muted">{label}</dt>
|
||||
<dd className="font-mono break-all">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -421,7 +421,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-muted">
|
||||
Use these to set up a desk phone or softphone by hand. Viewing the password is recorded in
|
||||
the audit log.
|
||||
</p>
|
||||
@@ -436,7 +436,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
|
||||
{creds && row('Dial plan', creds.dialPlan)}
|
||||
</dl>
|
||||
{creds && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
The dial plan is for analog phone adapters (Grandstream HT80x and HT81x): numbers that
|
||||
match go out at once, the rest after a pause or #.
|
||||
</p>
|
||||
@@ -446,7 +446,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
|
||||
Reveal password
|
||||
</Button>
|
||||
)}
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="border-t border-line pt-4">
|
||||
{!confirming ? (
|
||||
<Button onClick={() => setConfirming(true)}>New SIP password</Button>
|
||||
) : (
|
||||
@@ -462,7 +462,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
|
||||
)}
|
||||
{done && <p className="mt-2 text-green-700 dark:text-green-400">New password saved.</p>}
|
||||
</div>
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="border-t border-line pt-4">
|
||||
<h3 className="mb-2 font-medium">Phone app</h3>
|
||||
<PhoneAppSetup load={() => extensionsApi.softphoneQr(ext.id)} />
|
||||
</div>
|
||||
@@ -614,7 +614,7 @@ function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap'
|
||||
<p>
|
||||
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
|
||||
</p>
|
||||
</>
|
||||
@@ -650,7 +650,7 @@ function ResetTwoStep({ user }: { user: UserDto }) {
|
||||
Turn off two-step sign-in
|
||||
</Button>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
For a lost phone: they sign in with their password alone and can turn it on again.
|
||||
</p>
|
||||
{reset.error && <Alert>{reset.error.message}</Alert>}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
|
||||
</div>
|
||||
|
||||
{users.isError && <Alert>{users.error.message}</Alert>}
|
||||
{users.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
|
||||
{users.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{users.data && (
|
||||
<UsersTable
|
||||
users={users.data}
|
||||
@@ -105,7 +105,7 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
|
||||
they must change it when they first sign in.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md bg-zinc-100 px-3 py-2 font-mono text-base dark:bg-zinc-800">
|
||||
<code className="flex-1 rounded-md bg-subtle px-3 py-2 font-mono text-base">
|
||||
{tempPassword?.password}
|
||||
</code>
|
||||
<Button
|
||||
@@ -131,7 +131,7 @@ function UsersTable({
|
||||
}) {
|
||||
const live = useLive((s) => s.extensions);
|
||||
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
|
||||
if (users.length === 0) return <p className="text-sm text-zinc-500">No users yet.</p>;
|
||||
if (users.length === 0) return <p className="text-sm text-muted">No users yet.</p>;
|
||||
const ext = (u: UserDto) =>
|
||||
u.extension ? (
|
||||
<span className="inline-flex items-center gap-1.5 font-mono">
|
||||
@@ -144,13 +144,11 @@ function UsersTable({
|
||||
const badge = (u: UserDto) => (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{u.role === 'admin' && (
|
||||
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-950 dark:text-sky-200">
|
||||
<span className="rounded bg-primary-soft px-1.5 py-0.5 text-xs text-primary-ink">
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
{!u.isActive && (
|
||||
<span className="rounded bg-zinc-200 px-1.5 py-0.5 text-xs dark:bg-zinc-700">inactive</span>
|
||||
)}
|
||||
{!u.isActive && <span className="rounded bg-line px-1.5 py-0.5 text-xs">inactive</span>}
|
||||
</span>
|
||||
);
|
||||
// 12 §5: every way this user can sign in.
|
||||
@@ -173,18 +171,18 @@ function UsersTable({
|
||||
{/* Stacked cards on small screens (05 §1). */}
|
||||
<ul className="flex flex-col gap-2 md:hidden">
|
||||
{users.map((u) => (
|
||||
<li key={u.id} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
|
||||
<li key={u.id} className="rounded-xl border border-line bg-surface shadow-sm p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{u.displayName}
|
||||
{u.extension && <span className="ml-2 text-sm text-zinc-500">{ext(u)}</span>}
|
||||
{u.extension && <span className="ml-2 text-sm text-muted">{ext(u)}</span>}
|
||||
</p>
|
||||
<p className="truncate text-sm text-zinc-500">{u.email}</p>
|
||||
<p className="truncate text-sm text-muted">{u.email}</p>
|
||||
</div>
|
||||
{badge(u)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-zinc-500">
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted">
|
||||
<span>
|
||||
Last login: {fmtDate(u.lastLoginAt)} · {ways(u)}
|
||||
</span>
|
||||
@@ -194,7 +192,7 @@ function UsersTable({
|
||||
))}
|
||||
</ul>
|
||||
<table className="hidden w-full text-left text-sm md:table">
|
||||
<thead className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
|
||||
<thead className="border-b border-line text-muted">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Ext.</th>
|
||||
<th className="py-2 font-medium">Name</th>
|
||||
@@ -209,13 +207,13 @@ function UsersTable({
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-zinc-100 dark:border-zinc-800/60">
|
||||
<tr key={u.id} className="border-b border-line">
|
||||
<td className="py-2">{ext(u)}</td>
|
||||
<td className="py-2 font-medium">{u.displayName}</td>
|
||||
<td className="py-2">{u.email}</td>
|
||||
<td className="py-2">{badge(u)}</td>
|
||||
<td className="py-2">{ways(u)}</td>
|
||||
<td className="py-2 text-zinc-500">{fmtDate(u.lastLoginAt)}</td>
|
||||
<td className="py-2 text-muted">{fmtDate(u.lastLoginAt)}</td>
|
||||
<td className="py-2 text-right">{actions(u)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Contacts (spec 11): my book with the company's (read-only) at /me/contacts, the company's at
|
||||
// /admin/contacts. Add, edit, import (.vcf, .csv), share (an offer the colleague accepts or
|
||||
// denies), click to call. useContactName() gives every page names for numbers.
|
||||
import { X } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
@@ -133,7 +134,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
|
||||
href={ctk.publicUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||
className="inline-flex items-center rounded-md border border-line-strong bg-surface px-3 py-2 text-sm font-medium hover:bg-subtle"
|
||||
>
|
||||
Import in Contrack
|
||||
</a>
|
||||
@@ -146,7 +147,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
|
||||
)}
|
||||
</div>
|
||||
{note && (
|
||||
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p role="status" className="text-sm text-muted">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
@@ -161,11 +162,9 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
|
||||
)}
|
||||
{ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />}
|
||||
{list.data && shown.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
{q ? 'No contact matches that.' : 'No contacts yet.'}
|
||||
</p>
|
||||
<p className="text-sm text-muted">{q ? 'No contact matches that.' : 'No contacts yet.'}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{shown.map((c) => (
|
||||
<li key={c.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm">
|
||||
{canShare && (
|
||||
@@ -184,9 +183,9 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
|
||||
)}
|
||||
<span className="min-w-40 flex-1">
|
||||
<span className="font-medium">{c.name}</span>
|
||||
{c.company && <span className="text-zinc-500"> · {c.company}</span>}
|
||||
{c.company && <span className="text-muted"> · {c.company}</span>}
|
||||
{base === MINE && c.book === 'company' && (
|
||||
<span className="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
<span className="ml-2 rounded bg-subtle px-1.5 py-0.5 text-xs text-muted">
|
||||
Company
|
||||
</span>
|
||||
)}
|
||||
@@ -196,22 +195,17 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
|
||||
<button
|
||||
key={n.number}
|
||||
type="button"
|
||||
className="text-sky-700 underline dark:text-sky-400"
|
||||
className="text-link underline"
|
||||
aria-label={`Call ${c.name} ${n.label} ${show(n.number)}`}
|
||||
onClick={() => call(n.number)}
|
||||
>
|
||||
{n.label && <span className="text-zinc-500 no-underline">{n.label} </span>}
|
||||
{n.label && <span className="text-muted no-underline">{n.label} </span>}
|
||||
{show(n.number)}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
{c.url && (
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sky-700 underline dark:text-sky-400"
|
||||
>
|
||||
<a href={c.url} target="_blank" rel="noreferrer" className="text-link underline">
|
||||
Open in Contrack
|
||||
</a>
|
||||
)}
|
||||
@@ -272,7 +266,7 @@ function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-wrap items-center gap-2 rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950"
|
||||
className="flex flex-wrap items-center gap-2 rounded-md bg-primary-soft p-3 text-sm"
|
||||
>
|
||||
<span className="flex-1">
|
||||
{count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
|
||||
@@ -385,7 +379,7 @@ function ContactDrawer(props: {
|
||||
setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) })
|
||||
}
|
||||
>
|
||||
✕
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -454,7 +448,7 @@ function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => voi
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-muted">
|
||||
vCard files (.vcf) from a phone, Google Contacts, Outlook or Apple Contacts, or a CSV with
|
||||
a header row: name, company, email, label1, number1, label2, number2…
|
||||
</p>
|
||||
@@ -474,7 +468,7 @@ function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => voi
|
||||
if (read.length) run.mutate(false);
|
||||
}}
|
||||
/>
|
||||
{run.isPending && <p className="text-zinc-500">Reading…</p>}
|
||||
{run.isPending && <p className="text-muted">Reading…</p>}
|
||||
{preview && (
|
||||
<div role="status" className="flex flex-col gap-1">
|
||||
<p>
|
||||
@@ -538,7 +532,7 @@ function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean)
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-muted">
|
||||
Each person gets a copy to accept into their own contacts, or deny.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 font-medium">
|
||||
@@ -564,7 +558,7 @@ function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean)
|
||||
setTo(next);
|
||||
}}
|
||||
/>
|
||||
{p.name} <span className="text-zinc-500">({p.number})</span>
|
||||
{p.name} <span className="text-muted">({p.number})</span>
|
||||
</label>
|
||||
))}
|
||||
{share.error && <Alert>{share.error.message}</Alert>}
|
||||
@@ -604,7 +598,7 @@ function SharedWithYou() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="shared-h"
|
||||
className="flex flex-col gap-3 rounded-lg border border-sky-200 bg-sky-50 p-3 dark:border-sky-900 dark:bg-sky-950"
|
||||
className="flex flex-col gap-3 rounded-lg border border-primary-line bg-primary-soft p-3"
|
||||
>
|
||||
<h2 id="shared-h" className="text-lg font-semibold">
|
||||
Shared with you
|
||||
@@ -643,11 +637,9 @@ function SharedWithYou() {
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="flex-1">
|
||||
{s.contact.name}
|
||||
{s.contact.company && (
|
||||
<span className="text-zinc-500"> · {s.contact.company}</span>
|
||||
)}
|
||||
{s.contact.company && <span className="text-muted"> · {s.contact.company}</span>}
|
||||
{s.contact.numbers.map((n) => (
|
||||
<span key={n.number} className="text-zinc-500">
|
||||
<span key={n.number} className="text-muted">
|
||||
{' '}
|
||||
· {show(n.number)}
|
||||
</span>
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
|
||||
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Reports, Settings, People, and the
|
||||
// softphone.
|
||||
import {
|
||||
BookUser,
|
||||
ChartColumn,
|
||||
House,
|
||||
Info,
|
||||
LogOut,
|
||||
Menu,
|
||||
Phone,
|
||||
PhoneMissed,
|
||||
Settings,
|
||||
Shield,
|
||||
Users,
|
||||
Voicemail,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
|
||||
import type { AuthUser } from '@selfphoneng/shared';
|
||||
import { meApi, signOut } from '../../api/client.ts';
|
||||
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
||||
import { Alert, Brand, Button } from '../../components/ui.tsx';
|
||||
import { Alert, Brand, Button, Panel } from '../../components/ui.tsx';
|
||||
import { subscribeLive, useLive } from '../../live/store.ts';
|
||||
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
|
||||
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
|
||||
@@ -16,8 +32,9 @@ import { fmtNumber } from '../admin/CallsPage.tsx';
|
||||
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
||||
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
||||
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
||||
import { ReportsPage } from '../admin/ReportsPage.tsx';
|
||||
|
||||
const link = 'text-sky-700 underline dark:text-sky-400';
|
||||
const link = 'text-link underline';
|
||||
|
||||
export function MePage({ user }: { user: AuthUser }) {
|
||||
const qc = useQueryClient();
|
||||
@@ -36,15 +53,25 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
const toasts = useLive((s) => s.toasts);
|
||||
const offers = useContactShares().data?.length ?? 0;
|
||||
|
||||
const [menu, setMenu] = useState(false);
|
||||
const close = () => setMenu(false);
|
||||
async function logout() {
|
||||
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
|
||||
qc.clear();
|
||||
nav('/login', { replace: true });
|
||||
}
|
||||
|
||||
const tab = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
|
||||
`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium pointer-coarse:py-3 ${isActive ? 'bg-primary-soft text-primary-ink' : 'hover:bg-subtle'}`;
|
||||
const icon = 'size-4 shrink-0';
|
||||
const row = tab({ isActive: false });
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-6 p-4 sm:p-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-xl">
|
||||
<Brand />
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
{user.role === 'admin' && (
|
||||
<Link className={`${link} text-sm`} to="/admin">
|
||||
Admin
|
||||
@@ -54,47 +81,87 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
About
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
|
||||
qc.clear();
|
||||
nav('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" onClick={logout}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative md:hidden"
|
||||
aria-expanded={menu}
|
||||
aria-controls="me-nav"
|
||||
aria-label={offers > 0 ? `Menu, ${offers} shared with you` : 'Menu'}
|
||||
onClick={() => setMenu((o) => !o)}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
{offers > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</header>
|
||||
{/* On phones the tabs and the header links fold into the menu button. */}
|
||||
<nav
|
||||
id="me-nav"
|
||||
aria-label="My phone"
|
||||
className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800"
|
||||
className={`${menu ? 'flex' : 'hidden'} flex-col gap-1 border-b border-line pb-2 md:flex md:flex-row`}
|
||||
>
|
||||
<NavLink to="/me" end className={tab}>
|
||||
<NavLink to="/me" end className={tab} onClick={close}>
|
||||
<House className={icon} />
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink to="/me/calls" className={tab}>
|
||||
<NavLink to="/me/calls" className={tab} onClick={close}>
|
||||
<Phone className={icon} />
|
||||
Calls
|
||||
</NavLink>
|
||||
<NavLink to="/me/voicemail" className={tab}>
|
||||
<NavLink to="/me/voicemail" className={tab} onClick={close}>
|
||||
<Voicemail className={icon} />
|
||||
Voicemail
|
||||
</NavLink>
|
||||
<NavLink to="/me/contacts" className={tab}>
|
||||
<NavLink to="/me/contacts" className={tab} onClick={close}>
|
||||
<BookUser className={icon} />
|
||||
Contacts
|
||||
{offers > 0 && (
|
||||
<span className="ml-1 rounded-full bg-sky-600 px-1.5 text-xs text-white">
|
||||
<span className="rounded-full bg-primary px-1.5 text-xs text-white">
|
||||
<span className="sr-only">, shared with you: </span>
|
||||
{offers}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
<NavLink to="/me/settings" className={tab}>
|
||||
{user.extension && (
|
||||
<NavLink to="/me/reports" className={tab} onClick={close}>
|
||||
<ChartColumn className={icon} />
|
||||
Reports
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink to="/me/settings" className={tab} onClick={close}>
|
||||
<Settings className={icon} />
|
||||
Settings
|
||||
</NavLink>
|
||||
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
|
||||
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`}>
|
||||
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`} onClick={close}>
|
||||
<Users className={icon} />
|
||||
People
|
||||
</NavLink>
|
||||
<div className="mt-1 flex flex-col gap-1 border-t border-line pt-2 md:hidden">
|
||||
{user.role === 'admin' && (
|
||||
<Link className={row} to="/admin">
|
||||
<Shield className={icon} />
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<Link className={row} to="/about">
|
||||
<Info className={icon} />
|
||||
About
|
||||
</Link>
|
||||
<div className="flex items-center justify-between pl-3 text-sm font-medium">
|
||||
Theme
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<button type="button" className={`${row} text-left`} onClick={logout}>
|
||||
<LogOut className={icon} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
@@ -111,7 +178,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
useLive.setState((s) => ({ toasts: s.toasts.filter((x) => x.id !== t.id) }))
|
||||
}
|
||||
>
|
||||
✕
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -122,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
<Route path="calls" element={<MyCallsPage />} />
|
||||
<Route path="voicemail" element={<VoicemailPage />} />
|
||||
<Route path="contacts" element={<ContactsPage />} />
|
||||
<Route path="reports" element={<ReportsPage mine />} />
|
||||
<Route path="settings" element={<MeSettingsPage />} />
|
||||
<Route path="people" element={<PresencePanel me={me} />} />
|
||||
</Routes>
|
||||
@@ -135,39 +203,50 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
const seeAll = 'text-sm font-medium text-link hover:underline';
|
||||
const when = (iso: string) =>
|
||||
new Date(iso).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
/** One line in a Home card: a round icon, two lines of text, and an optional button. */
|
||||
function Row({
|
||||
icon,
|
||||
title,
|
||||
detail,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
detail: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 py-2">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-subtle">{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{title}</span>
|
||||
<span className="block text-xs text-muted">{detail}</span>
|
||||
</span>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Home({ user }: { user: AuthUser }) {
|
||||
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
|
||||
const fresh = list.data?.filter(isNew) ?? [];
|
||||
const nameOf = useContactName();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p>Hi {user.displayName}.</p>
|
||||
<p className="text-2xl font-semibold">Hi {user.displayName}</p>
|
||||
<SharedNotice />
|
||||
{user.extension && <StatusCard ext={user.extension.number} />}
|
||||
{user.extension && <MissedCalls />}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{user.extension && <MissedCalls />}
|
||||
<NewVoicemails />
|
||||
</div>
|
||||
{user.extension && <ParkedOnHome />}
|
||||
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
|
||||
<h2 id="new-vm" className="text-lg font-semibold">
|
||||
New voicemails
|
||||
</h2>
|
||||
{list.error && <Alert>{list.error.message}</Alert>}
|
||||
{list.data && fresh.length === 0 && <p className="text-sm text-zinc-500">None.</p>}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{fresh.slice(0, 5).map((m) => (
|
||||
<li key={m.id} className="text-sm">
|
||||
<span className="font-semibold">{nameOf(m.callerNumber) ?? callerOf(m)}</span>{' '}
|
||||
<span className="text-zinc-500">
|
||||
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{list.data && list.data.length > 0 && (
|
||||
<Link className={`${link} text-sm`} to="/me/voicemail">
|
||||
Open voicemail
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
<p className="text-sm">
|
||||
<Link className={link} to="/change-password">
|
||||
Change password
|
||||
@@ -177,13 +256,49 @@ function Home({ user }: { user: AuthUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NewVoicemails() {
|
||||
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
|
||||
const fresh = list.data?.filter(isNew) ?? [];
|
||||
const nameOf = useContactName();
|
||||
return (
|
||||
<Panel
|
||||
title="New voicemails"
|
||||
action={
|
||||
list.data &&
|
||||
list.data.length > 0 && (
|
||||
<Link className={seeAll} to="/me/voicemail">
|
||||
Open voicemail
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
>
|
||||
{list.error && <Alert>{list.error.message}</Alert>}
|
||||
{list.data && fresh.length === 0 && (
|
||||
<p className="text-sm text-muted">You're all caught up.</p>
|
||||
)}
|
||||
{fresh.length > 0 && (
|
||||
<ul className="-my-2 divide-y divide-line">
|
||||
{fresh.slice(0, 5).map((m) => (
|
||||
<Row
|
||||
key={m.id}
|
||||
icon={<Voicemail className="size-4 text-link" />}
|
||||
title={nameOf(m.callerNumber) ?? callerOf(m)}
|
||||
detail={`${when(m.receivedAt)} · ${mmss(m.durationS)}`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
|
||||
function SharedNotice() {
|
||||
const offers = useContactShares().data ?? [];
|
||||
if (!offers.length) return null;
|
||||
const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', ');
|
||||
return (
|
||||
<p role="status" className="rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950">
|
||||
<p role="status" className="rounded-md bg-primary-soft p-3 text-sm">
|
||||
{from} shared {offers.length} contact{offers.length === 1 ? '' : 's'} with you.{' '}
|
||||
<Link className={link} to="/me/contacts">
|
||||
Accept or deny
|
||||
@@ -202,19 +317,20 @@ function StatusCard({ ext }: { ext: string }) {
|
||||
me.data.cfna && `no answer → ${showTarget(me.data.cfna)}`,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<section
|
||||
aria-label="My status"
|
||||
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
|
||||
<Panel
|
||||
title="My status"
|
||||
action={
|
||||
<Link className={seeAll} to="/me/settings">
|
||||
Change
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<DndSwitch me={me.data} />
|
||||
<QueueLogins ext={ext} />
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}{' '}
|
||||
<Link className={link} to="/me/settings">
|
||||
Change
|
||||
</Link>
|
||||
<p className="text-sm text-muted">
|
||||
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}
|
||||
</p>
|
||||
</section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,7 +358,7 @@ function QueueLogins({ ext }: { ext: string }) {
|
||||
onChange={(e) => toggle.mutate({ id: q.id, paused: !e.target.checked })}
|
||||
/>
|
||||
Taking calls from queue {q.name} ({q.number})
|
||||
<span className="text-xs text-zinc-500">(also *45{q.number} from your phone)</span>
|
||||
<span className="text-xs text-muted">(also *45{q.number} from your phone)</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@@ -258,7 +374,7 @@ function ParkedOnHome() {
|
||||
<div className="flex flex-col gap-1">
|
||||
<ParkedCalls onPickUp={call} />
|
||||
{note && (
|
||||
<p role="status" className="text-sm text-zinc-500">
|
||||
<p role="status" className="text-sm text-muted">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
@@ -273,44 +389,49 @@ function MissedCalls() {
|
||||
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
|
||||
const nameOf = useContactName();
|
||||
return (
|
||||
<section aria-labelledby="missed-h" className="flex flex-col gap-2">
|
||||
<h2 id="missed-h" className="text-lg font-semibold">
|
||||
Missed calls
|
||||
</h2>
|
||||
{calls.data && missed.length === 0 && <p className="text-sm text-zinc-500">None.</p>}
|
||||
<Panel
|
||||
title="Missed calls"
|
||||
action={
|
||||
calls.data &&
|
||||
calls.data.items.length > 0 && (
|
||||
<Link className={seeAll} to="/me/calls">
|
||||
All my calls
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
>
|
||||
{calls.data && missed.length === 0 && <p className="text-sm text-muted">No missed calls.</p>}
|
||||
{note && (
|
||||
<p role="status" className="text-sm text-zinc-500">
|
||||
<p role="status" className="text-sm text-muted">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{missed.map((c) => {
|
||||
const o = otherParty(c);
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<span className="font-semibold">
|
||||
{nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
|
||||
</span>{' '}
|
||||
<span className="text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>{' '}
|
||||
{o.number && (
|
||||
<button
|
||||
type="button"
|
||||
className={link}
|
||||
onClick={() => call(o.number!)}
|
||||
aria-label={`Call back ${fmtNumber(o.number)}`}
|
||||
>
|
||||
Call back
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{calls.data && calls.data.items.length > 0 && (
|
||||
<Link className={`${link} text-sm`} to="/me/calls">
|
||||
All my calls
|
||||
</Link>
|
||||
{missed.length > 0 && (
|
||||
<ul className="-my-2 divide-y divide-line">
|
||||
{missed.map((c) => {
|
||||
const o = otherParty(c);
|
||||
return (
|
||||
<Row
|
||||
key={c.id}
|
||||
icon={<PhoneMissed className="size-4 text-red-600 dark:text-red-400" />}
|
||||
title={nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
|
||||
detail={when(c.startedAt)}
|
||||
>
|
||||
{o.number && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-link"
|
||||
onClick={() => call(o.number!)}
|
||||
aria-label={`Call back ${fmtNumber(o.number)}`}
|
||||
>
|
||||
<Phone className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function DndSwitch({ me }: { me: MeDto }) {
|
||||
onChange={(e) => save.mutate({ dnd: e.target.checked })}
|
||||
/>
|
||||
Do not disturb
|
||||
<span className="text-xs text-zinc-500">(also *78 on, *79 off from your phone)</span>
|
||||
<span className="text-xs text-muted">(also *78 on, *79 off from your phone)</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function DndSwitch({ me }: { me: MeDto }) {
|
||||
export function MeSettingsPage() {
|
||||
const me = useMe();
|
||||
if (me.error) return <Alert>{me.error.message}</Alert>;
|
||||
if (!me.data) return <p className="text-sm text-zinc-500">Loading…</p>;
|
||||
if (!me.data) return <p className="text-sm text-muted">Loading…</p>;
|
||||
// ponytail: the forms start from what was loaded; a *72 from the phone while this page is open
|
||||
// shows after a reload (remounting on every change would also wipe "Saved." and errors).
|
||||
return <SettingsForms me={me.data} />;
|
||||
@@ -119,7 +119,7 @@ function Forwarding({ me }: { me: MeDto }) {
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save forwarding
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
@@ -188,7 +188,7 @@ function Voicemail({ me }: { me: MeDto }) {
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save voicemail settings
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
@@ -218,7 +218,7 @@ function CallReportForm({ me }: { me: MeDto }) {
|
||||
<h2 id="report-h" className="text-lg font-semibold">
|
||||
Call report
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
An email listing your calls: who, which numbers, how long, and if they were recorded. No
|
||||
calls, no email. It goes out a few minutes after each period ends, in the company time zone.
|
||||
</p>
|
||||
@@ -285,14 +285,14 @@ function CallReportForm({ me }: { me: MeDto }) {
|
||||
onChange={(e) => setForm({ ...form, callReportSummaries: e.target.checked })}
|
||||
/>
|
||||
Add a summary of each recorded call
|
||||
<span className="text-xs text-zinc-500">(when your admin has call summaries on)</span>
|
||||
<span className="text-xs text-muted">(when your admin has call summaries on)</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save call report
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
@@ -313,12 +313,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
|
||||
const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
|
||||
const options = useMutation({ mutationFn: meApi.contrackOptions, onSuccess: done });
|
||||
const where = contrack.publicUrl ? (
|
||||
<a
|
||||
className="text-sky-700 underline dark:text-sky-400"
|
||||
href={contrack.publicUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<a className="text-link underline" href={contrack.publicUrl} target="_blank" rel="noreferrer">
|
||||
Contrack
|
||||
</a>
|
||||
) : (
|
||||
@@ -365,9 +360,9 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
|
||||
<span>
|
||||
With the summary and action items of each recorded call
|
||||
{!contrack.summariesAvailable && (
|
||||
<span className="text-zinc-500"> (when your admin turns on call summaries)</span>
|
||||
<span className="text-muted"> (when your admin turns on call summaries)</span>
|
||||
)}
|
||||
<span className="block text-xs text-zinc-500">
|
||||
<span className="block text-xs text-muted">
|
||||
What was said goes into Contrack. If Contrack's own AI uses a cloud provider, it can
|
||||
reach that provider.
|
||||
</span>
|
||||
@@ -376,7 +371,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
|
||||
</div>
|
||||
)}
|
||||
{!contrack.tokenPrefix && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-sm text-muted">
|
||||
Your contacts live in your own {where} account. There, open Settings → Account → API
|
||||
tokens → Create token, with Read and write (so contacts shared with you can be added) and
|
||||
Expires: Never, and paste it here. Only you see your contacts unless you share them.
|
||||
@@ -448,7 +443,7 @@ function MobileApp() {
|
||||
<li key={d.id} className="flex flex-wrap items-center gap-3">
|
||||
<span>
|
||||
{d.name}{' '}
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-muted">
|
||||
({d.platform === 'ios' ? 'iPhone' : 'Android'}, paired{' '}
|
||||
{new Date(d.createdAt).toLocaleDateString()}
|
||||
{d.lastUsedAt && `, last used ${new Date(d.lastUsedAt).toLocaleString()}`})
|
||||
@@ -485,7 +480,7 @@ function MobileApp() {
|
||||
Or type it in the app: server <span className="font-mono">{pair.data.server}</span>,
|
||||
code <span className="font-mono">{pair.data.code}</span>
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-muted">
|
||||
Works once, until {new Date(pair.data.expiresAt).toLocaleTimeString()}. Whoever uses it
|
||||
gets into your account: don't share it.
|
||||
</p>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My calls (05 §4): my call history; click the other number to call back (the softphone when it's
|
||||
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts".
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts", "Report
|
||||
// spam".
|
||||
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { MyCallDto } from '@selfphoneng/shared';
|
||||
import { meApi, recordingsApi } from '../../api/client.ts';
|
||||
import { Alert, Button } from '../../components/ui.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
import { softphone } from '../../softphone/SoftphoneEngine.ts';
|
||||
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
|
||||
import { AddToContacts, useContactName } from './ContactsPage.tsx';
|
||||
import { SpamNote } from '../../components/SpamNote.tsx';
|
||||
import { SpamNote, spamLine } from '../../components/SpamNote.tsx';
|
||||
|
||||
/** The other side of the call, from where I sit. */
|
||||
export const otherParty = (c: MyCallDto) =>
|
||||
@@ -30,6 +31,64 @@ export function useCallBack() {
|
||||
return { call, note };
|
||||
}
|
||||
|
||||
/** To the admins' Blocked numbers: the spam check's line when it flagged the call, else my words. */
|
||||
function ReportSpam({ c }: { c: MyCallDto }) {
|
||||
const flagged = c.spam && c.spam.risk !== 'low' ? spamLine(c.spam) : null;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const send = useMutation({
|
||||
mutationFn: (r: string) => meApi.reportSpam({ callId: c.id, reason: r }),
|
||||
});
|
||||
if (send.isSuccess)
|
||||
return (
|
||||
<span role="status" className="text-muted">
|
||||
Reported to your admin
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{open ? (
|
||||
<form
|
||||
aria-label={`Report ${fmtNumber(c.fromNumber)} as spam`}
|
||||
className="flex w-full flex-wrap items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
send.mutate(reason);
|
||||
}}
|
||||
>
|
||||
<Field label="Why is it spam?">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
maxLength={200}
|
||||
placeholder="Robocall about my car warranty"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button type="submit" variant="primary" disabled={!reason.trim() || send.isPending}>
|
||||
Report
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={send.isPending}
|
||||
onClick={() => (flagged ? send.mutate(flagged) : setOpen(true))}
|
||||
>
|
||||
Report spam
|
||||
</Button>
|
||||
)}
|
||||
{send.error && <Alert>{send.error.message}</Alert>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MyCallsPage() {
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['me', 'calls'],
|
||||
@@ -47,12 +106,12 @@ export function MyCallsPage() {
|
||||
</h2>
|
||||
{list.isError && <Alert>{list.error.message}</Alert>}
|
||||
{note && (
|
||||
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p role="status" className="text-sm text-muted">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
{list.data && calls.length === 0 && <p className="text-sm text-zinc-500">No calls yet.</p>}
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{list.data && calls.length === 0 && <p className="text-sm text-muted">No calls yet.</p>}
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
{calls.map((c) => {
|
||||
const other = otherParty(c);
|
||||
const known = nameOf(other.number);
|
||||
@@ -61,7 +120,7 @@ export function MyCallsPage() {
|
||||
key={c.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="w-36 text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>
|
||||
<span className="w-36 text-muted">{new Date(c.startedAt).toLocaleString()}</span>
|
||||
<span className="w-10">{c.myRole === 'out' ? 'Out' : 'In'}</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`}
|
||||
@@ -70,7 +129,7 @@ export function MyCallsPage() {
|
||||
{other.number ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sky-700 underline dark:text-sky-400"
|
||||
className="text-link underline"
|
||||
aria-label={`Call back ${fmtNumber(other.number)}`}
|
||||
onClick={() => call(other.number!)}
|
||||
>
|
||||
@@ -80,7 +139,7 @@ export function MyCallsPage() {
|
||||
'Unknown'
|
||||
)}
|
||||
{c.direction !== 'internal' && (
|
||||
<span className="text-zinc-500"> · {DIRECTION_TEXT[c.direction]}</span>
|
||||
<span className="text-muted"> · {DIRECTION_TEXT[c.direction]}</span>
|
||||
)}
|
||||
{!known && other.number && c.direction !== 'internal' && (
|
||||
<AddToContacts number={other.number} name={other.name} />
|
||||
@@ -98,6 +157,9 @@ export function MyCallsPage() {
|
||||
)}
|
||||
<StatusBadge status={c.status} />
|
||||
<span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span>
|
||||
{c.myRole === 'in' && c.direction === 'inbound' && other.number && !known && (
|
||||
<ReportSpam c={c} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -68,32 +68,32 @@ function Messages() {
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No messages.</p>}
|
||||
{list.data?.length === 0 && <p className="text-sm text-muted">No messages.</p>}
|
||||
{!!list.data?.length && shown?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No message mentions that.</p>
|
||||
<p className="text-sm text-muted">No message mentions that.</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{shown?.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 sm:flex-row sm:items-center dark:border-zinc-800"
|
||||
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={isNew(m) ? 'font-semibold' : ''}>
|
||||
{who(m)}
|
||||
{isNew(m) && (
|
||||
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
|
||||
<span className="ml-2 rounded bg-primary-soft px-1.5 py-0.5 text-xs text-primary-ink">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted">
|
||||
{m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''}
|
||||
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
|
||||
</p>
|
||||
{m.transcript !== null && (
|
||||
<p className="mt-1 text-sm" title="Automatic transcript, may have mistakes">
|
||||
{m.transcript || <span className="text-zinc-500">(no words)</span>}
|
||||
{m.transcript || <span className="text-muted">(no words)</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -108,7 +108,7 @@ function Messages() {
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={meApi.audioUrl(m.id, true)}
|
||||
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-subtle"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
@@ -217,7 +217,7 @@ function Greeting(props: {
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
|
||||
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3"
|
||||
>
|
||||
<p id={id} className="font-medium">
|
||||
{label}
|
||||
@@ -231,7 +231,7 @@ function Greeting(props: {
|
||||
className="w-full sm:w-80"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Not recorded. {props.fallback}</p>
|
||||
<p className="text-sm text-muted">Not recorded. {props.fallback}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recording ? (
|
||||
@@ -265,8 +265,8 @@ function Greeting(props: {
|
||||
)}
|
||||
</div>
|
||||
{recording && <p className="text-sm text-red-600 dark:text-red-400">Recording…</p>}
|
||||
{upload.isPending && <p className="text-sm text-zinc-500">Saving…</p>}
|
||||
{upload.isSuccess && !upload.isPending && <p className="text-sm text-zinc-500">Saved.</p>}
|
||||
{upload.isPending && <p className="text-sm text-muted">Saving…</p>}
|
||||
{upload.isSuccess && !upload.isPending && <p className="text-sm text-muted">Saved.</p>}
|
||||
{(micError ?? upload.error?.message ?? remove.error?.message) && (
|
||||
<Alert>{micError ?? upload.error?.message ?? remove.error?.message}</Alert>
|
||||
)}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PresencePanel({ me }: { me: string | null }) {
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
{note && (
|
||||
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p role="status" className="text-sm text-muted">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
@@ -63,7 +63,7 @@ export function PresencePanel({ me }: { me: string | null }) {
|
||||
<li
|
||||
key={e.number}
|
||||
data-number={e.number}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-subtle"
|
||||
onDragOver={(ev) => {
|
||||
if (active && ev.dataTransfer.types.includes(CALL_DRAG)) ev.preventDefault();
|
||||
}}
|
||||
@@ -75,7 +75,7 @@ export function PresencePanel({ me }: { me: string | null }) {
|
||||
<StatusDot ext={e} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
<span className="font-medium">{e.name}</span>{' '}
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-muted">
|
||||
{e.number} · {statusLabel(e)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user