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:
1 parent
a4da81cf8f
commit
1cfc13ead3
12 files changed
+1167
-141
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<ListResponse<UserDto>> => {
|
||||
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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<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),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof FormSchema>;
|
||||
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 }) {
|
||||
<p>
|
||||
Delete <strong>{deleting?.displayName}</strong> ({deleting?.email})? They will be signed
|
||||
out and can no longer log in.
|
||||
{deleting?.extension &&
|
||||
` Extension ${deleting.extension.number} stays, without a user.`}
|
||||
</p>
|
||||
}
|
||||
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">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<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>
|
||||
</div>
|
||||
{badge(u)}
|
||||
@@ -180,6 +179,7 @@ function UsersTable({
|
||||
<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">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Ext.</th>
|
||||
<th className="py-2 font-medium">Name</th>
|
||||
<th className="py-2 font-medium">Email</th>
|
||||
<th className="py-2 font-medium">Role</th>
|
||||
@@ -192,6 +192,7 @@ function UsersTable({
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<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">{u.email}</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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -5,3 +5,4 @@ export * from './destination.ts';
|
||||
export * from './settings.ts';
|
||||
export * from './auth.ts';
|
||||
export * from './users.ts';
|
||||
export * from './extensions.ts';
|
||||
@@ -31,6 +31,7 @@ export type UserDto = {
|
||||
mustChangePassword: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
extension: { id: string; number: string } | null;
|
||||
};
|
||||
|
||||
export type ListResponse<T> = { items: T[]; nextCursor: string | null };
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user