// SPDX-License-Identifier: AGPL-3.0-or-later
// Trunks (05 §2, 06 §1–§2): list with live status, voip.ms-first form with the portal checklist.
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import {
CODECS,
formatNumber,
Hostname,
VOIPMS_SERVERS,
type TrunkDto,
type TrunkLive,
type TrunkTestResult,
} from '@selfphoneng/shared';
import { trunksApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useLive } from '../../live/store.ts';
export function trunkStatus(t: Pick, live?: TrunkLive) {
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';
if (!live) return { label: 'Unknown', color: wait };
if (t.authMode === 'ip')
return live.reachable
? { label: 'Reachable', color: ok }
: { label: 'Unreachable', color: bad };
if (live.registration === 'registered') return { label: 'Registered', color: ok };
if (live.registration === 'rejected') return { label: 'Rejected', color: bad };
return { label: 'Not registered', color: wait };
}
function StatusPill({ t }: { t: TrunkDto }) {
const live = useLive((s) => s.trunks[t.id]);
const s = trunkStatus(t, live);
return (
{s.label}
);
}
export function TrunksPage() {
const qc = useQueryClient();
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['trunks'] });
const remove = useMutation({
mutationFn: (id: string) => trunksApi.remove(id),
onSuccess: () => {
setDeleting(null);
void refresh();
},
});
return (
Trunks
setEditing('new')}>
New trunk
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.
{trunks.isError && {trunks.error.message} }
{trunks.isPending && Loading…
}
{trunks.data?.length === 0 && (
No trunks yet. Add your voip.ms sub-account.
)}
{trunks.data && trunks.data.length > 0 && (
{trunks.data.map((t) => (
{t.name}
{t.username ? `${t.username}@` : ''}
{t.serverHost}
setEditing(t)}>Edit
{
remove.reset();
setDeleting(t);
}}
>
Delete
))}
)}
{editing && (
setEditing(null)}
onSaved={(t) => {
void refresh();
setEditing(t); // stay open so the admin can press Test
}}
/>
)}
Delete {deleting?.name} ? Calls can no longer use it.
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => setDeleting(null)}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
);
}
const FormSchema = z.object({
name: z.string().trim().min(1, 'Required').max(40),
provider: z.enum(['voipms', 'generic']),
enabled: z.boolean(),
serverHost: Hostname,
serverPort: z.number({ error: 'A port number' }).int().min(1).max(65535),
transport: z.enum(['udp', 'tcp', 'tls']),
authMode: z.enum(['registration', 'ip']),
username: z.string().trim().max(64),
password: z.string().max(128),
fromUser: z.string().trim().max(64),
fromDomain: z.union([z.literal(''), Hostname]),
codecs: z.array(z.enum(CODECS)).min(1, 'Pick at least one codec'),
srtp: z.boolean(),
maxChannels: z.string().regex(/^(\d{1,4})?$/, 'A number, or empty for no limit'),
voipmsApiUsername: z.string().trim().max(254),
voipmsApiPassword: z.string().max(128),
});
type FormValues = z.infer;
function TrunkDrawer({
trunk,
onClose,
onSaved,
}: {
trunk: TrunkDto | null;
onClose: () => void;
onSaved: (t: TrunkDto) => void;
}) {
return (
Close
{trunk ? 'Save' : 'Create trunk'}
>
}
>
);
}
/** The trunk form (id "trunk-form"); the caller renders the submit button. Also used by /setup. */
export function TrunkForm({
trunk,
onSaved,
}: {
trunk: TrunkDto | null;
onSaved: (t: TrunkDto) => void;
}) {
const qc = useQueryClient();
const [error, setError] = useState();
const [saved, setSaved] = useState(false);
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
name: trunk?.name ?? 'voip.ms',
provider: trunk?.provider ?? 'voipms',
enabled: trunk?.enabled ?? true,
serverHost: trunk?.serverHost ?? '',
serverPort: trunk?.serverPort ?? 5060,
transport: trunk?.transport ?? 'udp',
authMode: trunk?.authMode ?? 'registration',
username: trunk?.username ?? '',
password: '',
fromUser: trunk?.fromUser ?? '',
fromDomain: trunk?.fromDomain ?? '',
codecs: trunk?.codecs ?? ['ulaw'],
srtp: trunk?.srtp ?? false,
maxChannels: trunk?.maxChannels ? String(trunk.maxChannels) : '',
voipmsApiUsername: trunk?.voipmsApiUsername ?? '',
voipmsApiPassword: '',
},
});
const { errors } = form.formState;
const [provider, authMode, transport] = useWatch({
control: form.control,
name: ['provider', 'authMode', 'transport'],
});
const voipms = provider === 'voipms';
// The account's POP servers once the trunk has API access (06 §2); the built-in list otherwise.
const servers = useQuery({
queryKey: ['voipms-servers', trunk?.id],
queryFn: () => trunksApi.voipmsServers(trunk!.id),
enabled: voipms && !!trunk?.hasApiPassword,
retry: false,
});
function setTransport(t: FormValues['transport']) {
form.setValue('transport', t);
form.setValue('serverPort', t === 'tls' ? 5061 : 5060);
if (t !== 'tls') form.setValue('srtp', false);
}
const onSubmit = form.handleSubmit(async (v) => {
setError(undefined);
const body = {
...v,
username: v.username || null,
fromUser: v.fromUser || null,
fromDomain: v.fromDomain || null,
maxChannels: v.maxChannels ? Number(v.maxChannels) : null,
voipmsApiUsername: v.voipmsApiUsername || null,
// Empty password fields mean "keep the stored one".
password: v.password || undefined,
voipmsApiPassword: v.voipmsApiPassword || undefined,
};
try {
const t = trunk ? await trunksApi.update(trunk.id, body) : await trunksApi.create(body);
form.reset({ ...v, password: '', voipmsApiPassword: '' });
// New API login: ask voip.ms again.
void qc.invalidateQueries({ queryKey: ['voipms-servers', t.id] });
void qc.invalidateQueries({ queryKey: ['voipms-dids', t.id] });
setSaved(true);
onSaved(t);
} catch (e) {
setError((e as Error).message);
}
});
return (
);
}
/** "Import numbers from voip.ms" (06 §7): the account's DIDs with checkboxes. Also in /setup. */
export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [picked, setPicked] = useState([]);
const [done, setDone] = useState(null);
const list = useQuery({
queryKey: ['voipms-dids', trunk.id],
queryFn: () => trunksApi.voipmsDids(trunk.id),
enabled: open,
retry: false,
});
const run = useMutation({
mutationFn: () => trunksApi.importDids(trunk.id, picked),
onSuccess: async (r) => {
setDone(r.added);
setPicked([]);
await qc.invalidateQueries({ queryKey: ['dids'] });
await list.refetch();
},
});
if (!open)
return (
setOpen(true)}>Import numbers from voip.ms
);
const fresh = list.data?.filter((d) => !d.added) ?? [];
return (
Your voip.ms numbers
{list.isPending && Asking voip.ms…
}
{list.isError && {list.error.message} }
{list.data?.length === 0 && voip.ms lists no numbers.
}
{list.data?.map((d) => (
setPicked((p) =>
e.target.checked ? [...p, d.number] : p.filter((n) => n !== d.number),
)
}
/>
{formatNumber(d.number)}
{d.description && {d.description} }
{d.added && (added) }
))}
{fresh.length > 0 && (
run.mutate()}
>
Import {picked.length || ''} {picked.length === 1 ? 'number' : 'numbers'}
setPicked(fresh.map((d) => d.number))}>Pick all
)}
{done !== null && (
Added {done}. They hang up until you choose where their calls go (Phone numbers).
)}
{run.error && {run.error.message} }
);
}
function TestPanel({ trunk }: { trunk: TrunkDto }) {
const [result, setResult] = useState(null);
const test = useMutation({
mutationFn: () => trunksApi.test(trunk.id),
onSuccess: setResult,
onError: (e) => setResult({ error: e.message }),
});
const live = useLive((s) => s.trunks[trunk.id]);
const status = trunkStatus(trunk, live);
return (
{status.label}
test.mutate()} disabled={test.isPending || !trunk.enabled}>
{test.isPending ? 'Testing…' : 'Test'}
{result && (
{'error' in result
? result.error
: `${result.detail}${result.rttMs !== null ? ` (${result.rttMs} ms)` : ''}`}
)}
);
}
/** What the customer sets in the voip.ms portal (06 §1). */
function VoipmsChecklist() {
return (
Set up voip.ms first (checklist)
Create a sub-account (Main Menu → Sub Accounts → Create), one per phone
system, e.g. 123456_pbx: authentication User/Password , a strong
random password, device type Asterisk, IP PBX, Gateway or VoIP Switch , codec{' '}
G.711u , DTMF RFC 2833 , NAT Yes , CallerID number = your main
number, international calling off unless you need it.
Turn on IP restriction (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.
Pick a POP server close to you (e.g. chicago1.voip.ms). Enter the same
one below.
Point each number at the sub-account (DID Numbers → Manage DIDs → edit):
routing SIP/IAX → the sub-account , and the same POP server . A different
POP is a common reason incoming calls fail.
Register an E911 address for every number used as an emergency caller ID
(DID → e911).
Set balance alerts and per-call or daily limits in your voip.ms account
settings.
);
}