setup: first-run wizard, telephony restart, system status
/setup walks a fresh install to working calls: password (done by the forced change), company, network (detect public IP, LAN IP from /api/system/status, router checklist; restarts telephony because the transports changed), email (skippable), voip.ms trunk (Next unlocks on Registered), phone numbers, users (extension auto-numbered, temporary passwords shown once), main number routing, emergency (default E911 caller ID required once a trunk exists), done. Steps 7-8 ship now because the acceptance needs a user and a routed number; their M6 choices (everyone, IVR) come later. Admins land on /setup until it is finished or skipped. api: /api/setup/status and /complete, POST /api/settings/network/detect-public-ip (ipify, then ifconfig.me), GET /api/system/status (M3 subset), POST /api/system/asterisk/restart (an AMI disconnect during a restart counts as success). restartRequired clears on AMI connect once Asterisk's uptime shows it started after the change. The first phone number becomes the default outbound caller ID when none is set (found by the acceptance run: calls otherwise left with the provider's caller ID). Verified: scripts/e2e/m3-t5.mjs on a fresh database against the fake voip.ms: wizard end to end, then an inbound call to the DID rings the new user's softphone and an outbound call leaves as 13125550000 with the DID in Remote-Party-ID. m1-t4 now passes through "Skip setup". Task: M3-T5
This commit is contained in:
1 parent
409724bc55
commit
c2b9fff126
19 files changed
+1455
-210
No files matched your search
@@ -127,3 +127,17 @@ export const settingsApi = {
|
||||
),
|
||||
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
|
||||
};
|
||||
|
||||
export const systemApi = {
|
||||
status: () =>
|
||||
api<{ ami: { connected: boolean }; restartRequired: boolean; lanIps: string[] }>(
|
||||
'GET',
|
||||
'/api/system/status',
|
||||
),
|
||||
restartAsterisk: (now: boolean) =>
|
||||
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),
|
||||
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
|
||||
completeSetup: () => api<{ ok: true }>('POST', '/api/setup/complete'),
|
||||
};
|
||||
@@ -15,11 +15,13 @@ import { NumbersPage } from './pages/admin/NumbersPage.tsx';
|
||||
import { OutboundPage } from './pages/admin/OutboundPage.tsx';
|
||||
import { SettingsPage } from './pages/admin/SettingsPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import { SetupPage } from './pages/SetupPage.tsx';
|
||||
import './index.css';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/login', Component: LoginPage },
|
||||
{ path: '/change-password', Component: ChangePasswordPage },
|
||||
{ path: '/setup', element: <RequireAuth role="admin">{() => <SetupPage />}</RequireAuth> },
|
||||
{
|
||||
path: '/admin',
|
||||
element: <RequireAuth role="admin">{(user) => <AdminLayout user={user} />}</RequireAuth>,
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 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; step 8's ring group and IVR choices arrive with M6.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { formatNumber, type Destination, type TrunkDto } from '@selfphoneng/shared';
|
||||
import {
|
||||
didsApi,
|
||||
extensionsApi,
|
||||
settingsApi,
|
||||
systemApi,
|
||||
trunksApi,
|
||||
usersApi,
|
||||
} from '../api/client.ts';
|
||||
import { DestinationPicker } from '../components/DestinationPicker.tsx';
|
||||
import { ThemeToggle } from '../components/ThemeToggle.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
|
||||
import { subscribeLive, useLive } from '../live/store.ts';
|
||||
import { EmailTab, EmergencyTab } from './admin/SettingsPage.tsx';
|
||||
import { TrunkForm, trunkStatus } from './admin/TrunksPage.tsx';
|
||||
|
||||
const STEPS = [
|
||||
'Password',
|
||||
'Company',
|
||||
'Network',
|
||||
'Email',
|
||||
'Trunk',
|
||||
'Phone numbers',
|
||||
'Users',
|
||||
'Main number',
|
||||
'Emergency',
|
||||
'Done',
|
||||
] as const;
|
||||
|
||||
export function SetupPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const step = Math.min(Math.max(Number(params.get('step') ?? 1), 1), STEPS.length);
|
||||
const go = (n: number) => setParams({ step: String(n) });
|
||||
const next = () => go(step + 1);
|
||||
const qc = useQueryClient();
|
||||
const nav = useNavigate();
|
||||
useEffect(subscribeLive, []);
|
||||
const finish = useMutation({
|
||||
mutationFn: systemApi.completeSetup,
|
||||
onSuccess: async () => {
|
||||
qc.setQueryData(['setup'], { completed: true }); // the admin layout reads it on mount
|
||||
nav('/admin', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-6 p-4 md:p-8">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Set up your phone system</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" onClick={() => finish.mutate()}>
|
||||
Skip setup
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<ol aria-label="Steps" className="flex flex-wrap gap-1 text-xs">
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s}>
|
||||
<button
|
||||
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'}`}
|
||||
>
|
||||
{i + 1}. {s}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<section aria-labelledby="step-title" className="flex flex-col gap-4">
|
||||
<h2 id="step-title" className="text-lg font-medium">
|
||||
{step}. {STEPS[step - 1]}
|
||||
</h2>
|
||||
{step === 1 && <PasswordStep next={next} />}
|
||||
{step === 2 && <CompanyStep next={next} />}
|
||||
{step === 3 && <NetworkStep next={next} />}
|
||||
{step === 4 && <SettingsStep tab="email" next={next} skippable />}
|
||||
{step === 5 && <TrunkStep next={next} />}
|
||||
{step === 6 && <NumbersStep next={next} />}
|
||||
{step === 7 && <UsersStep next={next} />}
|
||||
{step === 8 && <RoutingStep next={next} />}
|
||||
{step === 9 && <SettingsStep tab="emergency" next={next} />}
|
||||
{step === 10 && <DoneStep onFinish={() => finish.mutate()} busy={finish.isPending} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Nav({
|
||||
next,
|
||||
canNext = true,
|
||||
skip,
|
||||
hint,
|
||||
}: {
|
||||
next: () => void;
|
||||
canNext?: boolean;
|
||||
skip?: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<Button variant="primary" onClick={next} disabled={!canNext}>
|
||||
Next
|
||||
</Button>
|
||||
{skip && (
|
||||
<Button variant="ghost" onClick={next}>
|
||||
{skip}
|
||||
</Button>
|
||||
)}
|
||||
{hint && <span className="text-sm text-zinc-500">{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<Nav next={next} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useSettings() {
|
||||
return useQuery({ queryKey: ['settings'], queryFn: settingsApi.get });
|
||||
}
|
||||
|
||||
function CompanyStep({ next }: { next: () => void }) {
|
||||
const s = useSettings();
|
||||
if (!s.data) return <Loading q={s} />;
|
||||
return <CompanyForm initial={s.data} next={next} />;
|
||||
}
|
||||
|
||||
function CompanyForm({
|
||||
initial,
|
||||
next,
|
||||
}: {
|
||||
initial: { 'general.companyName': string; 'general.timezone': string };
|
||||
next: () => void;
|
||||
}) {
|
||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const [name, setName] = useState(initial['general.companyName']);
|
||||
const [tz, setTz] = useState(
|
||||
initial['general.companyName'] === 'My Company' ? browserTz : initial['general.timezone'],
|
||||
);
|
||||
const save = useMutation({ mutationFn: settingsApi.patch, onSuccess: next });
|
||||
const zones = Intl.supportedValuesOf('timeZone');
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({
|
||||
'general.companyName': name,
|
||||
'general.timezone': tz,
|
||||
'general.language': 'en',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
<Field label="Company name">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Time zone" hint="Office hours and call history use it.">
|
||||
{(a) => (
|
||||
<select {...a} value={tz} onChange={(e) => setTz(e.target.value)} className={inputClass}>
|
||||
{zones.map((z) => (
|
||||
<option key={z}>{z}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Language" hint="More languages later.">
|
||||
{(a) => (
|
||||
<select {...a} disabled className={inputClass}>
|
||||
<option>English</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<Button variant="primary" type="submit" disabled={save.isPending || !name.trim()}>
|
||||
Save and continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkStep({ next }: { next: () => void }) {
|
||||
const s = useSettings();
|
||||
const status = useQuery({
|
||||
queryKey: ['system-status'],
|
||||
queryFn: systemApi.status,
|
||||
});
|
||||
if (!s.data || !status.data) return <Loading q={s} />;
|
||||
return (
|
||||
<NetworkForm
|
||||
next={next}
|
||||
publicIp={s.data['network.publicIp'] ?? ''}
|
||||
lanIp={s.data['network.lanIp'] ?? status.data.lanIps[0] ?? ''}
|
||||
lanIps={status.data.lanIps}
|
||||
localNets={s.data['network.localNets'].join(', ')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkForm(p: {
|
||||
next: () => void;
|
||||
publicIp: string;
|
||||
lanIp: string;
|
||||
lanIps: string[];
|
||||
localNets: string;
|
||||
}) {
|
||||
const [publicIp, setPublicIp] = useState(p.publicIp);
|
||||
const [lanIp, setLanIp] = useState(p.lanIp);
|
||||
const [nets, setNets] = useState(p.localNets);
|
||||
const [done, setDone] = useState(false);
|
||||
const [note, setNote] = useState<string>();
|
||||
const detect = useMutation({
|
||||
mutationFn: systemApi.detectPublicIp,
|
||||
onSuccess: setPublicIp,
|
||||
});
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
await settingsApi.patch({
|
||||
'network.publicIp': publicIp.trim() || null,
|
||||
'network.lanIp': lanIp.trim() || null,
|
||||
'network.localNets': nets.split(/[,\s]+/).filter(Boolean),
|
||||
});
|
||||
// New addresses change the SIP transports, which only load on a restart (01 §4.3).
|
||||
// A fresh install has no calls to drop, so restart right away.
|
||||
await new Promise((r) => setTimeout(r, 2000)); // let the debounced apply run
|
||||
if ((await systemApi.status()).restartRequired) {
|
||||
await systemApi.restartAsterisk(true);
|
||||
setNote('Telephony restarted to use the new addresses.');
|
||||
}
|
||||
},
|
||||
onSuccess: p.next,
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{(save.isError || detect.isError) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
|
||||
{note && (
|
||||
<p role="status" className="text-sm">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<Field
|
||||
label="Public IP address"
|
||||
hint="Your internet address, so outside phones and voip.ms can reach this server."
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={publicIp}
|
||||
onChange={(e) => setPublicIp(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button onClick={() => detect.mutate()} disabled={detect.isPending}>
|
||||
{detect.isPending ? 'Detecting…' : 'Detect public IP'}
|
||||
</Button>
|
||||
</div>
|
||||
<Field label="LAN IP address" hint="This server's address on your office network.">
|
||||
{(a) => (
|
||||
<>
|
||||
<input
|
||||
{...a}
|
||||
list="lan-ips"
|
||||
value={lanIp}
|
||||
onChange={(e) => setLanIp(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<datalist id="lan-ips">
|
||||
{p.lanIps.map((ip) => (
|
||||
<option key={ip} value={ip} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Local networks"
|
||||
hint="Networks that reach this server without NAT, separated by commas."
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={nets}
|
||||
onChange={(e) => setNets(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p className="font-medium">On your router:</p>
|
||||
<ol className="mt-1 list-decimal pl-5">
|
||||
<li>
|
||||
Turn off <strong>SIP ALG</strong> (sometimes called SIP helper). It rewrites calls and
|
||||
breaks them.
|
||||
</li>
|
||||
<li>
|
||||
Forward <strong>UDP 5060</strong> and <strong>UDP 10000–20000</strong> to this server (
|
||||
{lanIp || 'its LAN IP'}). Behind two routers, do it on both.
|
||||
</li>
|
||||
</ol>
|
||||
<label className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={(e) => setDone(e.target.checked)}
|
||||
className="size-4"
|
||||
/>
|
||||
I've done this
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<Button variant="primary" type="submit" disabled={save.isPending || !done}>
|
||||
{save.isPending ? 'Saving…' : 'Save and continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsStep({
|
||||
tab,
|
||||
next,
|
||||
skippable,
|
||||
}: {
|
||||
tab: 'email' | 'emergency';
|
||||
next: () => void;
|
||||
skippable?: boolean;
|
||||
}) {
|
||||
const s = useSettings();
|
||||
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
|
||||
if (!s.data) return <Loading q={s} />;
|
||||
// 06 §6: once a trunk exists, an emergency caller ID is required.
|
||||
const needCid =
|
||||
tab === 'emergency' &&
|
||||
Boolean(trunks.data?.length) &&
|
||||
!s.data['emergency.defaultCallerIdDidId'];
|
||||
return (
|
||||
<>
|
||||
{tab === 'email' ? <EmailTab s={s.data} /> : <EmergencyTab s={s.data} />}
|
||||
<Nav
|
||||
next={next}
|
||||
canNext={!needCid}
|
||||
skip={skippable ? 'Skip for now' : undefined}
|
||||
hint={needCid ? 'Pick and save a default emergency caller ID first.' : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TrunkStep({ next }: { next: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
|
||||
const [trunk, setTrunk] = useState<TrunkDto | null>(null);
|
||||
const t = trunk ?? trunks.data?.[0] ?? null;
|
||||
const live = useLive((s) => (t ? s.trunks[t.id] : undefined));
|
||||
if (!trunks.data) return <Loading q={trunks} />;
|
||||
const status = t ? trunkStatus(t, live) : null;
|
||||
const ok = status?.label === 'Registered' || status?.label === 'Reachable';
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Connect your voip.ms sub-account. Follow the checklist, save, and wait for{' '}
|
||||
<strong>Registered</strong>.
|
||||
</p>
|
||||
<TrunkForm
|
||||
trunk={t}
|
||||
onSaved={(saved) => {
|
||||
setTrunk(saved);
|
||||
void qc.invalidateQueries({ queryKey: ['trunks'] });
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Button variant="primary" type="submit" form="trunk-form">
|
||||
{t ? 'Save trunk' : 'Create trunk'}
|
||||
</Button>
|
||||
</div>
|
||||
<Nav
|
||||
next={next}
|
||||
canNext={ok}
|
||||
skip="I'll add it later"
|
||||
hint={
|
||||
t && !ok ? `Status: ${status?.label}. Next unlocks when it is Registered.` : undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NumbersStep({ next }: { next: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
|
||||
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
||||
const [number, setNumber] = useState('');
|
||||
const [label, setLabel] = useState('Main line');
|
||||
const add = useMutation({
|
||||
mutationFn: () =>
|
||||
didsApi.create({
|
||||
number,
|
||||
label: label || null,
|
||||
trunkId: trunks.data![0]!.id,
|
||||
destination: { type: 'hangup' },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNumber('');
|
||||
setLabel('');
|
||||
void qc.invalidateQueries({ queryKey: ['dids'] });
|
||||
},
|
||||
});
|
||||
if (!trunks.data || !dids.data) return <Loading q={dids} />;
|
||||
if (!trunks.data.length)
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm">Numbers arrive through a trunk: add one first (step 5).</p>
|
||||
<Nav next={next} skip="Skip" canNext={false} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 8.
|
||||
</p>
|
||||
{dids.data.length > 0 && (
|
||||
<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>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
add.mutate();
|
||||
}}
|
||||
>
|
||||
<Field label="Phone number">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={number}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
inputMode="tel"
|
||||
placeholder="(312) 555-1234"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Label">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button type="submit" disabled={!number || add.isPending}>
|
||||
Add number
|
||||
</Button>
|
||||
</form>
|
||||
{add.isError && <Alert>{add.error.message}</Alert>}
|
||||
<Nav next={next} canNext={dids.data.length > 0} skip="Skip for now" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type NewUser = { name: string; email: string };
|
||||
|
||||
function UsersStep({ next }: { next: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const users = useQuery({ queryKey: ['users'], queryFn: usersApi.list });
|
||||
const [rows, setRows] = useState<NewUser[]>([{ name: '', email: '' }]);
|
||||
const [created, setCreated] = useState<
|
||||
{ name: string; email: string; number: string; password: string }[]
|
||||
>([]);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const out: typeof created = [];
|
||||
for (const r of rows.filter((x) => x.name.trim() && x.email.trim())) {
|
||||
const { user, temporaryPassword } = await usersApi.create({
|
||||
displayName: r.name.trim(),
|
||||
email: r.email.trim(),
|
||||
role: 'user',
|
||||
});
|
||||
const number = await extensionsApi.nextNumber('user');
|
||||
if (number) await extensionsApi.create({ number, name: r.name.trim(), userId: user.id });
|
||||
out.push({
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
number: number ?? '–',
|
||||
password: temporaryPassword,
|
||||
});
|
||||
setCreated([...out]);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
onSettled: () => {
|
||||
setRows([{ name: '', email: '' }]);
|
||||
void qc.invalidateQueries({ queryKey: ['users'] });
|
||||
void qc.invalidateQueries({ queryKey: ['extensions'] });
|
||||
},
|
||||
});
|
||||
const set = (i: number, k: keyof NewUser, v: string) =>
|
||||
setRows(rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
||||
const withExt = users.data?.filter((u) => u.extension) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Add the people who get a phone (up to 10 here; more later under Users & Extensions).
|
||||
Each gets the next free extension and a temporary password, shown once.
|
||||
</p>
|
||||
{withExt.length > 0 && (
|
||||
<ul className="text-sm">
|
||||
{withExt.map((u) => (
|
||||
<li key={u.id}>
|
||||
✓ {u.extension!.number} {u.displayName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
aria-label={`Name ${i + 1}`}
|
||||
placeholder="Name"
|
||||
value={r.name}
|
||||
onChange={(e) => set(i, 'name', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
aria-label={`Email ${i + 1}`}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={r.email}
|
||||
onChange={(e) => set(i, 'email', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
{rows.length < 10 && (
|
||||
<Button onClick={() => setRows([...rows, { name: '', email: '' }])}>
|
||||
Another person
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" type="submit" disabled={save.isPending}>
|
||||
Add users
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<p className="mb-2 font-medium">
|
||||
Give each person their temporary password (shown only now):
|
||||
</p>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{created.map((c) => (
|
||||
<tr key={c.email}>
|
||||
<td>{c.number}</td>
|
||||
<td>{c.name}</td>
|
||||
<td className="text-zinc-500">{c.email}</td>
|
||||
<td className="font-mono">{c.password}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Nav next={next} canNext={withExt.length > 0} skip="Skip for now" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutingStep({ next }: { next: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
||||
const [dest, setDest] = useState<Record<string, Destination | null>>({});
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
for (const [id, d] of Object.entries(dest))
|
||||
if (d) await didsApi.update(id, { destination: d });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['dids'] });
|
||||
next();
|
||||
},
|
||||
});
|
||||
if (!dids.data) return <Loading q={dids} />;
|
||||
if (!dids.data.length)
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm">No phone numbers yet (step 6).</p>
|
||||
<Nav next={next} />
|
||||
</>
|
||||
);
|
||||
const incomplete = Object.values(dest).some((d) => d === null);
|
||||
return (
|
||||
<>
|
||||
{dids.data.map((d) => (
|
||||
<DestinationPicker
|
||||
key={d.id}
|
||||
label={`When someone calls ${formatNumber(d.number)}${d.label ? ` (${d.label})` : ''}, ring:`}
|
||||
value={dest[d.id] ?? d.destination}
|
||||
onChange={(v) => setDest({ ...dest, [d.id]: v })}
|
||||
/>
|
||||
))}
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={incomplete || save.isPending}
|
||||
>
|
||||
Save and continue
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm">
|
||||
<li>
|
||||
Set up each desk phone or softphone app with the SIP details under Users & Extensions
|
||||
→ Credentials.
|
||||
</li>
|
||||
<li>Call your main number from a mobile phone: the phone you chose in step 8 rings.</li>
|
||||
<li>From a desk phone, call your mobile: it shows your main number as caller ID.</li>
|
||||
<li>Everything here can be changed later in the admin pages.</li>
|
||||
</ul>
|
||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<Button variant="primary" onClick={onFinish} disabled={busy}>
|
||||
Finish
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading({ q }: { q: { isError: boolean; error: Error | null } }): ReactNode {
|
||||
return q.isError ? (
|
||||
<Alert>{q.error!.message}</Alert>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Loading…</p>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router';
|
||||
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
|
||||
import { APP_NAME, type AuthUser } from '@selfphoneng/shared';
|
||||
import { authApi } from '../../api/client.ts';
|
||||
import { authApi, systemApi } from '../../api/client.ts';
|
||||
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
||||
import { Button } from '../../components/ui.tsx';
|
||||
import { subscribeLive, useLive } from '../../live/store.ts';
|
||||
@@ -39,6 +39,8 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
useEffect(subscribeLive, []);
|
||||
const qc = useQueryClient();
|
||||
const nav = useNavigate();
|
||||
// The first-run wizard comes first until it's finished or skipped (05 §2.1).
|
||||
const setup = useQuery({ queryKey: ['setup'], queryFn: systemApi.setupStatus });
|
||||
|
||||
async function logout() {
|
||||
await authApi.logout().catch(() => undefined);
|
||||
@@ -46,6 +48,8 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
nav('/login', { replace: true });
|
||||
}
|
||||
|
||||
if (setup.data && !setup.data.completed) return <Navigate to="/setup" replace />;
|
||||
|
||||
const links = (
|
||||
<nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
|
||||
@@ -52,7 +52,7 @@ function useSave() {
|
||||
});
|
||||
}
|
||||
|
||||
function EmailTab({ s }: { s: SettingsDto }) {
|
||||
export function EmailTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const [v, setV] = useState({
|
||||
host: s.smtp?.host ?? '',
|
||||
@@ -188,7 +188,7 @@ function EmailTab({ s }: { s: SettingsDto }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EmergencyTab({ s }: { s: SettingsDto }) {
|
||||
export function EmergencyTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
|
||||
@@ -154,6 +154,34 @@ function TrunkDrawer({
|
||||
trunk: TrunkDto | null;
|
||||
onClose: () => void;
|
||||
onSaved: (t: TrunkDto) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
drawer
|
||||
onClose={onClose}
|
||||
title={trunk ? `Edit ${trunk.name}` : 'New trunk'}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
<Button variant="primary" type="submit" form="trunk-form">
|
||||
{trunk ? 'Save' : 'Create trunk'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TrunkForm trunk={trunk} onSaved={onSaved} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 [error, setError] = useState<string>();
|
||||
const [saved, setSaved] = useState(false);
|
||||
@@ -215,233 +243,216 @@ function TrunkDrawer({
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
drawer
|
||||
onClose={onClose}
|
||||
title={trunk ? `Edit ${trunk.name}` : 'New trunk'}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>{saved ? 'Close' : 'Cancel'}</Button>
|
||||
<Button variant="primary" type="submit" form="trunk-form">
|
||||
{trunk ? 'Save' : 'Create trunk'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="trunk-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{saved && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved. The phone system picks it up within a few seconds.
|
||||
</p>
|
||||
<form id="trunk-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{saved && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved. The phone system picks it up within a few seconds.
|
||||
</p>
|
||||
)}
|
||||
{trunk && <TestPanel trunk={trunk} />}
|
||||
<Field label="Provider">
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('provider')} className={inputClass}>
|
||||
<option value="voipms">voip.ms</option>
|
||||
<option value="generic">Other SIP provider</option>
|
||||
</select>
|
||||
)}
|
||||
{trunk && <TestPanel trunk={trunk} />}
|
||||
<Field label="Provider">
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('provider')} className={inputClass}>
|
||||
<option value="voipms">voip.ms</option>
|
||||
<option value="generic">Other SIP provider</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{voipms && <VoipmsChecklist />}
|
||||
<Field label="Name" error={errors.name?.message}>
|
||||
{(a) => <input {...a} {...form.register('name')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field
|
||||
label="Server"
|
||||
error={errors.serverHost?.message}
|
||||
hint={voipms ? 'The voip.ms POP your sub-account and numbers use.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<>
|
||||
</Field>
|
||||
{voipms && <VoipmsChecklist />}
|
||||
<Field label="Name" error={errors.name?.message}>
|
||||
{(a) => <input {...a} {...form.register('name')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field
|
||||
label="Server"
|
||||
error={errors.serverHost?.message}
|
||||
hint={voipms ? 'The voip.ms POP your sub-account and numbers use.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<>
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('serverHost')}
|
||||
list={voipms ? 'voipms-servers' : undefined}
|
||||
placeholder={voipms ? 'chicago1.voip.ms' : 'sip.example.com'}
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
<datalist id="voipms-servers">
|
||||
{VOIPMS_SERVERS.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Sign-in">
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('authMode')} className={inputClass}>
|
||||
<option value="registration">Username and password (registration)</option>
|
||||
<option value="ip">IP address (the provider knows this server's IP)</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{authMode === 'registration' && (
|
||||
<>
|
||||
<Field
|
||||
label={voipms ? 'Sub-account username' : 'Username'}
|
||||
error={errors.username?.message}
|
||||
hint={voipms ? 'Like 123456_pbx.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('serverHost')}
|
||||
list={voipms ? 'voipms-servers' : undefined}
|
||||
placeholder={voipms ? 'chicago1.voip.ms' : 'sip.example.com'}
|
||||
{...form.register('username')}
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
<datalist id="voipms-servers">
|
||||
{VOIPMS_SERVERS.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Sign-in">
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
hint={trunk?.hasPassword ? 'Leave empty to keep the current password.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Transport">
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('authMode')} className={inputClass}>
|
||||
<option value="registration">Username and password (registration)</option>
|
||||
<option value="ip">IP address (the provider knows this server's IP)</option>
|
||||
<select
|
||||
{...a}
|
||||
value={transport}
|
||||
onChange={(e) => setTransport(e.target.value as FormValues['transport'])}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="tls">TLS (encrypted)</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{authMode === 'registration' && (
|
||||
<>
|
||||
<Field
|
||||
label={voipms ? 'Sub-account username' : 'Username'}
|
||||
error={errors.username?.message}
|
||||
hint={voipms ? 'Like 123456_pbx.' : undefined}
|
||||
>
|
||||
<Field label="Port" error={errors.serverPort?.message}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('serverPort', { valueAsNumber: true })}
|
||||
type="number"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="text-sm font-medium">Codecs</legend>
|
||||
<div className="flex gap-4">
|
||||
{CODECS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={c} {...form.register('codecs')} className="size-4" />
|
||||
{c === 'ulaw' ? 'G.711 µ-law' : c === 'alaw' ? 'G.711 A-law' : 'G.722 (HD)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.codecs && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{errors.codecs.message}</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<Field
|
||||
label="Max simultaneous calls"
|
||||
error={errors.maxChannels?.message}
|
||||
hint="Empty for no limit."
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('maxChannels')}
|
||||
inputMode="numeric"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register('enabled')} className="size-4" />
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...form.register('srtp')}
|
||||
disabled={transport !== 'tls'}
|
||||
className="size-4"
|
||||
/>
|
||||
Encrypt audio (SRTP, needs TLS)
|
||||
</label>
|
||||
<Field
|
||||
label="From user"
|
||||
error={errors.fromUser?.message}
|
||||
hint="Defaults to the username."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('fromUser')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field
|
||||
label="From domain"
|
||||
error={errors.fromDomain?.message}
|
||||
hint="Defaults to the server."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('fromDomain')} className={inputClass} />}
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{voipms && (
|
||||
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<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">
|
||||
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.
|
||||
</p>
|
||||
<Field label="API username (your voip.ms email)">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('username')}
|
||||
{...form.register('voipmsApiUsername')}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
hint={trunk?.hasPassword ? 'Leave empty to keep the current password.' : undefined}
|
||||
label="API password"
|
||||
hint={trunk?.hasApiPassword ? 'Leave empty to keep the current password.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('password')}
|
||||
{...form.register('voipmsApiPassword')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Transport">
|
||||
{(a) => (
|
||||
<select
|
||||
{...a}
|
||||
value={transport}
|
||||
onChange={(e) => setTransport(e.target.value as FormValues['transport'])}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="tls">TLS (encrypted)</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Port" error={errors.serverPort?.message}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('serverPort', { valueAsNumber: true })}
|
||||
type="number"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="text-sm font-medium">Codecs</legend>
|
||||
<div className="flex gap-4">
|
||||
{CODECS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={c} {...form.register('codecs')} className="size-4" />
|
||||
{c === 'ulaw' ? 'G.711 µ-law' : c === 'alaw' ? 'G.711 A-law' : 'G.722 (HD)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.codecs && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{errors.codecs.message}</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<Field
|
||||
label="Max simultaneous calls"
|
||||
error={errors.maxChannels?.message}
|
||||
hint="Empty for no limit."
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('maxChannels')}
|
||||
inputMode="numeric"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register('enabled')} className="size-4" />
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...form.register('srtp')}
|
||||
disabled={transport !== 'tls'}
|
||||
className="size-4"
|
||||
/>
|
||||
Encrypt audio (SRTP, needs TLS)
|
||||
</label>
|
||||
<Field
|
||||
label="From user"
|
||||
error={errors.fromUser?.message}
|
||||
hint="Defaults to the username."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('fromUser')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field
|
||||
label="From domain"
|
||||
error={errors.fromDomain?.message}
|
||||
hint="Defaults to the server."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('fromDomain')} className={inputClass} />}
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{voipms && (
|
||||
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<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">
|
||||
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.
|
||||
</p>
|
||||
<Field label="API username (your voip.ms email)">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('voipmsApiUsername')}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="API password"
|
||||
hint={
|
||||
trunk?.hasApiPassword ? 'Leave empty to keep the current password.' : undefined
|
||||
}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('voipmsApiPassword')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</form>
|
||||
</Dialog>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user