From 1cfc13ead3a63786bcea294dbd4bfa85bc9fccdf Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Sat, 17 Oct 2026 16:35:07 -0500 Subject: [PATCH] extensions: numbering service, CRUD API, credentials, Users page tabs One namespace for internal numbers (range, reserved codes, parking, every object table) with a next-free lookup. Creating an extension generates its SIP password and voicemail PIN and a runtime-state row; every change and password regeneration triggers a config apply. Credentials are revealed only on request and each reveal is audited. Users get an Ext. column and a drawer with General (number + next free), Phone and Credentials tabs. scripts/e2e/m2-t5.mjs checks the flow in a browser and that the generated PJSIP file follows; the files it produced load cleanly in Asterisk. Task: M2-T5 --- apps/api/src/app.ts | 4 + apps/api/src/routes/extensions.ts | 240 +++++++++++++ apps/api/src/routes/users.ts | 30 +- apps/api/src/services/numbering.ts | 77 +++++ apps/api/test/extensions.test.ts | 175 ++++++++++ apps/web/src/api/client.ts | 26 +- apps/web/src/pages/admin/UserDrawer.tsx | 433 ++++++++++++++++++++++++ apps/web/src/pages/admin/UsersPage.tsx | 150 +------- packages/shared/src/extensions.ts | 58 ++++ packages/shared/src/index.ts | 1 + packages/shared/src/users.ts | 1 + scripts/e2e/m2-t5.mjs | 113 +++++++ 12 files changed, 1167 insertions(+), 141 deletions(-) create mode 100644 apps/api/src/routes/extensions.ts create mode 100644 apps/api/src/services/numbering.ts create mode 100644 apps/api/test/extensions.test.ts create mode 100644 apps/web/src/pages/admin/UserDrawer.tsx create mode 100644 packages/shared/src/extensions.ts create mode 100644 scripts/e2e/m2-t5.mjs diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 15844c0..3c2ba64 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -12,10 +12,12 @@ import { errorHandler, forbidden, notFound, sendError } from './errors.ts'; import { authRoutes } from './routes/auth.ts'; import { userRoutes } from './routes/users.ts'; import { systemRoutes } from './routes/system.ts'; +import { extensionRoutes } from './routes/extensions.ts'; declare module 'fastify' { interface FastifyInstance { db: Db; + config: Config; /** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */ generator: Generator; } @@ -43,6 +45,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public')) }); app.decorate('db', db); + app.decorate('config', config); app.decorateRequest('user', null); app.decorateRequest('sessionId', null); app.register(fastifyCookie); @@ -72,6 +75,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public')) app.register(authRoutes); app.register(userRoutes); app.register(systemRoutes); + app.register(extensionRoutes); if (existsSync(publicDir)) { app.register(fastifyStatic, { root: publicDir, wildcard: false }); diff --git a/apps/api/src/routes/extensions.ts b/apps/api/src/routes/extensions.ts new file mode 100644 index 0000000..95d9ce2 --- /dev/null +++ b/apps/api/src/routes/extensions.ts @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// /api/extensions and /api/numbering (04 §7.2, §7.3). Admin only. +import { asc, eq } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + ExtensionCreateSchema, + ExtensionUpdateSchema, + NUMBER_KINDS, + type ExtensionDto, + type ListResponse, + type SipCredentials, +} from '@selfphoneng/shared'; +import type { Db } from '../db/client.ts'; +import { extensions, userRuntimeState } from '../db/schema.ts'; +import { badRequest, conflict, notFound } from '../errors.ts'; +import { requireAdmin } from '../auth/guards.ts'; +import { + decrypt, + encrypt, + generatePin, + generateSipPassword, + isTrivialPin, +} from '../crypto/secrets.ts'; +import { audit } from '../services/audit.ts'; +import { assertNumberAvailable, nextFreeNumber } from '../services/numbering.ts'; +import { getSetting } from '../services/settings.ts'; + +type ExtRow = typeof extensions.$inferSelect; + +export const toExtensionDto = (e: ExtRow): ExtensionDto => ({ + id: e.id, + number: e.number, + userId: e.userId, + name: e.name, + webrtcEnabled: e.webrtcEnabled, + maxContacts: e.maxContacts, + ringSeconds: e.ringSeconds, + voicemailEnabled: e.voicemailEnabled, + voicemailEmail: e.voicemailEmail, + voicemailAttachAudio: e.voicemailAttachAudio, + voicemailDeleteAfterEmail: e.voicemailDeleteAfterEmail, + outboundClass: e.outboundClass, + outboundCallerIdDidId: e.outboundCallerIdDidId, + emergencyCallerIdDidId: e.emergencyCallerIdDidId, + emergencyLocation: e.emergencyLocation, + recordMode: e.recordMode, + pickupGroup: e.pickupGroup, + language: e.language, + mohClass: e.mohClass, + blfNumbers: e.blfNumbers, + createdAt: e.createdAt.toISOString(), +}); + +const IdParams = z.object({ id: z.uuid() }); + +const isUniqueViolation = (e: unknown, constraint?: string) => { + const err = + (e as { cause?: { code?: string; constraint?: string } }).cause ?? + (e as { code?: string; constraint?: string }); + return err?.code === '23505' && (!constraint || err.constraint?.includes(constraint)); +}; + +function checkPin(pin: string, number: string) { + if (isTrivialPin(pin, number)) + throw badRequest( + 'That PIN is too easy to guess (repeated, sequential or the extension number)', + ); +} + +/** extensions.user_id is unique: one extension per user. */ +async function assertUserFree(db: Db, userId: string | null | undefined, excludeId?: string) { + if (!userId) return; + const [e] = await db + .select({ id: extensions.id, number: extensions.number }) + .from(extensions) + .where(eq(extensions.userId, userId)); + if (e && e.id !== excludeId) throw conflict(`That user already has extension ${e.number}`); +} + +async function getExt(db: Db, id: string): Promise { + const [e] = await db.select().from(extensions).where(eq(extensions.id, id)); + if (!e) throw notFound('Extension not found'); + return e; +} + +export async function extensionRoutes(app: FastifyInstance) { + const { db } = app; + app.addHook('preHandler', requireAdmin); + + app.get('/api/numbering/next', async (req) => { + const { kind } = z.object({ kind: z.enum(NUMBER_KINDS) }).parse(req.query); + return { number: await nextFreeNumber(db, kind) }; + }); + + // ponytail: one page with every extension (v1 targets ≤100); cursor paging when needed. + app.get('/api/extensions', async (): Promise> => { + const rows = await db.select().from(extensions).orderBy(asc(extensions.number)); + return { items: rows.map(toExtensionDto), nextCursor: null }; + }); + + app.get('/api/extensions/:id', async (req) => { + const { id } = IdParams.parse(req.params); + return toExtensionDto(await getExt(db, id)); + }); + + app.post('/api/extensions', async (req, reply) => { + const { voicemailPin, ...body } = ExtensionCreateSchema.parse(req.body); + await assertNumberAvailable(db, body.number, 'user'); + await assertUserFree(db, body.userId); + if (voicemailPin) checkPin(voicemailPin, body.number); + let created: ExtRow; + try { + created = await db.transaction(async (tx) => { + const [e] = await tx + .insert(extensions) + .values({ + ...body, + sipPasswordEnc: encrypt(generateSipPassword()), + voicemailPinEnc: encrypt(voicemailPin ?? generatePin()), + }) + .returning(); + await tx.insert(userRuntimeState).values({ extensionId: e!.id }); + return e!; + }); + } catch (e) { + if (isUniqueViolation(e, 'user_id')) throw conflict('That user already has an extension'); + if (isUniqueViolation(e)) throw conflict(`${body.number} is already taken`); + throw e; + } + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'create', + entityType: 'extension', + entityId: created.id, + summary: `Created extension ${created.number} (${created.name})`, + diff: { after: body }, + }); + app.generator.requestApply('api-change'); + return reply.code(201).send(toExtensionDto(created)); + }); + + app.patch('/api/extensions/:id', async (req) => { + const { id } = IdParams.parse(req.params); + const { voicemailPin, ...body } = ExtensionUpdateSchema.parse(req.body); + const before = await getExt(db, id); + if (body.number && body.number !== before.number) + await assertNumberAvailable(db, body.number, 'user', id); + await assertUserFree(db, body.userId, id); + if (voicemailPin) checkPin(voicemailPin, body.number ?? before.number); + let after: ExtRow; + try { + [after] = (await db + .update(extensions) + .set({ ...body, ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}) }) + .where(eq(extensions.id, id)) + .returning()) as [ExtRow]; + } catch (e) { + if (isUniqueViolation(e, 'user_id')) throw conflict('That user already has an extension'); + throw e; + } + const changed = (Object.keys(body) as (keyof typeof body)[]).filter( + (k) => JSON.stringify(before[k]) !== JSON.stringify(after[k]), + ); + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'update', + entityType: 'extension', + entityId: id, + summary: `Updated extension ${after.number}${voicemailPin ? ' (voicemail PIN changed)' : ''}${changed.length ? ` (${changed.join(', ')})` : ''}`, + diff: { + before: Object.fromEntries(changed.map((k) => [k, before[k]])), + after: Object.fromEntries(changed.map((k) => [k, after[k]])), + }, + }); + app.generator.requestApply('api-change'); + return toExtensionDto(after); + }); + + app.delete('/api/extensions/:id', async (req) => { + const { id } = IdParams.parse(req.params); + const e = await getExt(db, id); + // TODO(M3-T2): refuse with 409 when a destination points here (findReferences, 02 §1). + await db.delete(extensions).where(eq(extensions.id, id)); // members, runtime state, devices cascade + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'delete', + entityType: 'extension', + entityId: id, + summary: `Deleted extension ${e.number} (${e.name})`, + diff: { before: toExtensionDto(e) }, + }); + app.generator.requestApply('api-change'); + return { ok: true }; + }); + + app.post('/api/extensions/:id/regenerate-sip-password', async (req) => { + const { id } = IdParams.parse(req.params); + const e = await getExt(db, id); + await db + .update(extensions) + .set({ sipPasswordEnc: encrypt(generateSipPassword()) }) + .where(eq(extensions.id, id)); + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'regenerate_sip_password', + entityType: 'extension', + entityId: id, + summary: `New SIP password for extension ${e.number}; its phones must be updated`, + }); + app.generator.requestApply('api-change'); + return { ok: true }; + }); + + // Reveal (05 §1): only on an explicit click, and every reveal is audited. + app.get('/api/extensions/:id/sip-credentials', async (req): Promise => { + 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: `Viewed the SIP password of extension ${e.number}`, + }); + return { + server: app.config.PUBLIC_HOSTNAME, + port: await getSetting(db, 'sip.udpPort'), + transport: 'udp', + username: e.number, + password: decrypt(e.sipPasswordEnc), + webUsername: e.webrtcEnabled ? `${e.number}-web` : null, + }; + }); +} diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 3ece8ac..9fbf6e0 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -10,7 +10,7 @@ import { type UserDto, } from '@selfphoneng/shared'; import type { Db } from '../db/client.ts'; -import { users } from '../db/schema.ts'; +import { extensions, users } from '../db/schema.ts'; import { badRequest, conflict, notFound } from '../errors.ts'; import { requireAdmin } from '../auth/guards.ts'; import { hashPassword } from '../auth/passwords.ts'; @@ -20,7 +20,7 @@ import { audit } from '../services/audit.ts'; type UserRow = typeof users.$inferSelect; -export const toUserDto = (u: UserRow): UserDto => ({ +export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({ id: u.id, email: u.email, displayName: u.displayName, @@ -29,8 +29,17 @@ export const toUserDto = (u: UserRow): UserDto => ({ mustChangePassword: u.mustChangePassword, lastLoginAt: u.lastLoginAt?.toISOString() ?? null, createdAt: u.createdAt.toISOString(), + extension, }); +const extOf = async (db: Db, userId: string) => { + const [e] = await db + .select({ id: extensions.id, number: extensions.number }) + .from(extensions) + .where(eq(extensions.userId, userId)); + return e ?? null; +}; + const IdParams = z.object({ id: z.uuid() }); /** 16 random letters/digits: never in the common list, and the user must change it at first login. */ @@ -61,13 +70,22 @@ export async function userRoutes(app: FastifyInstance) { // ponytail: one page with every user (v1 targets ≤100 users); cursor paging when it's needed. app.get('/api/users', async (): Promise> => { - const rows = await db.select().from(users).orderBy(asc(users.displayName), asc(users.id)); - return { items: rows.map(toUserDto), nextCursor: null }; + const rows = await db + .select({ u: users, extId: extensions.id, extNumber: extensions.number }) + .from(users) + .leftJoin(extensions, eq(extensions.userId, users.id)) + .orderBy(asc(users.displayName), asc(users.id)); + return { + items: rows.map((r) => + toUserDto(r.u, r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null), + ), + nextCursor: null, + }; }); app.get('/api/users/:id', async (req) => { const { id } = IdParams.parse(req.params); - return toUserDto(await getUser(db, id)); + return toUserDto(await getUser(db, id), await extOf(db, id)); }); app.post('/api/users', async (req, reply) => { @@ -131,7 +149,7 @@ export async function userRoutes(app: FastifyInstance) { after: Object.fromEntries(changed.map((k) => [k, after[k]])), }, }); - return toUserDto(after); + return toUserDto(after, await extOf(db, id)); }); app.delete('/api/users/:id', async (req) => { diff --git a/apps/api/src/services/numbering.ts b/apps/api/src/services/numbering.ts new file mode 100644 index 0000000..826ab60 --- /dev/null +++ b/apps/api/src/services/numbering.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// One namespace for every internal number (00 §7, 04 §7.2). +import { and, eq, ne } from 'drizzle-orm'; +import { isReservedNumber, type NumberKind } from '@selfphoneng/shared'; +import type { Db } from '../db/client.ts'; +import { conferences, extensions, ivrs, pagingGroups, queues, ringGroups } from '../db/schema.ts'; +import { badRequest, conflict } from '../errors.ts'; +import { getSetting } from './settings.ts'; + +const RANGE_KEY = { + user: 'numbering.users', + ringGroup: 'numbering.ringGroups', + queue: 'numbering.queues', + ivr: 'numbering.ivrs', + conference: 'numbering.conferences', + paging: 'numbering.paging', +} as const; + +const OWNERS = [ + { table: extensions, label: 'extension' }, + { table: ringGroups, label: 'ring group' }, + { table: queues, label: 'queue' }, + { table: ivrs, label: 'IVR' }, + { table: conferences, label: 'conference room' }, + { table: pagingGroups, label: 'paging group' }, +] as const; + +const PARKING = { from: 700, to: 720 }; // res_parking.conf: 700 + slots 701–720 + +/** Who holds `number` right now (ignoring row `excludeId`), or null. */ +async function ownerOf(db: Db, number: string, excludeId?: string): Promise { + const n = Number(number); + if (n >= PARKING.from && n <= PARKING.to) return 'parking'; + for (const { table, label } of OWNERS) { + const [row] = await db + .select({ id: table.id, name: table.name }) + .from(table) + .where( + excludeId + ? and(eq(table.number, number), ne(table.id, excludeId)) + : eq(table.number, number), + ) + .limit(1); + if (row) return `${label} "${row.name}"`; + } + return null; +} + +export async function assertNumberAvailable( + db: Db, + number: string, + kind: NumberKind, + excludeId?: string, +): Promise { + if (!/^\d{3,6}$/.test(number)) throw badRequest('Internal numbers have 3 to 6 digits'); + if (isReservedNumber(number)) + throw badRequest(`${number} is reserved (emergency and service codes can't be used)`); + const [from, to] = await getSetting(db, RANGE_KEY[kind]); + const n = Number(number); + if (n < from || n > to) throw badRequest(`Use a number from ${from} to ${to}`); + const owner = await ownerOf(db, number, excludeId); + if (owner) throw conflict(`${number} is already used by ${owner}`, { owner }); +} + +/** Lowest free number in the kind's range, or null when the range is full. */ +export async function nextFreeNumber(db: Db, kind: NumberKind): Promise { + const [from, to] = await getSetting(db, RANGE_KEY[kind]); + const taken = new Set(); + for (const { table } of OWNERS) + for (const r of await db.select({ number: table.number }).from(table)) taken.add(r.number); + for (let n = from; n <= to; n++) { + const s = String(n); + if (n >= PARKING.from && n <= PARKING.to) continue; + if (!taken.has(s) && !isReservedNumber(s)) return s; + } + return null; +} diff --git a/apps/api/test/extensions.test.ts b/apps/api/test/extensions.test.ts new file mode 100644 index 0000000..66cf66a --- /dev/null +++ b/apps/api/test/extensions.test.ts @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// M2-T5: numbering rules, extensions CRUD, credentials, and every change reaching the generator. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { asc, eq } from 'drizzle-orm'; +import type { LightMyRequestResponse } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { auditLog, extensions, userRuntimeState } from '../src/db/schema.ts'; +import { decrypt } from '../src/crypto/secrets.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +describe.skipIf(!hasTestDb)('extensions API', () => { + let t: Awaited>; + let admin: string; + let req: ( + method: string, + url: string, + payload?: unknown, + cookie?: string, + ) => Promise; + const pjsip = () => readFileSync(join(t.genDir, 'pjsip_selfphoneng.conf'), 'utf8'); + const settle = () => new Promise((r) => setTimeout(r, 150)); // debounce is 20 ms in tests + let userId: string; + let extId: string; + + beforeAll(async () => { + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + admin = await t.login(ADMIN.email, ADMIN.password); + req = (method, url, payload, cookie = admin) => + t.app.inject({ + method: method as 'GET', + url, + payload: payload as object, + headers: { ...H, cookie }, + }); + userId = (await t.addUser('dan@example.com', 'dan-password-123')).id; + }); + afterAll(() => t?.close()); + + it('suggests the lowest free number in the users range', async () => { + expect((await req('GET', '/api/numbering/next?kind=user')).json()).toEqual({ number: '100' }); + expect((await req('GET', '/api/numbering/next?kind=queue')).json()).toEqual({ number: '550' }); + expect((await req('GET', '/api/numbering/next?kind=nope')).statusCode).toBe(400); + }); + + it('creating an extension generates secrets, a runtime row, and PJSIP config within the debounce', async () => { + const r = await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId }); + expect(r.statusCode).toBe(201); + const dto = r.json(); + extId = dto.id; + expect(dto).toMatchObject({ number: '101', userId, webrtcEnabled: true, ringSeconds: 25 }); + expect(Object.keys(dto).filter((k) => /Enc$|password|Pin$/i.test(k))).toEqual([]); + + const [row] = await t.db.select().from(extensions).where(eq(extensions.id, extId)); + expect(decrypt(row!.sipPasswordEnc)).toMatch(/^[A-Za-z0-9]{24}$/); + expect(decrypt(row!.voicemailPinEnc)).toMatch(/^\d{6}$/); + expect(await t.db.select().from(userRuntimeState)).toHaveLength(1); + + await settle(); + expect(pjsip()).toContain('[101]\ntype = endpoint'); + expect(pjsip()).toContain('[101-web]\ntype = endpoint'); + expect(t.ami.commands).toContain('pjsip reload'); + }); + + it('enforces the numbering rules', async () => { + const make = (number: string, extra = {}) => + req('POST', '/api/extensions', { number, name: 'X', ...extra }); + expect((await make('211')).json().error.message).toMatch(/reserved/); + expect((await make('600')).json().error.message).toMatch(/from 100 to 499/); + expect((await make('12')).statusCode).toBe(400); + expect((await make('710')).statusCode).toBe(400); // outside the users range anyway + const dup = await make('101'); + expect(dup.statusCode).toBe(409); + expect(dup.json().error.message).toMatch(/extension "Dan Doe"/); + expect((await make('102', { userId })).json().error.message).toMatch( + /already has extension 101/, + ); + expect((await req('GET', '/api/numbering/next?kind=user')).json().number).toBe('100'); + }); + + it('shows the extension on the user', async () => { + const users = (await req('GET', '/api/users')).json().items; + expect(users.find((u: { id: string }) => u.id === userId).extension).toEqual({ + id: extId, + number: '101', + }); + }); + + it('reveals SIP credentials only on request, and audits it', async () => { + const r = await req('GET', `/api/extensions/${extId}/sip-credentials`); + expect(r.json()).toMatchObject({ + server: 'pbx.test', + port: 5060, + username: '101', + webUsername: '101-web', + }); + expect(pjsip()).toContain(`password = ${r.json().password}`); + const [a] = await t.db + .select() + .from(auditLog) + .where(eq(auditLog.action, 'reveal_sip_credentials')); + expect(a).toMatchObject({ entityId: extId }); + expect(JSON.stringify(a)).not.toContain(r.json().password); + }); + + it('regenerates the SIP password and the config follows', async () => { + const old = (await req('GET', `/api/extensions/${extId}/sip-credentials`)).json().password; + expect((await req('POST', `/api/extensions/${extId}/regenerate-sip-password`)).statusCode).toBe( + 200, + ); + await settle(); + const now = (await req('GET', `/api/extensions/${extId}/sip-credentials`)).json().password; + expect(now).not.toBe(old); + expect(pjsip()).toContain(`password = ${now}`); + expect(pjsip()).not.toContain(old); + }); + + it('updates fields, renumbers, and rejects trivial PINs', async () => { + expect( + (await req('PATCH', `/api/extensions/${extId}`, { voicemailPin: '101101' })).statusCode, + ).toBe(400); + expect( + (await req('PATCH', `/api/extensions/${extId}`, { voicemailPin: '123456' })).statusCode, + ).toBe(400); + expect( + (await req('PATCH', `/api/extensions/${extId}`, { pickupGroup: 'Sales!' })).statusCode, + ).toBe(400); + const r = await req('PATCH', `/api/extensions/${extId}`, { + number: '102', + webrtcEnabled: false, + pickupGroup: 'sales', + voicemailPin: '482913', + }); + expect(r.statusCode).toBe(200); + expect(r.json()).toMatchObject({ number: '102', webrtcEnabled: false, pickupGroup: 'sales' }); + await settle(); + expect(pjsip()).toContain('[102]\ntype = endpoint'); + expect(pjsip()).not.toContain('[101]'); + expect(pjsip()).not.toContain('102-web'); + expect(pjsip()).toContain('named_pickup_group = sales'); + }); + + it('non-admins get 403', async () => { + await t.addUser('bob@example.com', 'bob-password-123'); + const bob = await t.login('bob@example.com', 'bob-password-123'); + expect((await req('GET', '/api/extensions', undefined, bob)).statusCode).toBe(403); + expect( + (await req('GET', `/api/extensions/${extId}/sip-credentials`, undefined, bob)).statusCode, + ).toBe(403); + }); + + it('deletes the extension and removes it from the config', async () => { + expect((await req('DELETE', `/api/extensions/${extId}`)).statusCode).toBe(200); + await settle(); + expect(pjsip()).not.toContain('[102]'); + const actions = ( + await t.db + .select({ a: auditLog.action }) + .from(auditLog) + .where(eq(auditLog.entityId, extId)) + .orderBy(asc(auditLog.id)) + ).map((r) => r.a); + expect(actions).toEqual([ + 'create', + 'reveal_sip_credentials', + 'reveal_sip_credentials', + 'regenerate_sip_password', + 'reveal_sip_credentials', + 'update', + 'delete', + ]); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3c8317f..5aaf21b 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,6 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2). -import type { AuthUser, ListResponse, UserCreate, UserDto, UserUpdate } from '@selfphoneng/shared'; +import type { + AuthUser, + ExtensionCreate, + ExtensionDto, + ExtensionUpdate, + ListResponse, + NumberKind, + SipCredentials, + UserCreate, + UserDto, + UserUpdate, +} from '@selfphoneng/shared'; export class ApiError extends Error { readonly status: number; @@ -50,3 +61,16 @@ export const usersApi = { resetPassword: (id: string) => api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`), }; + +export const extensionsApi = { + get: (id: string) => api('GET', `/api/extensions/${id}`), + create: (e: ExtensionCreate) => api('POST', '/api/extensions', e), + update: (id: string, e: ExtensionUpdate) => + api('PATCH', `/api/extensions/${id}`, e), + remove: (id: string) => api<{ ok: true }>('DELETE', `/api/extensions/${id}`), + credentials: (id: string) => api('GET', `/api/extensions/${id}/sip-credentials`), + regenerateSipPassword: (id: string) => + api<{ ok: true }>('POST', `/api/extensions/${id}/regenerate-sip-password`), + nextNumber: (kind: NumberKind) => + api<{ number: string | null }>('GET', `/api/numbering/next?kind=${kind}`).then((r) => r.number), +}; diff --git a/apps/web/src/pages/admin/UserDrawer.tsx b/apps/web/src/pages/admin/UserDrawer.tsx new file mode 100644 index 0000000..1e867a4 --- /dev/null +++ b/apps/web/src/pages/admin/UserDrawer.tsx @@ -0,0 +1,433 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// User + extension editor (05 §2). Tabs so far: General, Phone, Credentials. +// Voicemail (M4), Calling and Emergency (M3) and Forwarding (M6) arrive with their features. +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState, type ReactNode } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { + ExtensionUpdateSchema, + InternalNumber, + RoleSchema, + type ExtensionDto, + type SipCredentials, + type UserDto, +} from '@selfphoneng/shared'; +import { extensionsApi, usersApi } from '../../api/client.ts'; +import { Dialog } from '../../components/Dialog.tsx'; +import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; + +type Tab = 'general' | 'phone' | 'credentials'; + +const GeneralSchema = z.object({ + displayName: z.string().trim().min(1, 'Required').max(100), + email: z.email('Enter a valid email'), + role: RoleSchema, + isActive: z.boolean(), + number: z.union([z.literal(''), InternalNumber]), +}); +type GeneralValues = z.infer; + +export function UserDrawer({ + user, + isSelf, + onClose, + onSaved, + onResetPassword, +}: { + user: UserDto | null; + isSelf: boolean; + onClose: () => void; + onSaved: (created?: { email: string; password: string }) => void; + onResetPassword: (u: UserDto) => void; +}) { + const [tab, setTab] = useState('general'); + const ext = useQuery({ + queryKey: ['extension', user?.extension?.id], + queryFn: () => extensionsApi.get(user!.extension!.id), + enabled: Boolean(user?.extension), + }); + const tabs: { id: Tab; label: string; enabled: boolean }[] = [ + { id: 'general', label: 'General', enabled: true }, + { id: 'phone', label: 'Phone', enabled: Boolean(ext.data) }, + { id: 'credentials', label: 'Credentials', enabled: Boolean(ext.data) }, + ]; + + return ( + + + {tab !== 'credentials' && ( + + )} + + } + > +
+ {tabs.map((t) => ( + + ))} +
+ {tab === 'general' && ( + + )} + {tab === 'phone' && ext.data && onSaved()} />} + {tab === 'credentials' && ext.data && } +
+ ); +} + +function GeneralTab({ + user, + ext, + isSelf, + onSaved, + onResetPassword, +}: { + user: UserDto | null; + ext: ExtensionDto | null; + isSelf: boolean; + onSaved: (created?: { email: string; password: string }) => void; + onResetPassword: (u: UserDto) => void; +}) { + const [error, setError] = useState(); + const form = useForm({ + resolver: zodResolver(GeneralSchema), + defaultValues: { + displayName: user?.displayName ?? '', + email: user?.email ?? '', + role: user?.role ?? 'user', + isActive: user?.isActive ?? true, + number: user?.extension?.number ?? '', + }, + }); + const { errors } = form.formState; + + async function nextFree() { + try { + const n = await extensionsApi.nextNumber('user'); + if (n) form.setValue('number', n, { shouldValidate: true }); + else setError('The users number range is full'); + } catch (e) { + setError((e as Error).message); + } + } + + const onSubmit = form.handleSubmit(async (v) => { + setError(undefined); + try { + let created: { email: string; password: string } | undefined; + let userId = user?.id; + if (user) await usersApi.update(user.id, v); + else { + const r = await usersApi.create({ + displayName: v.displayName, + email: v.email, + role: v.role, + }); + userId = r.user.id; + created = { email: r.user.email, password: r.temporaryPassword }; + } + // The user's extension: create it, or renumber it. + if (v.number && !ext) + await extensionsApi.create({ number: v.number, name: v.displayName, userId }); + else if (ext && v.number && v.number !== ext.number) + await extensionsApi.update(ext.id, { number: v.number }); + onSaved(created); + } catch (e) { + setError((e as Error).message); + } + }); + + return ( +
+ {error && {error}} + + {(a) => } + + + {(a) => } + + + {(a) => ( + + )} + + + {(a) => ( +
+ + {!ext && ( + + )} +
+ )} +
+ {user && ( + + )} + {user && ( +
+ +

+ Creates a new temporary password and signs them out everywhere. +

+
+ )} +
+ ); +} + +const PhoneSchema = ExtensionUpdateSchema.pick({ + name: true, + ringSeconds: true, + webrtcEnabled: true, + maxContacts: true, + language: true, + mohClass: true, +}) + .required() + .extend({ + pickupGroup: z.union([ + z.literal(''), + z.string().regex(/^[a-z0-9-]{1,32}$/, 'Lowercase letters, digits and dashes'), + ]), + blfNumbers: z + .string() + .regex(/^(\s*\d{3,6}\s*(,\s*\d{3,6}\s*)*)?$/, 'Internal numbers separated by commas'), + }); +type PhoneValues = z.input; + +function PhoneTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) { + const qc = useQueryClient(); + const [error, setError] = useState(); + const form = useForm>({ + resolver: zodResolver(PhoneSchema), + defaultValues: { + name: ext.name, + ringSeconds: ext.ringSeconds, + webrtcEnabled: ext.webrtcEnabled, + maxContacts: ext.maxContacts, + language: ext.language, + mohClass: ext.mohClass, + pickupGroup: ext.pickupGroup ?? '', + blfNumbers: ext.blfNumbers.join(', '), + }, + }); + const { errors } = form.formState; + const num = (label: string, field: 'ringSeconds' | 'maxContacts', hint: string) => ( + + {(a) => ( + + )} + + ); + + const onSubmit = form.handleSubmit(async (v) => { + setError(undefined); + try { + await extensionsApi.update(ext.id, { + ...v, + pickupGroup: v.pickupGroup || null, + blfNumbers: v.blfNumbers + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }); + await qc.invalidateQueries({ queryKey: ['extension', ext.id] }); + onSaved(); + } catch (e) { + setError((e as Error).message); + } + }); + + return ( +
+ {error && {error}} + + {(a) => } + + {num( + 'Ring time (seconds)', + 'ringSeconds', + 'How long their phones ring before voicemail (5–120).', + )} + {num( + 'Max devices', + 'maxContacts', + 'Desk phones and apps registered at the same time (1–10).', + )} + + + {(a) => ( + + )} + + + {(a) => } + +
+ + {(a) => } + + + {(a) => } + +
+
+ ); +} + +function CredentialsTab({ ext }: { ext: ExtensionDto }) { + const [creds, setCreds] = useState(null); + const [error, setError] = useState(); + const [confirming, setConfirming] = useState(false); + const [done, setDone] = useState(false); + + const reveal = async () => { + setError(undefined); + try { + setCreds(await extensionsApi.credentials(ext.id)); + } catch (e) { + setError((e as Error).message); + } + }; + const regenerate = async () => { + setError(undefined); + try { + await extensionsApi.regenerateSipPassword(ext.id); + setConfirming(false); + setDone(true); + if (creds) await reveal(); + } catch (e) { + setError((e as Error).message); + } + }; + const row = (label: string, value: ReactNode) => ( +
+
{label}
+
{value}
+
+ ); + + return ( +
+ {error && {error}} +

+ Use these to set up a desk phone or softphone by hand. Viewing the password is recorded in + the audit log. +

+
+ {row( + 'Server', + creds ? `${creds.server}:${creds.port} (${creds.transport.toUpperCase()})` : '••••', + )} + {row('Username', ext.number)} + {row('Password', creds ? creds.password : '••••••••••••••••••••••••')} + {ext.webrtcEnabled && row('Browser', `${ext.number}-web (used by the built-in softphone)`)} +
+ {!creds && ( + + )} +
+ {!confirming ? ( + + ) : ( +
+

Every phone using the old password stops working until you update it. Continue?

+
+ + +
+
+ )} + {done &&

New password saved.

} +
+
+ ); +} diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx index 657dd6a..0443640 100644 --- a/apps/web/src/pages/admin/UsersPage.tsx +++ b/apps/web/src/pages/admin/UsersPage.tsx @@ -1,22 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// Users & Extensions (05 §2). M1: the General tab only; extension tabs arrive in M2. -import { zodResolver } from '@hookform/resolvers/zod'; +// Users & Extensions (05 §2). import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { RoleSchema, type UserDto } from '@selfphoneng/shared'; +import type { UserDto } from '@selfphoneng/shared'; import { usersApi } from '../../api/client.ts'; import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx'; -import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; - -const FormSchema = z.object({ - displayName: z.string().trim().min(1, 'Required').max(100), - email: z.email('Enter a valid email'), - role: RoleSchema, - isActive: z.boolean(), -}); -type FormValues = z.infer; +import { Alert, Button } from '../../components/ui.tsx'; +import { UserDrawer } from './UserDrawer.tsx'; const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never'); @@ -85,6 +75,8 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {

Delete {deleting?.displayName} ({deleting?.email})? They will be signed out and can no longer log in. + {deleting?.extension && + ` Extension ${deleting.extension.number} stays, without a user.`}

} confirmLabel="Delete" @@ -165,7 +157,14 @@ function UsersTable({
  • -

    {u.displayName}

    +

    + {u.displayName} + {u.extension && ( + + {u.extension.number} + + )} +

    {u.email}

    {badge(u)} @@ -180,6 +179,7 @@ function UsersTable({ + @@ -192,6 +192,7 @@ function UsersTable({ {users.map((u) => ( + @@ -204,122 +205,3 @@ function UsersTable({ ); } - -function UserDrawer({ - user, - isSelf, - onClose, - onSaved, - onResetPassword, -}: { - user: UserDto | null; - isSelf: boolean; - onClose: () => void; - onSaved: (created?: { email: string; password: string }) => void; - onResetPassword: (u: UserDto) => void; -}) { - const [error, setError] = useState(); - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: user - ? { - displayName: user.displayName, - email: user.email, - role: user.role, - isActive: user.isActive, - } - : { displayName: '', email: '', role: 'user', isActive: true }, - }); - const { errors, isSubmitting } = form.formState; - - const onSubmit = form.handleSubmit(async (v) => { - setError(undefined); - try { - if (user) { - await usersApi.update(user.id, v); - onSaved(); - } else { - const r = await usersApi.create({ - displayName: v.displayName, - email: v.email, - role: v.role, - }); - onSaved({ email: r.user.email, password: r.temporaryPassword }); - } - } catch (e) { - setError((e as Error).message); - } - }); - - return ( - - - - - } - > - {/* Tabs (05 §2): General now; Phone, Voicemail, Calling, Emergency, Credentials and - Forwarding arrive with extensions in M2. */} -
    - - General - -
    -
    - {error && {error}} - - {(a) => } - - - {(a) => } - - - {(a) => ( - - )} - - {user && ( - - )} -

    Extension number and phone settings: coming in M2.

    - {user && ( -
    - -

    - Creates a new temporary password and signs them out everywhere. -

    -
    - )} - -
    - ); -} diff --git a/packages/shared/src/extensions.ts b/packages/shared/src/extensions.ts new file mode 100644 index 0000000..ca6e033 --- /dev/null +++ b/packages/shared/src/extensions.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Extension (the telephony identity of a user) as the API accepts and returns it (02, 04 §7.3). +// The patterns match the generator's sanitizers (03 §2), so valid input always renders. +import { z } from 'zod'; + +export const InternalNumber = z.string().regex(/^\d{3,6}$/, 'Use 3 to 6 digits'); +const Slug = z.string().regex(/^[a-z0-9-]{1,32}$/, 'Lowercase letters, digits and dashes'); +const Pin = z.string().regex(/^\d{4,10}$/, 'Use 4 to 10 digits'); + +const ExtensionFields = z.object({ + number: InternalNumber, + userId: z.uuid().nullable(), + name: z.string().trim().min(1, 'Required').max(40), + webrtcEnabled: z.boolean(), + maxContacts: z.number().int().min(1).max(10), + ringSeconds: z.number().int().min(5).max(120), + voicemailEnabled: z.boolean(), + voicemailPin: Pin, // write-only + voicemailEmail: z.boolean(), + voicemailAttachAudio: z.boolean(), + voicemailDeleteAfterEmail: z.boolean(), + outboundClass: z.enum(['internal', 'local', 'national', 'international']), + outboundCallerIdDidId: z.uuid().nullable(), + emergencyCallerIdDidId: z.uuid().nullable(), + emergencyLocation: z.string().trim().max(200).nullable(), + recordMode: z.enum(['never', 'inbound', 'outbound', 'all']), + pickupGroup: Slug.nullable(), + language: z.string().regex(/^[a-z]{2,3}$/, 'A language code like en'), + mohClass: Slug, + blfNumbers: z.array(InternalNumber).max(32), +}); + +export const ExtensionCreateSchema = ExtensionFields.partial().required({ + number: true, + name: true, +}); +export const ExtensionUpdateSchema = ExtensionFields.partial(); + +export type ExtensionCreate = z.input; +export type ExtensionUpdate = z.input; + +export type ExtensionDto = Omit, 'voicemailPin'> & { + id: string; + createdAt: string; +}; + +export type SipCredentials = { + server: string; + port: number; + transport: 'udp' | 'tcp'; + username: string; + password: string; + /** Browser softphone identity (when webrtc is on). */ + webUsername: string | null; +}; + +export const NUMBER_KINDS = ['user', 'ringGroup', 'queue', 'ivr', 'conference', 'paging'] as const; +export type NumberKind = (typeof NUMBER_KINDS)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2a216c3..9ee0bc3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,3 +5,4 @@ export * from './destination.ts'; export * from './settings.ts'; export * from './auth.ts'; export * from './users.ts'; +export * from './extensions.ts'; diff --git a/packages/shared/src/users.ts b/packages/shared/src/users.ts index d7efeb5..e16ec30 100644 --- a/packages/shared/src/users.ts +++ b/packages/shared/src/users.ts @@ -31,6 +31,7 @@ export type UserDto = { mustChangePassword: boolean; lastLoginAt: string | null; createdAt: string; + extension: { id: string; number: string } | null; }; export type ListResponse = { items: T[]; nextCursor: string | null }; diff --git a/scripts/e2e/m2-t5.mjs b/scripts/e2e/m2-t5.mjs new file mode 100644 index 0000000..11b52e8 --- /dev/null +++ b/scripts/e2e/m2-t5.mjs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// M2-T5 browser flow: create a user with the next free extension, edit the Phone tab, reveal and +// regenerate SIP credentials, and check the generated PJSIP file follows every step. +// Usage: node m2-t5.mjs +// (the admin must already have changed the initial password; run m1-t4.mjs first on a fresh DB) +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { launch } from './cdp.mjs'; + +const [base, email, pw, confDir, shotsDir] = process.argv.slice(2); +const pjsip = () => readFileSync(join(confDir, 'pjsip_selfphoneng.conf'), 'utf8'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const until = async (fn, what, ms = 5000) => { + for (const end = Date.now() + ms; ; await sleep(100)) { + if ( + (() => { + try { + return fn(); + } catch { + return false; + } + })() + ) + return; // file may not exist yet + if (Date.now() > end) throw new Error(`timeout: ${what}`); + } +}; + +const p = await launch({ shotsDir }); +try { + await p.goto(`${base}/login`); + await p.waitFor(`!!document.querySelector('input[type=email]')`); + await p.fill('Email', email); + await p.fill('Password', pw); + await p.click('Sign in'); + await p.waitFor(`location.pathname === '/admin'`); + await p.click('Users & Extensions'); + await p.waitFor(`document.body.innerText.includes('Ext.')`, 'users table'); + + // create with the next free number + await p.click('New user'); + await p.waitFor(`!!document.querySelector('dialog[open]')`); + await p.fill('Name', 'Carol Diaz'); + await p.fill('Email', 'carol@example.com'); + await p.click('Next free'); + await p.waitFor( + `[...document.querySelectorAll('dialog[open] input')].some(i => i.value === '100')`, + 'next free = 100', + ); + await p.shot('m2-01-new-user'); + const t0 = Date.now(); + await p.click('Create user'); + await p.waitFor(`document.body.innerText.includes('Temporary password')`, 'temp password'); + await p.click('Done'); + await until(() => pjsip().includes('[100]\ntype = endpoint'), 'endpoint 100 in pjsip config'); + console.log(`endpoint 100 written ${Date.now() - t0} ms after clicking Create`); + await p.waitFor(`document.body.innerText.includes('Carol Diaz')`); + + // Phone tab + await p.click('Edit Carol Diaz'); + await p.waitFor( + `[...document.querySelectorAll('[role=tab]')].some(t => t.textContent === 'Phone' && !t.disabled)`, + 'phone tab enabled', + ); + await p.click('Phone'); + await p.waitFor(`document.body.innerText.includes('Ring time')`); + await p.fill('Ring time (seconds)', '30'); + await p.fill('Pickup group', 'sales'); + await p.evaluate( + `(() => { const c = [...document.querySelectorAll('dialog[open] input[type=checkbox]')].find(i => i.closest('label').textContent.includes('Browser softphone')); c.click(); return true; })()`, + ); + await p.shot('m2-02-phone-tab'); + await p.click('Save'); + await until( + () => pjsip().includes('named_pickup_group = sales') && !pjsip().includes('[100-web]'), + 'phone settings in config', + ); + + // Credentials tab + await p.click('Edit Carol Diaz'); + await p.waitFor( + `[...document.querySelectorAll('[role=tab]')].some(t => t.textContent === 'Credentials' && !t.disabled)`, + ); + await p.click('Credentials'); + await p.click('Reveal password'); + await p.waitFor( + `/[A-Za-z0-9]{24}/.test(document.querySelector('dialog[open]').innerText)`, + 'password shown', + ); + const pass1 = await p.evaluate( + `document.querySelector('dialog[open]').innerText.match(/[A-Za-z0-9]{24}/)[0]`, + ); + assert.ok(pjsip().includes(`password = ${pass1}`), 'revealed password is the configured one'); + await p.shot('m2-03-credentials'); + await p.click('New SIP password'); + await p.click('Yes, make a new password'); + await p.waitFor(`document.body.innerText.includes('New password saved.')`); + await p.waitFor( + `!document.querySelector('dialog[open]').innerText.includes('${pass1}')`, + 'new password shown', + ); + await until(() => !pjsip().includes(pass1), 'old password gone from config'); + + assert.deepEqual(p.consoleErrors, [], 'no console errors'); + console.log('M2-T5 browser flow: PASS'); +} catch (e) { + await p.shot('m2-FAILED').catch(() => {}); + console.error('FAIL:', e.message, '\nconsole:', p.consoleErrors); + process.exitCode = 1; +} finally { + await p.close(); +}
    Ext. Name Email Role
    {u.extension?.number ?? '–'} {u.displayName} {u.email} {badge(u)}