feat(onboarding/phase-4): telephony, AI, widget config CRUD pages

Replaces the three remaining Pattern B placeholder routes
(/settings/telephony, /settings/ai, /settings/widget) with real forms
backed by the sidecar config endpoints introduced in Phase 1. Each page
loads the current config on mount, round-trips edits via PUT, and
supports reset-to-defaults. Changes take effect immediately since the
TelephonyConfigService / AiConfigService / WidgetConfigService all keep
in-memory caches that all consumers read through.

- src/components/forms/telephony-form.tsx — Ozonetel + SIP + Exotel
  sections; honours the '***masked***' sentinel for secrets
- src/components/forms/ai-form.tsx — provider/model/temperature/prompt
  with per-provider model suggestions
- src/components/forms/widget-form.tsx — enabled/url/embed toggles plus
  an allowedOrigins chip list
- src/pages/telephony-settings.tsx — loads masked config, marks the
  telephony wizard step complete when all required Ozonetel fields
  are filled
- src/pages/ai-settings.tsx — clamps temperature to 0..2 on save,
  marks the ai wizard step complete on successful save
- src/pages/widget-settings.tsx — uses the admin endpoint
  (/api/config/widget/admin), exposes rotate-key + copy-to-clipboard
  for the site key, and separates the read-only key card from the
  editable config card
- src/main.tsx — swaps the three placeholder routes for the real pages
- src/pages/settings-placeholder.tsx — removed; no longer referenced

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 07:49:32 +05:30
parent 4420b648d4
commit a7b2fd7fbe
8 changed files with 1006 additions and 78 deletions

View File

@@ -0,0 +1,136 @@
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
// AI assistant form — mirrors AiConfig in
// helix-engage-server/src/config/ai.defaults.ts. API keys stay in env vars
// (true secrets, rotated at the infra level); everything the admin can safely
// adjust lives here: provider choice, model, temperature, and an optional
// system-prompt addendum appended to the hospital-specific prompts that the
// WidgetChatService generates.
export type AiProvider = 'openai' | 'anthropic';
export type AiFormValues = {
provider: AiProvider;
model: string;
temperature: string;
systemPromptAddendum: string;
};
export const emptyAiFormValues = (): AiFormValues => ({
provider: 'openai',
model: 'gpt-4o-mini',
temperature: '0.7',
systemPromptAddendum: '',
});
const PROVIDER_ITEMS = [
{ id: 'openai', label: 'OpenAI' },
{ id: 'anthropic', label: 'Anthropic' },
];
// Recommended model presets per provider. Admin can still type any model
// string they want — these are suggestions, not the only options.
export const MODEL_SUGGESTIONS: Record<AiProvider, string[]> = {
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
};
type AiFormProps = {
value: AiFormValues;
onChange: (value: AiFormValues) => void;
};
export const AiForm = ({ value, onChange }: AiFormProps) => {
const patch = (updates: Partial<AiFormValues>) => onChange({ ...value, ...updates });
const suggestions = MODEL_SUGGESTIONS[value.provider];
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Provider & model</h3>
<p className="mt-1 text-xs text-tertiary">
Choose the AI vendor powering the website widget chat and call-summary features.
Changing providers takes effect immediately.
</p>
</div>
<Select
label="Provider"
placeholder="Select provider"
items={PROVIDER_ITEMS}
selectedKey={value.provider}
onSelectionChange={(key) => {
const next = key as AiProvider;
// When switching providers, also reset the model to the first
// suggested model for that provider — saves the admin a second
// edit step and avoids leaving an OpenAI model selected while
// provider=anthropic.
patch({
provider: next,
model: MODEL_SUGGESTIONS[next][0],
});
}}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<div>
<Input
label="Model"
placeholder="Model identifier"
value={value.model}
onChange={(v) => patch({ model: v })}
/>
<div className="mt-2 flex flex-wrap gap-1.5">
{suggestions.map((model) => (
<button
key={model}
type="button"
onClick={() => patch({ model })}
className={`rounded-md border px-2 py-1 text-xs transition duration-100 ease-linear ${
value.model === model
? 'border-brand bg-brand-secondary text-brand-secondary'
: 'border-secondary bg-primary text-tertiary hover:bg-secondary'
}`}
>
{model}
</button>
))}
</div>
</div>
<Input
label="Temperature"
type="number"
placeholder="0.7"
hint="0 = deterministic, 1 = balanced, 2 = very creative"
value={value.temperature}
onChange={(v) => patch({ temperature: v })}
/>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">System prompt addendum</h3>
<p className="mt-1 text-xs text-tertiary">
Optional gets appended to the hospital-specific prompts the widget generates
automatically from your doctors and clinics. Use this to add tone guidelines,
escalation rules, or topics the assistant should avoid. Leave blank for the default
behaviour.
</p>
</div>
<TextArea
label="Additional instructions"
placeholder="e.g. Always respond in the patient's language. Never quote specific medication dosages; refer them to a doctor for prescriptions."
value={value.systemPromptAddendum}
onChange={(v) => patch({ systemPromptAddendum: v })}
rows={6}
/>
</section>
</div>
);
};

