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

+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
// 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>
);
}