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
This commit is contained in:
iamdoubz committed 2026-10-17 16:35:07 -05:00
1 parent a4da81cf8f
commit 1cfc13ead3
12 files changed
+1167 -141

No files matched your search

+4
View File
@@ -12,10 +12,12 @@ import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts'; import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts'; import { userRoutes } from './routes/users.ts';
import { systemRoutes } from './routes/system.ts'; import { systemRoutes } from './routes/system.ts';
import { extensionRoutes } from './routes/extensions.ts';
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
db: Db; db: Db;
config: Config;
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */ /** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator; generator: Generator;
} }
@@ -43,6 +45,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
}); });
app.decorate('db', db); app.decorate('db', db);
app.decorate('config', config);
app.decorateRequest('user', null); app.decorateRequest('user', null);
app.decorateRequest('sessionId', null); app.decorateRequest('sessionId', null);
app.register(fastifyCookie); app.register(fastifyCookie);
@@ -72,6 +75,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(authRoutes); app.register(authRoutes);
app.register(userRoutes); app.register(userRoutes);
app.register(systemRoutes); app.register(systemRoutes);
app.register(extensionRoutes);
if (existsSync(publicDir)) { if (existsSync(publicDir)) {
app.register(fastifyStatic, { root: publicDir, wildcard: false }); app.register(fastifyStatic, { root: publicDir, wildcard: false });
+240
View File
@@ -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<ExtRow> {
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<ListResponse<ExtensionDto>> => {
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<SipCredentials> => {
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,
};
});
}
+24 -6
View File
@@ -10,7 +10,7 @@ import {
type UserDto, type UserDto,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts'; 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 { badRequest, conflict, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts'; import { requireAdmin } from '../auth/guards.ts';
import { hashPassword } from '../auth/passwords.ts'; import { hashPassword } from '../auth/passwords.ts';
@@ -20,7 +20,7 @@ import { audit } from '../services/audit.ts';
type UserRow = typeof users.$inferSelect; type UserRow = typeof users.$inferSelect;
export const toUserDto = (u: UserRow): UserDto => ({ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({
id: u.id, id: u.id,
email: u.email, email: u.email,
displayName: u.displayName, displayName: u.displayName,
@@ -29,8 +29,17 @@ export const toUserDto = (u: UserRow): UserDto => ({
mustChangePassword: u.mustChangePassword, mustChangePassword: u.mustChangePassword,
lastLoginAt: u.lastLoginAt?.toISOString() ?? null, lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
createdAt: u.createdAt.toISOString(), 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() }); 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. */ /** 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. // ponytail: one page with every user (v1 targets ≤100 users); cursor paging when it's needed.
app.get('/api/users', async (): Promise<ListResponse<UserDto>> => { app.get('/api/users', async (): Promise<ListResponse<UserDto>> => {
const rows = await db.select().from(users).orderBy(asc(users.displayName), asc(users.id)); const rows = await db
return { items: rows.map(toUserDto), nextCursor: null }; .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) => { app.get('/api/users/:id', async (req) => {
const { id } = IdParams.parse(req.params); 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) => { 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]])), 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) => { app.delete('/api/users/:id', async (req) => {
+77
View File
@@ -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<string | null> {
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<void> {
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<string | null> {
const [from, to] = await getSetting(db, RANGE_KEY[kind]);
const taken = new Set<string>();
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;
}
+175
View File
@@ -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<ReturnType<typeof createTestApp>>;
let admin: string;
let req: (
method: string,
url: string,
payload?: unknown,
cookie?: string,
) => Promise<LightMyRequestResponse>;
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',
]);
});
});
+25 -1
View File
@@ -1,6 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2). // 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 { export class ApiError extends Error {
readonly status: number; readonly status: number;
@@ -50,3 +61,16 @@ export const usersApi = {
resetPassword: (id: string) => resetPassword: (id: string) =>
api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`), api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`),
}; };
export const extensionsApi = {
get: (id: string) => api<ExtensionDto>('GET', `/api/extensions/${id}`),
create: (e: ExtensionCreate) => api<ExtensionDto>('POST', '/api/extensions', e),
update: (id: string, e: ExtensionUpdate) =>
api<ExtensionDto>('PATCH', `/api/extensions/${id}`, e),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/extensions/${id}`),
credentials: (id: string) => api<SipCredentials>('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),
};
+433
View File
@@ -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<typeof GeneralSchema>;
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<Tab>('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 (
<Dialog
open
drawer
onClose={onClose}
title={user ? `Edit ${user.displayName}` : 'New user'}
footer={
<>
<Button onClick={onClose}>{tab === 'credentials' ? 'Close' : 'Cancel'}</Button>
{tab !== 'credentials' && (
<Button variant="primary" type="submit" form={`user-${tab}-form`}>
{user ? 'Save' : 'Create user'}
</Button>
)}
</>
}
>
<div
role="tablist"
aria-label="User settings"
className="mb-4 flex gap-1 border-b border-zinc-200 dark:border-zinc-800"
>
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
disabled={!t.enabled}
title={t.enabled ? undefined : 'Give the user an extension number first'}
onClick={() => setTab(t.id)}
className={`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium disabled:cursor-not-allowed disabled:text-zinc-400 ${tab === t.id ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`}
>
{t.label}
</button>
))}
</div>
{tab === 'general' && (
<GeneralTab
user={user}
ext={ext.data ?? null}
isSelf={isSelf}
onSaved={onSaved}
onResetPassword={onResetPassword}
/>
)}
{tab === 'phone' && ext.data && <PhoneTab ext={ext.data} onSaved={() => onSaved()} />}
{tab === 'credentials' && ext.data && <CredentialsTab ext={ext.data} />}
</Dialog>
);
}
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<string>();
const form = useForm<GeneralValues>({
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 (
<form id="user-general-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field label="Name" error={errors.displayName?.message}>
{(a) => <input {...a} {...form.register('displayName')} className={inputClass} />}
</Field>
<Field label="Email" error={errors.email?.message}>
{(a) => <input {...a} {...form.register('email')} type="email" className={inputClass} />}
</Field>
<Field
label="Role"
error={errors.role?.message}
hint={
isSelf ? 'You cannot remove your own admin role if you are the last admin.' : undefined
}
>
{(a) => (
<select {...a} {...form.register('role')} className={inputClass}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
)}
</Field>
<Field
label="Extension number"
error={errors.number?.message}
hint={
ext
? 'Changing it renumbers their phones and voicemail box.'
: 'Leave empty for a user without a phone.'
}
>
{(a) => (
<div className="flex gap-2">
<input {...a} {...form.register('number')} inputMode="numeric" className={inputClass} />
{!ext && (
<Button onClick={nextFree} className="shrink-0">
Next free
</Button>
)}
</div>
)}
</Field>
{user && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
{...form.register('isActive')}
className="size-4"
disabled={isSelf}
/>
Active (inactive users cannot sign in)
</label>
)}
{user && (
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<Button onClick={() => onResetPassword(user)}>Reset password</Button>
<p className="mt-1 text-xs text-zinc-500">
Creates a new temporary password and signs them out everywhere.
</p>
</div>
)}
</form>
);
}
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<typeof PhoneSchema>;
function PhoneTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) {
const qc = useQueryClient();
const [error, setError] = useState<string>();
const form = useForm<PhoneValues, unknown, z.output<typeof PhoneSchema>>({
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) => (
<Field label={label} error={errors[field]?.message} hint={hint}>
{(a) => (
<input
{...a}
{...form.register(field, { valueAsNumber: true })}
type="number"
className={inputClass}
/>
)}
</Field>
);
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 (
<form id="user-phone-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field
label="Caller ID name"
error={errors.name?.message}
hint="Shown on the phones they call."
>
{(a) => <input {...a} {...form.register('name')} className={inputClass} />}
</Field>
{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).',
)}
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register('webrtcEnabled')} className="size-4" />
Browser softphone
</label>
<Field
label="Pickup group"
error={errors.pickupGroup?.message}
hint="Phones in the same group can answer each other with *8."
>
{(a) => (
<input
{...a}
{...form.register('pickupGroup')}
placeholder="sales"
className={inputClass}
/>
)}
</Field>
<Field
label="BLF keys"
error={errors.blfNumbers?.message}
hint="Extensions whose status shows on their phones, e.g. 101, 102."
>
{(a) => <input {...a} {...form.register('blfNumbers')} className={inputClass} />}
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Language" error={errors.language?.message}>
{(a) => <input {...a} {...form.register('language')} className={inputClass} />}
</Field>
<Field label="Music on hold" error={errors.mohClass?.message}>
{(a) => <input {...a} {...form.register('mohClass')} className={inputClass} />}
</Field>
</div>
</form>
);
}
function CredentialsTab({ ext }: { ext: ExtensionDto }) {
const [creds, setCreds] = useState<SipCredentials | null>(null);
const [error, setError] = useState<string>();
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) => (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="w-28 shrink-0 text-zinc-500">{label}</dt>
<dd className="font-mono break-all">{value}</dd>
</div>
);
return (
<div className="flex flex-col gap-4 text-sm">
{error && <Alert>{error}</Alert>}
<p className="text-zinc-600 dark:text-zinc-400">
Use these to set up a desk phone or softphone by hand. Viewing the password is recorded in
the audit log.
</p>
<dl className="flex flex-col gap-2">
{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)`)}
</dl>
{!creds && (
<Button onClick={reveal} className="self-start">
Reveal password
</Button>
)}
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
{!confirming ? (
<Button onClick={() => setConfirming(true)}>New SIP password</Button>
) : (
<div className="flex flex-col gap-2">
<p>Every phone using the old password stops working until you update it. Continue?</p>
<div className="flex gap-2">
<Button variant="danger" onClick={regenerate}>
Yes, make a new password
</Button>
<Button onClick={() => setConfirming(false)}>Cancel</Button>
</div>
</div>
)}
{done && <p className="mt-2 text-green-700 dark:text-green-400">New password saved.</p>}
</div>
</div>
);
}
+16 -134
View File
@@ -1,22 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Users & Extensions (05 §2). M1: the General tab only; extension tabs arrive in M2. // Users & Extensions (05 §2).
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import type { UserDto } from '@selfphoneng/shared';
import { z } from 'zod';
import { RoleSchema, type UserDto } from '@selfphoneng/shared';
import { usersApi } from '../../api/client.ts'; import { usersApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx'; import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; import { Alert, Button } from '../../components/ui.tsx';
import { UserDrawer } from './UserDrawer.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<typeof FormSchema>;
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never'); const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never');
@@ -85,6 +75,8 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
<p> <p>
Delete <strong>{deleting?.displayName}</strong> ({deleting?.email})? They will be signed Delete <strong>{deleting?.displayName}</strong> ({deleting?.email})? They will be signed
out and can no longer log in. out and can no longer log in.
{deleting?.extension &&
` Extension ${deleting.extension.number} stays, without a user.`}
</p> </p>
} }
confirmLabel="Delete" confirmLabel="Delete"
@@ -165,7 +157,14 @@ function UsersTable({
<li key={u.id} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"> <li key={u.id} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium">{u.displayName}</p> <p className="font-medium">
{u.displayName}
{u.extension && (
<span className="ml-2 font-mono text-sm text-zinc-500">
{u.extension.number}
</span>
)}
</p>
<p className="truncate text-sm text-zinc-500">{u.email}</p> <p className="truncate text-sm text-zinc-500">{u.email}</p>
</div> </div>
{badge(u)} {badge(u)}
@@ -180,6 +179,7 @@ function UsersTable({
<table className="hidden w-full text-left text-sm md:table"> <table className="hidden w-full text-left text-sm md:table">
<thead className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800"> <thead className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<tr> <tr>
<th className="py-2 font-medium">Ext.</th>
<th className="py-2 font-medium">Name</th> <th className="py-2 font-medium">Name</th>
<th className="py-2 font-medium">Email</th> <th className="py-2 font-medium">Email</th>
<th className="py-2 font-medium">Role</th> <th className="py-2 font-medium">Role</th>
@@ -192,6 +192,7 @@ function UsersTable({
<tbody> <tbody>
{users.map((u) => ( {users.map((u) => (
<tr key={u.id} className="border-b border-zinc-100 dark:border-zinc-800/60"> <tr key={u.id} className="border-b border-zinc-100 dark:border-zinc-800/60">
<td className="py-2 font-mono">{u.extension?.number ?? '–'}</td>
<td className="py-2 font-medium">{u.displayName}</td> <td className="py-2 font-medium">{u.displayName}</td>
<td className="py-2">{u.email}</td> <td className="py-2">{u.email}</td>
<td className="py-2">{badge(u)}</td> <td className="py-2">{badge(u)}</td>
@@ -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<string>();
const form = useForm<FormValues>({
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 (
<Dialog
open
drawer
onClose={onClose}
title={user ? `Edit ${user.displayName}` : 'New user'}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" type="submit" form="user-form" disabled={isSubmitting}>
{user ? 'Save' : 'Create user'}
</Button>
</>
}
>
{/* Tabs (05 §2): General now; Phone, Voicemail, Calling, Emergency, Credentials and
Forwarding arrive with extensions in M2. */}
<div role="tablist" className="mb-4 flex gap-1 border-b border-zinc-200 dark:border-zinc-800">
<span
role="tab"
aria-selected="true"
className="border-b-2 border-sky-600 px-3 py-1.5 text-sm font-medium"
>
General
</span>
</div>
<form id="user-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field label="Name" error={errors.displayName?.message}>
{(a) => <input {...a} {...form.register('displayName')} className={inputClass} />}
</Field>
<Field label="Email" error={errors.email?.message}>
{(a) => <input {...a} {...form.register('email')} type="email" className={inputClass} />}
</Field>
<Field
label="Role"
error={errors.role?.message}
hint={
isSelf ? 'You cannot remove your own admin role if you are the last admin.' : undefined
}
>
{(a) => (
<select {...a} {...form.register('role')} className={inputClass}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
)}
</Field>
{user && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
{...form.register('isActive')}
className="size-4"
disabled={isSelf}
/>
Active (inactive users cannot sign in)
</label>
)}
<p className="text-xs text-zinc-500">Extension number and phone settings: coming in M2.</p>
{user && (
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<Button onClick={() => onResetPassword(user)}>Reset password</Button>
<p className="mt-1 text-xs text-zinc-500">
Creates a new temporary password and signs them out everywhere.
</p>
</div>
)}
</form>
</Dialog>
);
}
+58
View File
@@ -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<typeof ExtensionCreateSchema>;
export type ExtensionUpdate = z.input<typeof ExtensionUpdateSchema>;
export type ExtensionDto = Omit<z.output<typeof ExtensionFields>, '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];
+1
View File
@@ -5,3 +5,4 @@ export * from './destination.ts';
export * from './settings.ts'; export * from './settings.ts';
export * from './auth.ts'; export * from './auth.ts';
export * from './users.ts'; export * from './users.ts';
export * from './extensions.ts';
+1
View File
@@ -31,6 +31,7 @@ export type UserDto = {
mustChangePassword: boolean; mustChangePassword: boolean;
lastLoginAt: string | null; lastLoginAt: string | null;
createdAt: string; createdAt: string;
extension: { id: string; number: string } | null;
}; };
export type ListResponse<T> = { items: T[]; nextCursor: string | null }; export type ListResponse<T> = { items: T[]; nextCursor: string | null };
+113
View File
@@ -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 <baseUrl> <adminEmail> <adminPassword> <asteriskConfigDir> <shotsDir>
// (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();
}