View File

@@ -0,0 +1,177 @@
import { Input } from '@/components/base/input/input';
// Telephony form — covers Ozonetel cloud-call-center, the Ozonetel WebRTC
// gateway, and Exotel REST API credentials. Mirrors the TelephonyConfig shape
// in helix-engage-server/src/config/telephony.defaults.ts.
//
// Secrets (ozonetel.agentPassword, exotel.apiToken) come back from the GET
// endpoint as the sentinel '***masked***' — the form preserves that sentinel
// untouched unless the admin actually edits the field, in which case the
// backend overwrites the stored value. This is the same convention used by
// TelephonyConfigService.getMaskedConfig / updateConfig.
export type TelephonyFormValues = {
ozonetel: {
agentId: string;
agentPassword: string;
did: string;
sipId: string;
campaignName: string;
};
sip: {
domain: string;
wsPort: string;
};
exotel: {
apiKey: string;
apiToken: string;
accountSid: string;
subdomain: string;
};
};
export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
ozonetel: {
agentId: '',
agentPassword: '',
did: '',
sipId: '',
campaignName: '',
},
sip: {
domain: 'blr-pub-rtc4.ozonetel.com',
wsPort: '444',
},
exotel: {
apiKey: '',
apiToken: '',
accountSid: '',
subdomain: 'api.exotel.com',
},
});
type TelephonyFormProps = {
value: TelephonyFormValues;
onChange: (value: TelephonyFormValues) => void;
};
export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
const patchOzonetel = (updates: Partial<TelephonyFormValues['ozonetel']>) =>
onChange({ ...value, ozonetel: { ...value.ozonetel, ...updates } });
const patchSip = (updates: Partial<TelephonyFormValues['sip']>) =>
onChange({ ...value, sip: { ...value.sip, ...updates } });
const patchExotel = (updates: Partial<TelephonyFormValues['exotel']>) =>
onChange({ ...value, exotel: { ...value.exotel, ...updates } });
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Ozonetel Cloud Agent</h3>
<p className="mt-1 text-xs text-tertiary">
Outbound dialing, SIP registration, and agent provisioning. Get these values from your
Ozonetel dashboard under Admin Users and Numbers.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Agent ID"
placeholder="e.g. agent001"
value={value.ozonetel.agentId}
onChange={(v) => patchOzonetel({ agentId: v })}
/>
<Input
label="Agent password"
type="password"
placeholder="Leave '***masked***' to keep current"
value={value.ozonetel.agentPassword}
onChange={(v) => patchOzonetel({ agentPassword: v })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Default DID"
placeholder="Primary hospital number"
value={value.ozonetel.did}
onChange={(v) => patchOzonetel({ did: v })}
/>
<Input
label="SIP ID"
placeholder="Softphone extension"
value={value.ozonetel.sipId}
onChange={(v) => patchOzonetel({ sipId: v })}
/>
</div>
<Input
label="Campaign name"
placeholder="CloudAgent campaign for outbound dial"
value={value.ozonetel.campaignName}
onChange={(v) => patchOzonetel({ campaignName: v })}
/>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">SIP Gateway (WebRTC)</h3>
<p className="mt-1 text-xs text-tertiary">
Used by the staff portal softphone. Defaults work for most Indian Ozonetel tenants only
change if Ozonetel support instructs you to.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="SIP domain"
placeholder="blr-pub-rtc4.ozonetel.com"
value={value.sip.domain}
onChange={(v) => patchSip({ domain: v })}
/>
<Input
label="WebSocket port"
placeholder="444"
value={value.sip.wsPort}
onChange={(v) => patchSip({ wsPort: v })}
/>
</div>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Exotel (SMS + inbound numbers)</h3>
<p className="mt-1 text-xs text-tertiary">
Optional only required if you use Exotel for SMS or want inbound number management from
this portal.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="API key"
placeholder="Exotel API key"
value={value.exotel.apiKey}
onChange={(v) => patchExotel({ apiKey: v })}
/>
<Input
label="API token"
type="password"
placeholder="Leave '***masked***' to keep current"
value={value.exotel.apiToken}
onChange={(v) => patchExotel({ apiToken: v })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Account SID"
placeholder="Exotel account SID"
value={value.exotel.accountSid}
onChange={(v) => patchExotel({ accountSid: v })}
/>
<Input
label="Subdomain"
placeholder="api.exotel.com"
value={value.exotel.subdomain}
onChange={(v) => patchExotel({ subdomain: v })}
/>
</div>
</section>
</div>
);
};

