// SPDX-License-Identifier: AGPL-3.0-or-later // User + extension editor (05 §2). Tabs so far: General, Phone, Calling, Emergency, Credentials. // Emergency too. Voicemail (M4) 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, OUTBOUND_CLASSES, formatNumber, InternalNumber, RoleSchema, type ExtensionDto, type SipCredentials, type UserDto, } from '@selfphoneng/shared'; import { didsApi, extensionsApi, usersApi } from '../../api/client.ts'; import { CLASS_LABEL } from './OutboundPage.tsx'; import { Dialog } from '../../components/Dialog.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials'; const GeneralSchema = z.object({ displayName: z.string().trim().min(1, 'Required').max(100), email: z.email('Enter a valid email'), role: RoleSchema, isActive: z.boolean(), number: z.union([z.literal(''), InternalNumber]), }); type GeneralValues = z.infer; export function UserDrawer({ user, isSelf, onClose, onSaved, onResetPassword, }: { user: UserDto | null; isSelf: boolean; onClose: () => void; onSaved: (created?: { email: string; password: string }) => void; onResetPassword: (u: UserDto) => void; }) { const [tab, setTab] = useState('general'); const ext = useQuery({ queryKey: ['extension', user?.extension?.id], queryFn: () => extensionsApi.get(user!.extension!.id), enabled: Boolean(user?.extension), }); const tabs: { id: Tab; label: string; enabled: boolean }[] = [ { id: 'general', label: 'General', enabled: true }, { id: 'phone', label: 'Phone', enabled: Boolean(ext.data) }, { id: 'calling', label: 'Calling', enabled: Boolean(ext.data) }, { id: 'emergency', label: 'Emergency', enabled: Boolean(ext.data) }, { id: 'credentials', label: 'Credentials', enabled: Boolean(ext.data) }, ]; return ( {tab !== 'credentials' && ( )} } >
{tabs.map((t) => ( ))}
{tab === 'general' && ( )} {tab === 'phone' && ext.data && onSaved()} />} {tab === 'calling' && ext.data && onSaved()} />} {tab === 'emergency' && ext.data && onSaved()} />} {tab === 'credentials' && ext.data && }
); } function GeneralTab({ user, ext, isSelf, onSaved, onResetPassword, }: { user: UserDto | null; ext: ExtensionDto | null; isSelf: boolean; onSaved: (created?: { email: string; password: string }) => void; onResetPassword: (u: UserDto) => void; }) { const [error, setError] = useState(); const form = useForm({ resolver: zodResolver(GeneralSchema), defaultValues: { displayName: user?.displayName ?? '', email: user?.email ?? '', role: user?.role ?? 'user', isActive: user?.isActive ?? true, number: user?.extension?.number ?? '', }, }); const { errors } = form.formState; async function nextFree() { try { const n = await extensionsApi.nextNumber('user'); if (n) form.setValue('number', n, { shouldValidate: true }); else setError('The users number range is full'); } catch (e) { setError((e as Error).message); } } const onSubmit = form.handleSubmit(async (v) => { setError(undefined); try { let created: { email: string; password: string } | undefined; let userId = user?.id; if (user) await usersApi.update(user.id, v); else { const r = await usersApi.create({ displayName: v.displayName, email: v.email, role: v.role, }); userId = r.user.id; created = { email: r.user.email, password: r.temporaryPassword }; } // The user's extension: create it, or renumber it. if (v.number && !ext) await extensionsApi.create({ number: v.number, name: v.displayName, userId }); else if (ext && v.number && v.number !== ext.number) await extensionsApi.update(ext.id, { number: v.number }); onSaved(created); } catch (e) { setError((e as Error).message); } }); return (
{error && {error}} {(a) => } {(a) => } {(a) => ( )} {(a) => (
{!ext && ( )}
)}
{user && ( )} {user && (

Creates a new temporary password and signs them out everywhere.

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

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

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

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

)} {done &&

New password saved.

}
); } function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) { const qc = useQueryClient(); const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list }); const [error, setError] = useState(); const form = useForm({ defaultValues: { outboundClass: ext.outboundClass, outboundCallerIdDidId: ext.outboundCallerIdDidId ?? '', }, }); const onSubmit = form.handleSubmit(async (v) => { setError(undefined); try { await extensionsApi.update(ext.id, { outboundClass: v.outboundClass, outboundCallerIdDidId: v.outboundCallerIdDidId || null, }); await qc.invalidateQueries({ queryKey: ['extension', ext.id] }); onSaved(); } catch (e) { setError((e as Error).message); } }); return (
{error && {error}} {(a) => ( )} {(a) => ( )}
); } function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) { const qc = useQueryClient(); const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list }); const [error, setError] = useState(); const form = useForm({ defaultValues: { emergencyCallerIdDidId: ext.emergencyCallerIdDidId ?? '', emergencyLocation: ext.emergencyLocation ?? '', }, }); const onSubmit = form.handleSubmit(async (v) => { setError(undefined); try { await extensionsApi.update(ext.id, { emergencyCallerIdDidId: v.emergencyCallerIdDidId || null, emergencyLocation: v.emergencyLocation.trim() || null, }); await qc.invalidateQueries({ queryKey: ['extension', ext.id] }); onSaved(); } catch (e) { setError((e as Error).message); } }); return (
{error && {error}} {(a) => ( )} {(a) => }
); }