// 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

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}
  • ))}
)} {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 ( } > ); } /** 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 (
{error && {error}} {saved && (

Saved. The phone system picks it up within a few seconds.

)} {trunk && } {(a) => ( )} {voipms && } {(a) => } {(a) => ( <> {(servers.data ?? VOIPMS_SERVERS).map((s) => ( )} {(a) => ( )} {authMode === 'registration' && ( <> {(a) => ( )} {(a) => ( )} )}
{(a) => ( )} {(a) => ( )}
Codecs
{CODECS.map((c) => ( ))}
{errors.codecs && (

{errors.codecs.message}

)}
{(a) => ( )}
Advanced
{(a) => } {(a) => }
{voipms && (
voip.ms API (optional)

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.

{(a) => ( )} {(a) => ( )} {trunk?.hasApiPassword && }
)} ); } /** "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 (
); 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) => ( ))} {fresh.length > 0 && (
)} {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}
{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)
  1. 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.
  2. 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.
  3. Pick a POP server close to you (e.g. chicago1.voip.ms). Enter the same one below.
  4. 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.
  5. Register an E911 address for every number used as an emergency caller ID (DID → e911).
  6. Set balance alerts and per-call or daily limits in your voip.ms account settings.
); }