View File

@@ -0,0 +1,167 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Toggle } from '@/components/base/toggle/toggle';
import { Button } from '@/components/base/buttons/button';
// Widget form — mirrors WidgetConfig from
// helix-engage-server/src/config/widget.defaults.ts. The site key and site ID
// are read-only (generated / rotated by the backend), the rest are editable.
//
// allowedOrigins is an origin allowlist — an empty list means "any origin"
// which is useful for testing but should be tightened in production.
export type WidgetFormValues = {
enabled: boolean;
url: string;
allowedOrigins: string[];
embed: {
loginPage: boolean;
};
};
export const emptyWidgetFormValues = (): WidgetFormValues => ({
enabled: true,
url: '',
allowedOrigins: [],
embed: {
loginPage: false,
},
});
type WidgetFormProps = {
value: WidgetFormValues;
onChange: (value: WidgetFormValues) => void;
};
export const WidgetForm = ({ value, onChange }: WidgetFormProps) => {
const [originDraft, setOriginDraft] = useState('');
const addOrigin = () => {
const trimmed = originDraft.trim();
if (!trimmed) return;
if (value.allowedOrigins.includes(trimmed)) {
setOriginDraft('');
return;
}
onChange({ ...value, allowedOrigins: [...value.allowedOrigins, trimmed] });
setOriginDraft('');
};
const removeOrigin = (origin: string) => {
onChange({ ...value, allowedOrigins: value.allowedOrigins.filter((o) => o !== origin) });
};
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Activation</h3>
<p className="mt-1 text-xs text-tertiary">
When disabled, widget.js returns an empty response and the script no-ops on the
embedding page. Use this as a kill switch if something goes wrong in production.
</p>
</div>
<div className="rounded-lg border border-secondary bg-secondary p-4">
<Toggle
label="Website widget enabled"
isSelected={value.enabled}
onChange={(checked) => onChange({ ...value, enabled: checked })}
/>
</div>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Hosting</h3>
<p className="mt-1 text-xs text-tertiary">
Public base URL where widget.js is served from. Leave blank to use the same origin as
this sidecar (the common case).
</p>
</div>
<Input
label="Public URL"
placeholder="https://widget.hospital.com"
value={value.url}
onChange={(v) => onChange({ ...value, url: v })}
/>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Allowed origins</h3>
<p className="mt-1 text-xs text-tertiary">
Origins where the widget may be embedded. An empty list means any origin is accepted
(test mode). In production, list every hospital website + staging environment
explicitly.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder="https://hospital.com"
value={originDraft}
onChange={setOriginDraft}
/>
</div>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPlus} className={className} />
)}
onClick={addOrigin}
isDisabled={!originDraft.trim()}
>
Add
</Button>
</div>
{value.allowedOrigins.length === 0 ? (
<p className="rounded-lg border border-dashed border-secondary bg-secondary p-4 text-center text-xs text-tertiary">
Any origin allowed widget runs in test mode.
</p>
) : (
<ul className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-3">
{value.allowedOrigins.map((origin) => (
<li
key={origin}
className="flex items-center justify-between rounded-md bg-primary px-3 py-2 text-sm"
>
<span className="font-mono text-primary">{origin}</span>
<button
type="button"
onClick={() => removeOrigin(origin)}
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-error-primary"
title="Remove origin"
>
<FontAwesomeIcon icon={faTrash} className="size-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Embed surfaces</h3>
<p className="mt-1 text-xs text-tertiary">
Where inside this application the widget should auto-render. Keep these off if you
only plan to embed it on your public hospital website.
</p>
</div>
<div className="rounded-lg border border-secondary bg-secondary p-4">
<Toggle
label="Show on staff login page"
hint="Useful for smoke-testing without a public landing page."
isSelected={value.embed.loginPage}
onChange={(checked) =>
onChange({ ...value, embed: { ...value.embed, loginPage: checked } })
}
/>
</div>
</section>
</div>
);
};

View File

@@ -32,9 +32,11 @@ import { RulesSettingsPage } from "@/pages/rules-settings";
import { BrandingSettingsPage } from "@/pages/branding-settings"; import { BrandingSettingsPage } from "@/pages/branding-settings";
import { TeamSettingsPage } from "@/pages/team-settings"; import { TeamSettingsPage } from "@/pages/team-settings";
import { SetupWizardPage } from "@/pages/setup-wizard"; import { SetupWizardPage } from "@/pages/setup-wizard";
import { SettingsPlaceholder } from "@/pages/settings-placeholder";
import { ClinicsPage } from "@/pages/clinics"; import { ClinicsPage } from "@/pages/clinics";
import { DoctorsPage } from "@/pages/doctors"; import { DoctorsPage } from "@/pages/doctors";
import { TelephonySettingsPage } from "@/pages/telephony-settings";
import { AiSettingsPage } from "@/pages/ai-settings";
import { WidgetSettingsPage } from "@/pages/widget-settings";
import { AuthProvider } from "@/providers/auth-provider"; import { AuthProvider } from "@/providers/auth-provider";
import { DataProvider } from "@/providers/data-provider"; import { DataProvider } from "@/providers/data-provider";
import { RouteProvider } from "@/providers/router-provider"; import { RouteProvider } from "@/providers/router-provider";
@@ -88,36 +90,9 @@ createRoot(document.getElementById("root")!).render(
<Route path="/settings/team" element={<TeamSettingsPage />} /> <Route path="/settings/team" element={<TeamSettingsPage />} />
<Route path="/settings/clinics" element={<ClinicsPage />} /> <Route path="/settings/clinics" element={<ClinicsPage />} />
<Route path="/settings/doctors" element={<DoctorsPage />} /> <Route path="/settings/doctors" element={<DoctorsPage />} />
<Route <Route path="/settings/telephony" element={<TelephonySettingsPage />} />
path="/settings/telephony" <Route path="/settings/ai" element={<AiSettingsPage />} />
element={ <Route path="/settings/widget" element={<WidgetSettingsPage />} />
<SettingsPlaceholder
title="Telephony"
description="Connect Ozonetel and Exotel for calls"
phase="Phase 4"
/>
}
/>
<Route
path="/settings/ai"
element={
<SettingsPlaceholder
title="AI Assistant"
description="Choose your AI provider, model, and prompts"
phase="Phase 4"
/>
}
/>
<Route
path="/settings/widget"
element={
<SettingsPlaceholder
title="Website Widget"
description="Embed the chat + booking widget on your hospital website"
phase="Phase 4"
/>
}
/>
<Route path="/agent/:id" element={<AgentDetailPage />} /> <Route path="/agent/:id" element={<AgentDetailPage />} />
<Route path="/patient/:id" element={<Patient360Page />} /> <Route path="/patient/:id" element={<Patient360Page />} />

159
src/pages/ai-settings.tsx Normal file
View File

@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faRobot, faRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import {
AiForm,
emptyAiFormValues,
type AiFormValues,
type AiProvider,
} from '@/components/forms/ai-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { markSetupStepComplete } from '@/lib/setup-state';
// /settings/ai — Pattern B page for the AI assistant config. Backed by
// /api/config/ai which is file-backed (data/ai.json) and hot-reloaded through
// AiConfigService — no restart needed.
//
// Temperature is a string in the form for input UX (so users can partially
// type '0.', '0.5', etc) then clamped to 0..2 on save.
type ServerAiConfig = {
provider?: AiProvider;
model?: string;
temperature?: number;
systemPromptAddendum?: string;
};
const clampTemperature = (raw: string): number => {
const n = Number(raw);
if (Number.isNaN(n)) return 0.7;
return Math.min(2, Math.max(0, n));
};
export const AiSettingsPage = () => {
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const loadConfig = async () => {
try {
const data = await apiClient.get<ServerAiConfig>('/api/config/ai');
setValues({
provider: data.provider ?? 'openai',
model: data.model ?? 'gpt-4o-mini',
temperature: data.temperature != null ? String(data.temperature) : '0.7',
systemPromptAddendum: data.systemPromptAddendum ?? '',
});
} catch {
// toast already shown
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
if (!values.model.trim()) {
notify.error('Model is required');
return;
}
setIsSaving(true);
try {
await apiClient.put('/api/config/ai', {
provider: values.provider,
model: values.model.trim(),
temperature: clampTemperature(values.temperature),
systemPromptAddendum: values.systemPromptAddendum,
});
notify.success('AI settings updated', 'Changes are live for new conversations.');
markSetupStepComplete('ai').catch(() => {});
await loadConfig();
} catch (err) {
console.error('[ai] save failed', err);
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
if (!confirm('Reset AI settings to defaults? The system prompt addendum will be cleared.')) {
return;
}
setIsResetting(true);
try {
await apiClient.post('/api/config/ai/reset');
notify.info('AI reset', 'Provider, model, and prompt have been restored to defaults.');
await loadConfig();
} catch (err) {
console.error('[ai] reset failed', err);
} finally {
setIsResetting(false);
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="AI Assistant" subtitle="Choose provider, model, and conversational guidelines" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3 rounded-lg border border-secondary bg-primary p-4">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-5 text-fg-brand-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-primary">API keys live in environment variables</p>
<p className="text-xs text-tertiary">
The actual OPENAI_API_KEY and ANTHROPIC_API_KEY are set at deploy time and
can't be edited here. If you change the provider, make sure the matching key
is configured on the sidecar or the assistant will silently fall back to the
other provider.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-sm text-tertiary">Loading AI settings...</p>
</div>
) : (
<div className="rounded-xl border border-secondary bg-primary p-6 shadow-xs">
<AiForm value={values} onChange={setValues} />
<div className="mt-8 flex items-center justify-between border-t border-secondary pt-4">
<Button
size="md"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
onClick={handleReset}
isLoading={isResetting}
showTextWhileLoading
>
Reset to defaults
</Button>
<Button
size="md"
color="primary"
onClick={handleSave}
isLoading={isSaving}
showTextWhileLoading
>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,47 +0,0 @@
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faTools } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
type SettingsPlaceholderProps = {
title: string;
description: string;
phase: string;
};
// Placeholder for settings pages that haven't been built yet. Used by routes
// the Settings hub links to during Phase 2 — Phase 3 (Clinics, Doctors, Team
// invite/role editor) and Phase 4 (Telephony, AI, Widget) replace these with
// real CRUD pages.
export const SettingsPlaceholder = ({ title, description, phase }: SettingsPlaceholderProps) => {
const navigate = useNavigate();
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title={title} subtitle={description} />
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-md rounded-xl border border-dashed border-secondary bg-secondary px-8 py-12 text-center">
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-primary">
<FontAwesomeIcon icon={faTools} className="size-5 text-brand-primary" />
</div>
<h2 className="text-base font-semibold text-primary">Coming in {phase}</h2>
<p className="mt-2 text-sm text-tertiary">
This page will let you manage <b>{title.toLowerCase()}</b> directly from the staff portal.
It's not built yet — see the onboarding plan for delivery details.
</p>
<Button
size="sm"
color="secondary"
className="mt-6"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowLeft} className={className} />
)}
onClick={() => navigate('/settings')}
>
Back to settings
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone, faRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import {
TelephonyForm,
emptyTelephonyFormValues,
type TelephonyFormValues,
} from '@/components/forms/telephony-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { markSetupStepComplete } from '@/lib/setup-state';
// /settings/telephony — Pattern B page against the sidecar's
// /api/config/telephony endpoint. The sidecar masks secrets on GET (agent
// password + Exotel API token become '***masked***') and treats that sentinel
// as "no change" on PUT, so we just round-trip the form values directly.
//
// Changes take effect immediately — TelephonyConfigService keeps an in-memory
// cache that all consumers read via getters, no restart required.
export const TelephonySettingsPage = () => {
const [values, setValues] = useState<TelephonyFormValues>(emptyTelephonyFormValues);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const loadConfig = async () => {
try {
const data = await apiClient.get<TelephonyFormValues>('/api/config/telephony');
setValues({
ozonetel: {
agentId: data.ozonetel?.agentId ?? '',
agentPassword: data.ozonetel?.agentPassword ?? '',
did: data.ozonetel?.did ?? '',
sipId: data.ozonetel?.sipId ?? '',
campaignName: data.ozonetel?.campaignName ?? '',
},
sip: {
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
wsPort: data.sip?.wsPort ?? '444',
},
exotel: {
apiKey: data.exotel?.apiKey ?? '',
apiToken: data.exotel?.apiToken ?? '',
accountSid: data.exotel?.accountSid ?? '',
subdomain: data.exotel?.subdomain ?? 'api.exotel.com',
},
});
} catch {
// toast already shown
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
setIsSaving(true);
try {
await apiClient.put('/api/config/telephony', {
ozonetel: values.ozonetel,
sip: values.sip,
exotel: values.exotel,
});
notify.success('Telephony updated', 'Changes are live — no restart needed.');
// Mark the wizard step complete if the required Ozonetel fields are
// all filled in. Keeps the setup hub badges in sync with reality.
const complete =
!!values.ozonetel.agentId &&
!!values.ozonetel.did &&
!!values.ozonetel.sipId &&
!!values.ozonetel.campaignName;
if (complete) {
markSetupStepComplete('telephony').catch(() => {});
}
await loadConfig();
} catch (err) {
console.error('[telephony] save failed', err);
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
if (!confirm('Reset telephony settings to defaults? Your Ozonetel and Exotel credentials will be cleared.')) {
return;
}
setIsResetting(true);
try {
await apiClient.post('/api/config/telephony/reset');
notify.info('Telephony reset', 'All fields have been cleared.');
await loadConfig();
} catch (err) {
console.error('[telephony] reset failed', err);
} finally {
setIsResetting(false);
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Telephony" subtitle="Connect Ozonetel and Exotel for calls and SMS" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3 rounded-lg border border-secondary bg-primary p-4">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faPhone} className="size-5 text-fg-brand-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-primary">Credentials are stored locally</p>
<p className="text-xs text-tertiary">
Values are written to the sidecar's data/telephony.json. API tokens are masked
when loaded — leave the <code className="rounded bg-secondary px-1 py-0.5 font-mono">***masked***</code>{' '}
placeholder to keep the existing value.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-sm text-tertiary">Loading telephony settings...</p>
</div>
) : (
<div className="rounded-xl border border-secondary bg-primary p-6 shadow-xs">
<TelephonyForm value={values} onChange={setValues} />
<div className="mt-8 flex items-center justify-between border-t border-secondary pt-4">
<Button
size="md"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
onClick={handleReset}
isLoading={isResetting}
showTextWhileLoading
>
Reset to defaults
</Button>
<Button
size="md"
color="primary"
onClick={handleSave}
isLoading={isSaving}
showTextWhileLoading
>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe, faCopy, faArrowsRotate } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import {
WidgetForm,
emptyWidgetFormValues,
type WidgetFormValues,
} from '@/components/forms/widget-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
// /settings/widget — Pattern B page for the website widget config. Uses the
// admin endpoint GET /api/config/widget/admin (the plain /api/config/widget
// endpoint returns only the public subset and is used by the embed page).
//
// The site key and site ID are read-only here — generated and rotated by the
// backend. The copy-to-clipboard button on the key helps the admin paste it
// into their website's embed snippet.
type ServerWidgetConfig = {
enabled: boolean;
key: string;
siteId: string;
url: string;
allowedOrigins: string[];
embed: { loginPage: boolean };
version?: number;
updatedAt?: string;
};
export const WidgetSettingsPage = () => {
const [values, setValues] = useState<WidgetFormValues>(emptyWidgetFormValues);
const [key, setKey] = useState<string>('');
const [siteId, setSiteId] = useState<string>('');
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isRotating, setIsRotating] = useState(false);
const loadConfig = async () => {
try {
const data = await apiClient.get<ServerWidgetConfig>('/api/config/widget/admin');
setValues({
enabled: data.enabled,
url: data.url ?? '',
allowedOrigins: data.allowedOrigins ?? [],
embed: {
loginPage: data.embed?.loginPage ?? false,
},
});
setKey(data.key ?? '');
setSiteId(data.siteId ?? '');
} catch {
// toast already shown
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
setIsSaving(true);
try {
await apiClient.put('/api/config/widget', {
enabled: values.enabled,
url: values.url,
allowedOrigins: values.allowedOrigins,
embed: values.embed,
});
notify.success('Widget updated', 'Changes take effect on next widget load.');
await loadConfig();
} catch (err) {
console.error('[widget] save failed', err);
} finally {
setIsSaving(false);
}
};
const handleRotateKey = async () => {
if (
!confirm(
'Rotate the widget key? Any website embed using the old key will stop working until you update it.',
)
) {
return;
}
setIsRotating(true);
try {
await apiClient.post('/api/config/widget/rotate-key');
notify.success('Key rotated', 'Update every embed snippet with the new key.');
await loadConfig();
} catch (err) {
console.error('[widget] rotate failed', err);
} finally {
setIsRotating(false);
}
};
const handleCopyKey = async () => {
try {
await navigator.clipboard.writeText(key);
notify.success('Copied', 'Widget key copied to clipboard.');
} catch {
notify.error('Copy failed', 'Select the key manually and copy it.');
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Website Widget" subtitle="Configure the chat + booking widget for your hospital website" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3 rounded-lg border border-secondary bg-primary p-4">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faGlobe} className="size-5 text-fg-brand-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-primary">One-line embed snippet</p>
<p className="text-xs text-tertiary">
Drop the script below into your hospital website's <code className="rounded bg-secondary px-1 py-0.5 font-mono">&lt;head&gt;</code> to
enable chat and appointment booking. Changing the key requires re-embedding.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-sm text-tertiary">Loading widget settings...</p>
</div>
) : (
<div className="flex flex-col gap-6">
{/* Site key card — read-only with copy + rotate */}
<div className="rounded-xl border border-secondary bg-primary p-5 shadow-xs">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-primary">Site key</h3>
<p className="text-xs text-tertiary">
Site ID: <span className="font-mono">{siteId || ''}</span>
</p>
</div>
<Button
size="sm"
color="tertiary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowsRotate} className={className} />
)}
onClick={handleRotateKey}
isLoading={isRotating}
showTextWhileLoading
>
Rotate
</Button>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded-lg border border-secondary bg-secondary px-3 py-2 font-mono text-xs text-primary">
{key || ' no key yet '}
</code>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faCopy} className={className} />
)}
onClick={handleCopyKey}
isDisabled={!key}
>
Copy
</Button>
</div>
</div>
{/* Editable config */}
<div className="rounded-xl border border-secondary bg-primary p-6 shadow-xs">
<WidgetForm value={values} onChange={setValues} />
<div className="mt-8 flex items-center justify-end border-t border-secondary pt-4">
<Button
size="md"
color="primary"
onClick={handleSave}
isLoading={isSaving}
showTextWhileLoading
>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};