/setup walks a fresh install to working calls: password (done by the forced change), company, network (detect public IP, LAN IP from /api/system/status, router checklist; restarts telephony because the transports changed), email (skippable), voip.ms trunk (Next unlocks on Registered), phone numbers, users (extension auto-numbered, temporary passwords shown once), main number routing, emergency (default E911 caller ID required once a trunk exists), done. Steps 7-8 ship now because the acceptance needs a user and a routed number; their M6 choices (everyone, IVR) come later. Admins land on /setup until it is finished or skipped. api: /api/setup/status and /complete, POST /api/settings/network/detect-public-ip (ipify, then ifconfig.me), GET /api/system/status (M3 subset), POST /api/system/asterisk/restart (an AMI disconnect during a restart counts as success). restartRequired clears on AMI connect once Asterisk's uptime shows it started after the change. The first phone number becomes the default outbound caller ID when none is set (found by the acceptance run: calls otherwise left with the provider's caller ID). Verified: scripts/e2e/m3-t5.mjs on a fresh database against the fake voip.ms: wizard end to end, then an inbound call to the DID rings the new user's softphone and an outbound call leaves as 13125550000 with the DID in Remote-Party-ID. m1-t4 now passes through "Skip setup". Task: M3-T5
165 lines
14 KiB
Markdown
165 lines
14 KiB
Markdown
# 05 — Web UI (apps/web)
|
||
|
||
One React app with two areas: **Admin** (`/admin/*`, admins only) and **My
|
||
Phone** (`/me/*`, every user, admins included). The browser softphone is part
|
||
of My Phone and stays docked on every `/me` page.
|
||
|
||
## 1. Global rules
|
||
|
||
- React 19 + TypeScript strict + Vite + react-router 7 + TanStack Query + Tailwind 4.
|
||
- A typed API client in `src/api/` generated from the zod schemas in
|
||
`packages/shared` (write the wrappers by hand; no codegen tool). Every
|
||
request sends `X-Requested-With: selfphoneng` and `credentials: 'same-origin'`.
|
||
- Forms validate with the **same zod schemas** as the API (`@hookform/resolvers/zod` + `react-hook-form`; add both to the allowed deps).
|
||
- Works at 360 px wide. Admin tables become stacked cards on small screens.
|
||
- Light and dark mode follow `prefers-color-scheme`, with a manual toggle stored in `localStorage` (wrapped in try/catch).
|
||
- Accessibility: every control has a label, focus is visible, the dialpad works from the keyboard, and the incoming-call dialog traps focus.
|
||
- Show phone numbers formatted (`(312) 555-1234`). Inputs accept any format and normalize on blur.
|
||
- Every destructive action uses a confirm dialog. Delete errors of type 409 show the "in use by" list from the API.
|
||
- Live data comes from the `/api/live` WebSocket (`src/live/`), stored in a small
|
||
Zustand store (add `zustand` to the allowed deps). The client reconnects with backoff and shows a
|
||
thin top banner "Reconnecting…" while disconnected.
|
||
- Never show a secret unless the user clicks "Reveal", which calls the explicit reveal endpoint.
|
||
|
||
## 2. Admin area
|
||
|
||
Left sidebar navigation, in this order:
|
||
|
||
| Page | Path | Contents |
|
||
|---|---|---|
|
||
| Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down) |
|
||
| Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable) |
|
||
| Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" |
|
||
| Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed |
|
||
| Outbound rules | `/admin/outbound` | Sortable list (drag to reorder). Form: name, patterns (rows of match/strip/prepend with a pattern help popover), required class, trunk order (multi-select with order), caller ID override. **Test box**: pick an extension + type a number → shows the matching route, final dialed number, trunk, caller ID, or the reason it's denied |
|
||
| Ring groups | `/admin/ring-groups` | Form: number, name, strategy, ring seconds, members (ordered picker), music instead of ring, caller-name prefix, no-answer DestinationPicker |
|
||
| Queues | `/admin/queues` | Form: every column from 02. Detail page shows live waiting callers + agent list with pause/unpause buttons + today's stats |
|
||
| IVRs (Digital receptionist) | `/admin/ivrs` | Form: number, name, greeting (SoundPicker), keypad grid 0–9 * # where each key gets a DestinationPicker or "unused", timeout, retries, invalid sound, direct dial toggle, timeout and invalid destinations |
|
||
| Office hours | `/admin/hours` | Time conditions: weekly grid editor (click-drag hours per day) that saves to `open_ranges`; timezone; holidays toggle; open/closed/holiday destinations; override digit; big status chip "Open now / Closed now" with Force open / Force closed / Automatic buttons. Holidays sub-tab: list + "Add US federal holidays for {year}" |
|
||
| Conferences | `/admin/conferences` | number, name, PIN, admin PIN, max members, record |
|
||
| Paging | `/admin/paging` | number, name, members, duplex |
|
||
| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files |
|
||
| Phones (devices) | `/admin/devices` | Table: MAC, vendor, model, user, last provisioned. Form: MAC (any format → normalized), vendor, model, extension. Shows the provisioning URL to type into the phone, and "Preview config" |
|
||
| Contacts | `/admin/contacts` | Company phonebook, CSV import |
|
||
| Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording |
|
||
| Recordings | `/admin/recordings` | Searchable list, play, download, delete |
|
||
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table |
|
||
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
|
||
| Blocked numbers | `/admin/blocked` | |
|
||
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens |
|
||
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log |
|
||
|
||
### 2.1 First-run setup wizard (`/setup`)
|
||
|
||
Shown to admins while `setup.completed` is false. Each step saves when you go
|
||
to the next one. Steps can be skipped where noted.
|
||
|
||
1. Change the initial admin password.
|
||
2. Company name, timezone (default from the browser), language.
|
||
3. Network: "Detect public IP", LAN IP (prefilled from `/api/system/status` if detectable), local nets.
|
||
A big note about SIP ALG and port forwarding (01 §4.2) with a checkbox "I've done this".
|
||
4. Email (SMTP), skippable. Test button.
|
||
5. Trunk: voip.ms form (06 §2), with the voip.ms portal checklist next to it. "Test" must show Registered before Next is enabled. Skippable for now ("I'll add it later").
|
||
6. Phone numbers: Import from voip.ms (if API creds were given) or add manually.
|
||
7. Users: quick table to add up to 10 users (name, email, extension auto-numbered, temporary password generated and shown once).
|
||
8. Main number routing: "When someone calls {DID}, ring:" DestinationPicker (common choices first: a user, everyone (creates a ring group of all users), an IVR (creates a simple one)).
|
||
9. Emergency: default E911 caller ID DID (required if a trunk exists), notification emails. Shows the 06 §6 text about registering the address at voip.ms.
|
||
10. Done: shows "Dial *43 from any phone for an echo test" and links to the softphone.
|
||
|
||
Implementation notes (M3):
|
||
- Admins are sent to `/setup` until `setup.completed` is true; "Skip setup" completes it.
|
||
- Step 1 is already done by the forced password change at first sign-in, so it only confirms.
|
||
- Step 3 changes the SIP transports; on a fresh install the wizard restarts telephony right away
|
||
(`POST /api/system/asterisk/restart?now=true`). `restartRequired` clears on the next AMI
|
||
connect once Asterisk's uptime shows it started after the change.
|
||
- M3 also ships steps 7 and 8 because "calls work using only the wizard" needs a user and a
|
||
routed number. Step 8 offers a user or an outside number; "everyone" and "an IVR" arrive in M6.
|
||
Step 10 does not mention *43 or the softphone until M6/M5 build them.
|
||
- The first phone number added becomes `routing.defaultCallerIdDidId` when none is set.
|
||
|
||
## 3. Shared components (`src/components/`)
|
||
|
||
- **DestinationPicker**: a type select (User, Voicemail of user, Ring group, Queue,
|
||
IVR, Office hours, Conference, Announcement, External number, Hang up), then a
|
||
searchable target select or a number input. The value is a `Destination` object (02 §1).
|
||
The read-only display renders "→ Ring group Sales (500)".
|
||
- **SoundPicker**: select + play button + "upload new" shortcut.
|
||
- **ExtensionPicker** (single/multi, orderable via drag handle).
|
||
- **PhoneNumberInput** (normalizes, shows formatted).
|
||
- **StatusDot**: idle green, ringing amber pulsing, in use red, on hold blue, unavailable gray, DND purple.
|
||
- **AudioPlayer**: `<audio controls preload="none">` with a download link.
|
||
- **ConfirmDialog**, **Drawer**, **DataTable** (sorting, client-side filtering for <1000 rows, server pagination otherwise).
|
||
|
||
## 4. My Phone area (`/me`)
|
||
|
||
| Page | Path | Contents |
|
||
|---|---|---|
|
||
| Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list |
|
||
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts |
|
||
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete. Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
|
||
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
|
||
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
|
||
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5) |
|
||
|
||
### 4.1 Presence panel
|
||
Right column on wide screens, a tab on mobile. Lists every extension with its
|
||
StatusDot, name and number, plus a search box. Click to call, or to transfer when
|
||
in a call (drop the active call onto a person = blind transfer; a menu offers attended).
|
||
|
||
## 5. Browser softphone (`src/softphone/`)
|
||
|
||
### 5.1 Structure
|
||
- `SoftphoneEngine.ts`: a class with no React in it, wrapping SIP.js. Exposes an event emitter + state.
|
||
- `useSoftphone.ts`: a React hook that subscribes to the engine.
|
||
- `SoftphoneDock.tsx`: collapsed bar at the bottom of `/me` pages (status + dial button). Expands to a panel with dialpad, active calls and controls.
|
||
- `IncomingCallDialog.tsx`.
|
||
|
||
### 5.2 Engine (SIP.js 0.21)
|
||
|
||
```ts
|
||
import { UserAgent, Registerer, Inviter, Invitation, Session, SessionState, RegistererState } from 'sip.js';
|
||
|
||
class SoftphoneEngine {
|
||
async start(creds: WebRtcCreds): Promise<void>; // creds from GET /api/me/webrtc
|
||
stop(): Promise<void>;
|
||
call(number: string): Promise<CallHandle>; // target: sip:<normalized>@<host>
|
||
answer(id: string): Promise<void>;
|
||
decline(id: string): Promise<void>; // invitation.reject({ statusCode: 486 })
|
||
hangup(id: string): Promise<void>; // bye() if established, cancel() if early
|
||
hold(id: string, on: boolean): Promise<void>; // re-INVITE with sessionDescriptionHandlerOptions { hold: on }
|
||
mute(id: string, on: boolean): void; // sender.track.enabled = !on
|
||
dtmf(id: string, digit: string): void; // RTCDTMFSender via sessionDescriptionHandler.sendDtmf
|
||
blindTransfer(id: string, target: string): Promise<void>; // session.refer(UserAgent.makeURI(...))
|
||
attendedTransfer(id: string, consultId: string): Promise<void>; // session.refer(consultSession)
|
||
setOutputDevice(deviceId: string): Promise<void>; // audioEl.setSinkId
|
||
setInputDevice(deviceId: string): Promise<void>; // getUserMedia constraints for next calls
|
||
}
|
||
```
|
||
- UserAgent options: `uri` = `creds.sipUri`, `transportOptions.server` = `creds.wsUrl`,
|
||
`authorizationUsername` = `creds.authUser`, `authorizationPassword` = `creds.password`,
|
||
`displayName`, `sessionDescriptionHandlerFactoryOptions.peerConnectionConfiguration.iceServers` = `creds.iceServers`,
|
||
`contactParams: { transport: 'wss' }`, `logLevel: 'warn'`.
|
||
- Register with `Registerer` (expires 300). On transport disconnect, reconnect with backoff 2→30 s and re-register.
|
||
- Remote audio: one hidden `<audio autoplay>` element per call. Attach `MediaStream` from the receivers' tracks.
|
||
- Max 2 calls at once (call waiting). A second incoming call while busy shows a smaller banner. Answering it puts the first on hold.
|
||
- **One tab only:** use `BroadcastChannel('selfphoneng-softphone')` to elect a leader tab. Only the
|
||
leader registers. Other tabs show "Softphone is active in another tab" with a "Use here" button
|
||
that asks the leader to stop.
|
||
- Ask for microphone permission only when the user first clicks "Enable softphone". Remember the choice in
|
||
localStorage (`softphone.enabled`, try/catch).
|
||
- Incoming call: play `/sounds/ringtone.mp3` (a CC0 file we commit to `apps/web/public/sounds/`, with its license in `SOUNDS-LICENSE.md`) in a loop,
|
||
show `Notification` if permitted and the tab is hidden, and clicking the notification focuses the tab.
|
||
Look up the caller name: contacts → extensions → raw number.
|
||
- Auto-answer header (paging): if the INVITE has `Call-Info: ...;answer-after=0`, answer after a short beep. (Paging only targets desk phones today. Implement the check anyway; it's 5 lines.)
|
||
- Show call quality warnings if `RTCPeerConnection.getStats()` shows packet loss > 5% or jitter > 50 ms over 5 s.
|
||
|
||
### 5.3 Softphone panel UI
|
||
- Top: registration status (Registered / Connecting / Error with a reason) + DND toggle.
|
||
- Number input with paste normalization + 12-key dialpad (also typing on the keyboard; Enter calls).
|
||
- Active call cards: name/number, timer, state; buttons Mute, Hold, Keypad, Transfer, Record (sends DTMF `*1`, which toggles automixmon on the server), Hang up.
|
||
- Transfer flow: search box (contacts + extensions) → "Transfer now" (blind) or "Talk first" (attended: places a consult call; then "Complete transfer" / "Cancel").
|
||
- Recent calls (last 10, from `/api/me/calls`).
|
||
|
||
## 6. Pages to leave out of v1
|
||
Wallboard (big-screen queue display), CRM pop-ups, chat. Don't build them.
|