provisioning: Linphone setup QR, "Set up a phone app"
GET /api/extensions/:id/softphone-qr (admin) and /api/me/softphone-qr
return {url, qr, expiresAt}: the link Linphone fetches and its QR as a
PNG data URL (qrcode), valid 24 h. Both are audited like revealing the
SIP password. JSON rather than a bare PNG, so the page can show the
link for desktop Linphone too.
The token is stateless per 07 §5: base64url(extensionId.expiry.hmac),
HMAC-SHA256 with APP_SECRET, checked timing-safe. /prov/linphone/
<token>.xml matches it before the other file names are lower-cased
(the token is case-sensitive); a bad or expired token is a 404 and a
counted failure. auth_info keys checked against liblinphone's config
reader; proxy keys stay (verify).
Web: a "Set up a phone app" section on My settings and in the admin
Credentials tab: Linphone with the QR, MicroSIP/Baresip by hand, and
the plain warning about mobile apps without push. e2e m10-t3: as a
user, the QR's link gives the XML and a baresip phone registers with
only what it holds.
Task: M10-T3
This commit is contained in:
1 parent
6195aca2fb
commit
c1f8b04e7e
17 files changed
+631
-4
No files matched your search
@@ -20,10 +20,12 @@
|
||||
"fastify": "^5.12.7",
|
||||
"nodemailer": "10.0.11",
|
||||
"pg": "^8.23.1",
|
||||
"qrcode": "1.5.4",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/ws": "^8.18.2",
|
||||
"drizzle-kit": "^0.31.11"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M10-T3: the stateless Linphone token (07 §5) and the XML.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { linphoneToken, linphoneXml, verifyLinphoneToken } from './linphone.ts';
|
||||
|
||||
const SECRET = Buffer.alloc(32, 7).toString('base64');
|
||||
const OTHER = Buffer.alloc(32, 8).toString('base64');
|
||||
const ID = '00000000-0000-4000-8000-000000000101';
|
||||
|
||||
describe('linphone token', () => {
|
||||
it('round-trips for 24 h, then expires', () => {
|
||||
const now = Date.UTC(2026, 9, 19, 12);
|
||||
const { token, expiresAt } = linphoneToken(SECRET, ID, now);
|
||||
expect(expiresAt).toBe('2026-10-20T12:00:00.000Z');
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(verifyLinphoneToken(SECRET, token, now + 23 * 3600_000)).toBe(ID);
|
||||
expect(verifyLinphoneToken(SECRET, token, now + 24 * 3600_000 + 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses another secret, a changed id or expiry, and junk', () => {
|
||||
const now = Date.now();
|
||||
const { token } = linphoneToken(SECRET, ID, now);
|
||||
expect(verifyLinphoneToken(OTHER, token, now)).toBeNull();
|
||||
const [id, exp, mac] = Buffer.from(token, 'base64url').toString().split('.');
|
||||
const forge = (s: string) => Buffer.from(s).toString('base64url');
|
||||
expect(
|
||||
verifyLinphoneToken(SECRET, forge(`${ID.replace('101', '102')}.${exp}.${mac}`), now),
|
||||
).toBeNull();
|
||||
expect(
|
||||
verifyLinphoneToken(SECRET, forge(`${id}.${Number(exp) + 3600}.${mac}`), now),
|
||||
).toBeNull();
|
||||
expect(verifyLinphoneToken(SECRET, 'nope', now)).toBeNull();
|
||||
expect(verifyLinphoneToken(SECRET, forge('a.b'), now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('linphone XML', () => {
|
||||
it('proxy and auth sections, values escaped', () => {
|
||||
const x = linphoneXml({
|
||||
host: 'pbx.example.com',
|
||||
sipPort: 5060,
|
||||
transport: 'udp',
|
||||
number: '101',
|
||||
name: 'Dan "D" <Doe>',
|
||||
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
|
||||
});
|
||||
expect(x).toContain(
|
||||
'<entry name="reg_proxy"><sip:pbx.example.com:5060;transport=udp></entry>',
|
||||
);
|
||||
expect(x).toContain(
|
||||
`<entry name="reg_identity">"Dan 'D' <Doe>" <sip:101@pbx.example.com></entry>`,
|
||||
);
|
||||
expect(x).toContain('<entry name="username">101</entry>');
|
||||
expect(x).toContain('<entry name="passwd">Ab3dEf9hIj2kLm4nOp6qRs8t</entry>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Linphone remote provisioning (07 §5): a QR of https://<host>/prov/linphone/<token>.xml. The token
|
||||
// is stateless: base64url(extensionId.expiry.hmac), valid 24 h, HMAC-SHA256 with APP_SECRET.
|
||||
// Regenerating the SIP password makes older configs useless anyway.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import QRCode from 'qrcode';
|
||||
import { xml } from './context.ts';
|
||||
|
||||
const TTL_S = 24 * 3600;
|
||||
const sign = (secret: string, id: string, exp: number) =>
|
||||
createHmac('sha256', Buffer.from(secret, 'base64')).update(`${id}.${exp}`).digest('hex');
|
||||
|
||||
export function linphoneToken(secret: string, extensionId: string, now = Date.now()) {
|
||||
const exp = Math.floor(now / 1000) + TTL_S;
|
||||
return {
|
||||
token: Buffer.from(`${extensionId}.${exp}.${sign(secret, extensionId, exp)}`).toString(
|
||||
'base64url',
|
||||
),
|
||||
expiresAt: new Date(exp * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The extension id in a valid, unexpired token, or null. */
|
||||
export function verifyLinphoneToken(secret: string, token: string, now = Date.now()) {
|
||||
const parts = Buffer.from(token, 'base64url').toString('utf8').split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const [id, expS, mac] = parts as [string, string, string];
|
||||
const exp = Number(expS);
|
||||
if (!/^[0-9a-f-]{36}$/.test(id) || !Number.isInteger(exp) || exp * 1000 < now) return null;
|
||||
const want = Buffer.from(sign(secret, id, exp));
|
||||
const got = Buffer.from(mac);
|
||||
return want.length === got.length && timingSafeEqual(want, got) ? id : null;
|
||||
}
|
||||
|
||||
export type LinphoneAccount = {
|
||||
host: string;
|
||||
sipPort: number;
|
||||
transport: 'udp' | 'tcp' | 'tls';
|
||||
number: string;
|
||||
name: string;
|
||||
sipPassword: string;
|
||||
};
|
||||
|
||||
/** The remote provisioning XML. auth_info keys checked against liblinphone; proxy keys (verify). */
|
||||
export function linphoneXml(a: LinphoneAccount): string {
|
||||
const name = xml(a.name).replace(/"/g, "'");
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<config xmlns="http://www.linphone.org/xsds/lpconfig.xsd">',
|
||||
' <section name="proxy_0">',
|
||||
` <entry name="reg_proxy"><sip:${a.host}:${a.sipPort};transport=${a.transport}></entry>`,
|
||||
` <entry name="reg_identity">"${name}" <sip:${a.number}@${a.host}></entry>`,
|
||||
' <entry name="reg_expires">600</entry>',
|
||||
' <entry name="reg_sendregister">1</entry>',
|
||||
' </section>',
|
||||
' <section name="auth_info_0">',
|
||||
` <entry name="username">${a.number}</entry>`,
|
||||
` <entry name="passwd">${xml(a.sipPassword)}</entry>`,
|
||||
` <entry name="domain">${a.host}</entry>`,
|
||||
' </section>',
|
||||
'</config>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** The link Linphone fetches and its QR (a PNG data URL); both stop working after 24 h. */
|
||||
export async function softphoneQr(secret: string, host: string, extensionId: string) {
|
||||
const { token, expiresAt } = linphoneToken(secret, extensionId);
|
||||
const url = `https://${host}/prov/linphone/${token}.xml`;
|
||||
return { url, qr: await QRCode.toDataURL(url, { margin: 2, width: 280 }), expiresAt };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ExtensionDto,
|
||||
type ListResponse,
|
||||
type SipCredentials,
|
||||
type SoftphoneQr,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, userRuntimeState } from '../db/schema.ts';
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertNumberAvailable, nextFreeNumber } from '../services/numbering.ts';
|
||||
import { assertMohClass } from './moh.ts';
|
||||
import { softphoneQr } from '../provisioning/linphone.ts';
|
||||
import { findReferences } from '../services/destinations.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
@@ -243,4 +245,19 @@ export async function extensionRoutes(app: FastifyInstance) {
|
||||
webUsername: e.webrtcEnabled ? `${e.number}-web` : null,
|
||||
};
|
||||
});
|
||||
|
||||
/** Linphone setup QR (07 §5). It carries the SIP password, so it's audited like a reveal. */
|
||||
app.get('/api/extensions/:id/softphone-qr', async (req): Promise<SoftphoneQr> => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const e = await getExt(db, id);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'reveal_sip_credentials',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
summary: `Made a phone app setup QR for extension ${e.number}`,
|
||||
});
|
||||
return softphoneQr(app.config.APP_SECRET, app.config.PUBLIC_HOSTNAME, id);
|
||||
});
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
type ListResponse,
|
||||
type MeDto,
|
||||
type VoicemailDto,
|
||||
type SoftphoneQr,
|
||||
type WebRtcCreds,
|
||||
} from '@selfphoneng/shared';
|
||||
import { softphoneQr } from '../provisioning/linphone.ts';
|
||||
import { decrypt, encrypt, isTrivialPin } from '../crypto/secrets.ts';
|
||||
import { setDnd, setForward } from '../services/astdb.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
@@ -175,6 +177,21 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
return myState(req.user!.id);
|
||||
});
|
||||
|
||||
/** "Set up a phone app" (07 §5): a Linphone setup QR for my own extension. */
|
||||
app.get('/api/me/softphone-qr', async (req): Promise<SoftphoneQr> => {
|
||||
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
|
||||
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'reveal_sip_credentials',
|
||||
entityType: 'extension',
|
||||
entityId: ext.id,
|
||||
summary: `Made a phone app setup QR for extension ${ext.number}`,
|
||||
});
|
||||
return softphoneQr(app.config.APP_SECRET, app.config.PUBLIC_HOSTNAME, ext.id);
|
||||
});
|
||||
|
||||
app.get('/api/me/webrtc', async (req): Promise<WebRtcCreds> => {
|
||||
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
|
||||
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
|
||||
|
||||
@@ -6,7 +6,9 @@ import { timingSafeEqual } from 'node:crypto';
|
||||
import { BlockList, isIPv4 } from 'node:net';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { devices } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { devices, extensions } from '../db/schema.ts';
|
||||
import { linphoneXml, verifyLinphoneToken } from '../provisioning/linphone.ts';
|
||||
import { loadContext, loadPhonebook, type ProvisionContext } from '../provisioning/context.ts';
|
||||
import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts';
|
||||
import { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
|
||||
@@ -78,7 +80,33 @@ export async function provisioningRoutes(app: FastifyInstance) {
|
||||
return deny(reply, 403, 'Forbidden');
|
||||
if (!allowProvRequest(req.ip, local)) return deny(reply, 429, 'Too many requests');
|
||||
|
||||
const file = (req.params as { '*': string })['*'].toLowerCase();
|
||||
const raw = (req.params as { '*': string })['*'];
|
||||
// Linphone (07 §5): the token is the credential (case matters), no Basic auth.
|
||||
const lin = /^linphone\/([A-Za-z0-9_-]{20,200})\.xml$/.exec(raw);
|
||||
if (lin) {
|
||||
const extId = verifyLinphoneToken(app.config.APP_SECRET, lin[1]!);
|
||||
const [e] = extId ? await db.select().from(extensions).where(eq(extensions.id, extId)) : [];
|
||||
if (!e) {
|
||||
recordProvFailure(req.ip);
|
||||
req.log.warn({ ip: req.ip }, 'provisioning: bad or expired Linphone token');
|
||||
return deny(reply, 404, 'Not found');
|
||||
}
|
||||
req.log.info({ ip: req.ip, ext: e.number }, 'provisioning: Linphone config served');
|
||||
return reply
|
||||
.header('Cache-Control', 'no-store')
|
||||
.type('application/xml')
|
||||
.send(
|
||||
linphoneXml({
|
||||
host: app.config.PUBLIC_HOSTNAME,
|
||||
sipPort: await getSetting(db, 'sip.udpPort'),
|
||||
transport: 'udp',
|
||||
number: e.number,
|
||||
name: e.name,
|
||||
sipPassword: decrypt(e.sipPasswordEnc),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const file = raw.toLowerCase();
|
||||
if (YEALINK_COMMON.test(file))
|
||||
return reply.header('Cache-Control', 'no-store').type('text/plain').send(yealinkCommon());
|
||||
const kind = FILES.map((f) => ({ f, m: f.re.exec(file) })).find((x) => x.m);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { contacts, devices } from '../src/db/schema.ts';
|
||||
import { auditLog, contacts, devices } from '../src/db/schema.ts';
|
||||
import { resetProvLimiter } from '../src/provisioning/limiter.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
@@ -230,6 +230,28 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
|
||||
await setSetting(t.db, 'general.timezone', 'America/Chicago');
|
||||
});
|
||||
|
||||
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
|
||||
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
const { url, qr } = r.json();
|
||||
expect(qr).toMatch(/^data:image\/png;base64,/);
|
||||
expect(url).toMatch(/^https:\/\/pbx\.test\/prov\/linphone\/[A-Za-z0-9_-]+\.xml$/);
|
||||
const path = new URL(url).pathname.slice('/prov/'.length);
|
||||
const x = await prov(path, '203.0.113.20'); // a mobile phone, no Basic auth
|
||||
expect(x.statusCode).toBe(200);
|
||||
expect(x.body).toContain('<entry name="username">101</entry>');
|
||||
expect(x.body).toContain(`<entry name="passwd">${sipPassword}</entry>`);
|
||||
// the token is case-sensitive: a lower-cased one is just a bad token
|
||||
expect((await prov(path.toLowerCase(), '203.0.113.21')).statusCode).toBe(404);
|
||||
expect((await prov('linphone/AAAAAAAAAAAAAAAAAAAAAAAA.xml', '203.0.113.21')).statusCode).toBe(
|
||||
404,
|
||||
);
|
||||
const logged = JSON.stringify(await t.db.select().from(auditLog));
|
||||
expect(logged).toContain('Made a phone app setup QR for extension 101');
|
||||
// a user with no extension gets a 404 from /api/me
|
||||
expect((await req('GET', '/api/me/softphone-qr')).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('delete: the phone gets nothing any more', async () => {
|
||||
expect((await req('DELETE', `/api/devices/${device.id}`)).statusCode).toBe(200);
|
||||
expect(
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
SettingsDto,
|
||||
SipCredentials,
|
||||
SoundDto,
|
||||
SoftphoneQr,
|
||||
MohClassDto,
|
||||
VoipmsDidDto,
|
||||
TimeConditionCreate,
|
||||
@@ -140,6 +141,7 @@ export const usersApi = {
|
||||
export const extensionsApi = {
|
||||
list: () => api<ListResponse<ExtensionDto>>('GET', '/api/extensions').then((r) => r.items),
|
||||
get: (id: string) => api<ExtensionDto>('GET', `/api/extensions/${id}`),
|
||||
softphoneQr: (id: string) => api<SoftphoneQr>('GET', `/api/extensions/${id}/softphone-qr`),
|
||||
create: (e: ExtensionCreate) => api<ExtensionDto>('POST', '/api/extensions', e),
|
||||
update: (id: string, e: ExtensionUpdate) =>
|
||||
api<ExtensionDto>('PATCH', `/api/extensions/${id}`, e),
|
||||
@@ -214,6 +216,7 @@ export const meApi = {
|
||||
calls: (cursor?: string) =>
|
||||
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
|
||||
get: () => api<MeDto>('GET', '/api/me'),
|
||||
softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'),
|
||||
update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m),
|
||||
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
|
||||
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// "Set up a phone app" (07 §5): recommended apps, a Linphone setup QR (24 h), and the plain
|
||||
// truth about mobile apps without push. Used on /me/settings and the admin Credentials tab.
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { SoftphoneQr } from '@selfphoneng/shared';
|
||||
import { Alert, Button } from './ui.tsx';
|
||||
|
||||
export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
|
||||
const qr = useMutation({ mutationFn: load });
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<p>
|
||||
<strong>Linphone</strong> (Android, iPhone, Windows, Mac, Linux) sets itself up from a QR
|
||||
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">
|
||||
<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>
|
||||
{!qr.data ? (
|
||||
<Button className="self-start" onClick={() => qr.mutate()} disabled={qr.isPending}>
|
||||
Show setup QR code
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<img
|
||||
src={qr.data.qr}
|
||||
alt="Linphone setup QR code"
|
||||
width={280}
|
||||
height={280}
|
||||
className="rounded bg-white"
|
||||
/>
|
||||
<p className="font-mono text-xs break-all">{qr.data.url}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
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">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { didsApi, extensionsApi, usersApi } from '../../api/client.ts';
|
||||
import { CLASS_LABEL } from './OutboundPage.tsx';
|
||||
import { Dialog } from '../../components/Dialog.tsx';
|
||||
import { MohOptions } from '../../components/MohOptions.tsx';
|
||||
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials';
|
||||
@@ -440,6 +441,10 @@ 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">
|
||||
<h3 className="mb-2 font-medium">Phone app</h3>
|
||||
<PhoneAppSetup load={() => extensionsApi.softphoneQr(ext.id)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options.
|
||||
// Softphone devices and notifications live in the softphone panel; phone apps (QR) come in M10.
|
||||
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { formatNumber, type MeDto, type MeUpdate } from '@selfphoneng/shared';
|
||||
import { meApi } from '../../api/client.ts';
|
||||
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
/** "102" stays as is; outside numbers get their usual format. */
|
||||
@@ -60,6 +61,12 @@ function SettingsForms({ me }: { me: MeDto }) {
|
||||
<Forwarding me={me} />
|
||||
</section>
|
||||
{me.voicemailEnabled && <Voicemail me={me} />}
|
||||
<section aria-labelledby="app-h" className="flex flex-col gap-3">
|
||||
<h2 id="app-h" className="text-lg font-semibold">
|
||||
Set up a phone app
|
||||
</h2>
|
||||
<PhoneAppSetup load={meApi.softphoneQr} />
|
||||
</section>
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">Password</h2>
|
||||
<Link className="text-sm text-sky-700 underline dark:text-sky-400" to="/change-password">
|
||||
|
||||
Reference in new issue
Block a user