feat: email provider, admin password reset, portal new-patient, chat pill

Chat: history pill now shows a History icon + a Start-new-chat (SquarePen)
button; removed the duplicate chat-history list from the sidebar.

Email: deployment-wide email provider config (Resend/Postmark/SendGrid/SMTP) in
Settings → Developers, with encrypted API key and a Send-test action. sendEmail
dispatches via the chosen provider (REST via fetch; SMTP via nodemailer).

Forgot password with no provider: alert the clinic admin(s) via a "System"
message card in Messages + a bell notification (seeded system user + per-clinic
System conversation); clicking deep-links to /settings?tab=careTeam&member=<id>.
Admins can set a member's password directly from the employee dialog
(PATCH /api/staff/:id/password via Better Auth's internal context — no admin
plugin needed).

Patient Portal: "New patient" booking path registers a demographics-only patient
then books; bookings reject double-booked slots (409).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 19:52:55 +03:00
parent 516de6ad60
commit 90e6ec4cc0
29 changed files with 5200 additions and 142 deletions
@@ -1,6 +1,6 @@
"use client";
import { PanelLeft, Plus, Search, Trash2 } from "lucide-react";
import { History, Plus, Search, SquarePen, Trash2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { type MouseEvent, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -78,15 +78,15 @@ export function ChatHistoryPanel() {
onClick={() => setOpen(true)}
type="button"
>
<PanelLeft className="size-4" />
<History className="size-4" />
</button>
<button
aria-label={t("chat.history.search")}
aria-label={t("chat.history.startNew")}
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(true)}
onClick={() => router.push("/")}
type="button"
>
<Search className="size-4" />
<SquarePen className="size-4" />
</button>
</div>
+29 -1
View File
@@ -4,6 +4,7 @@ import {
CalendarClock,
Download,
FileText,
KeyRound,
Mail,
Paperclip,
Plus,
@@ -93,7 +94,32 @@ const GROUP_WINDOW_MS = 5 * 60 * 1000;
// shared-appointment card. Alignment (left/right) comes from the parent column.
function SentAttachment({ att }: { att: MessageAttachment }) {
const { t } = useTranslation();
const router = useRouter();
const [apptOpen, setApptOpen] = useState(false);
if (att.kind === "passwordReset") {
return (
<button
className="max-w-[75%] rounded-2xl border border-warning/40 bg-warning/5 p-3 text-left text-sm transition-colors hover:bg-warning/10"
onClick={() =>
router.push(
`/settings?tab=careTeam&member=${encodeURIComponent(att.userId)}`,
)
}
type="button"
>
<div className="flex items-center gap-1.5 text-warning text-xs">
<KeyRound className="size-3.5" />
{t("messages.system.label")}
</div>
<p className="mt-1 font-medium text-foreground">
{t("messages.system.passwordResetTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("messages.system.passwordResetBody", { name: att.userName })}
</p>
</button>
);
}
if (att.kind === "file") {
return (
<button
@@ -668,7 +694,9 @@ export function MessagesView() {
<span className="max-w-40 truncate">
{att.kind === "file"
? att.fileName
: att.appointment.name}
: att.kind === "appointment"
? att.appointment.name
: ""}
</span>
<button
aria-label={t("messages.attach.remove")}
+76 -8
View File
@@ -23,11 +23,13 @@ import {
import { Input } from "@/components/ui/input";
import {
bookPortalAppointment,
createPortalPatient,
getPortalClinic,
lookupPortalResults,
type PortalBookingResult,
type PortalResults,
} from "@/lib/portal";
import { cn } from "@/lib/utils";
type Step = "choose" | "book" | "results";
@@ -157,14 +159,20 @@ function BackButton({ onBack }: { onBack: () => void }) {
function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
const { t } = useTranslation();
// "returning" = has a file number; "new" = register first, then book.
const [mode, setMode] = useState<"returning" | "new">("returning");
const [name, setName] = useState("");
const [fileNumber, setFileNumber] = useState("");
const [sex, setSex] = useState("M");
const [age, setAge] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("09:00");
const [type, setType] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<PortalBookingResult | null>(null);
// The file number assigned to a freshly-registered patient (shown on success).
const [newFile, setNewFile] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
@@ -172,9 +180,20 @@ function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
setBusy(true);
setError(null);
try {
// A new patient is registered first to obtain a file number.
let file = fileNumber.trim();
if (mode === "new") {
const created = await createPortalPatient(clinic, {
name: name.trim(),
sex,
age: age ? Number(age) : undefined,
});
file = created.fileNumber;
setNewFile(created.fileNumber);
}
const result = await bookPortalAppointment(clinic, {
name: name.trim(),
fileNumber: fileNumber.trim(),
fileNumber: file,
date,
time,
type: type.trim() || undefined,
@@ -203,6 +222,9 @@ function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
date: done.date,
time: done.time,
})}
{newFile
? ` ${t("portal.book.newFileNote", { file: newFile })}`
: ""}
</EmptyDescription>
</EmptyHeader>
</Empty>
@@ -217,16 +239,62 @@ function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
<BackButton onBack={onBack} />
<h2 className="font-semibold text-xl">{t("portal.book.title")}</h2>
{/* Returning vs new patient. */}
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-muted/40 p-1">
{(["returning", "new"] as const).map((m) => (
<button
className={cn(
"rounded-xl px-3 py-2 text-sm font-medium transition-colors",
mode === m
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
key={m}
onClick={() => setMode(m)}
type="button"
>
{t(`portal.book.mode.${m}`)}
</button>
))}
</div>
<Field label={t("portal.field.name")}>
<Input onChange={(e) => setName(e.target.value)} required value={name} />
</Field>
<Field label={t("portal.field.fileNumber")}>
<Input
onChange={(e) => setFileNumber(e.target.value)}
required
value={fileNumber}
/>
</Field>
{mode === "returning" ? (
<Field label={t("portal.field.fileNumber")}>
<Input
onChange={(e) => setFileNumber(e.target.value)}
required
value={fileNumber}
/>
</Field>
) : (
<div className="grid grid-cols-2 gap-3">
<Field label={t("portal.field.sex")}>
<select
className="h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
onChange={(e) => setSex(e.target.value)}
value={sex}
>
<option value="M">{t("portal.field.sexMale")}</option>
<option value="F">{t("portal.field.sexFemale")}</option>
</select>
</Field>
<Field label={t("portal.field.age")}>
<Input
max={150}
min={0}
onChange={(e) => setAge(e.target.value)}
type="number"
value={age}
/>
</Field>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={t("portal.field.date")}>
<Input
@@ -24,6 +24,7 @@ import {
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectItem,
@@ -34,7 +35,12 @@ import {
import { ROLE_LABELS } from "@/lib/access";
import { authClient } from "@/lib/auth-client";
import { PROVISIONABLE_ROLES, rolePermissionSummary } from "@/lib/roles";
import { SPECIALTIES, specialtyLabel, updateStaffSpecialty } from "@/lib/staff";
import {
SPECIALTIES,
setStaffPassword,
specialtyLabel,
updateStaffSpecialty,
} from "@/lib/staff";
import { notify } from "@/lib/toast";
// Roles that can carry a clinical specialty (i.e. treat patients).
@@ -105,10 +111,15 @@ export function EmployeeDetailDialog({
const [saving, setSaving] = useState(false);
const [specialty, setSpecialty] = useState<string>(member?.specialty ?? "");
const [savingSpecialty, setSavingSpecialty] = useState(false);
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [savingPw, setSavingPw] = useState(false);
useEffect(() => {
setRole(member?.role ?? "");
setSpecialty(member?.specialty ?? "");
setNewPw("");
setConfirmPw("");
}, [member?.id, member?.role, member?.specialty]);
const summary = rolePermissionSummary(member?.role);
@@ -169,6 +180,43 @@ export function EmployeeDetailDialog({
}
};
const resetPassword = async () => {
if (!member || savingPw) return;
if (newPw.length < 12) {
notify.error(
t("settings.careTeam.employee.pwTooShortTitle"),
t("settings.careTeam.employee.pwTooShortBody"),
);
return;
}
if (newPw !== confirmPw) {
notify.error(
t("settings.careTeam.employee.pwMismatchTitle"),
t("settings.careTeam.employee.pwMismatchBody"),
);
return;
}
setSavingPw(true);
try {
await setStaffPassword(member.userId, newPw);
notify.success(
t("settings.careTeam.employee.pwUpdatedTitle"),
t("settings.careTeam.employee.pwUpdatedBody", {
name: member.name ?? member.email ?? "",
}),
);
setNewPw("");
setConfirmPw("");
} catch {
notify.error(
t("settings.careTeam.employee.pwFailedTitle"),
t("settings.careTeam.employee.pwFailedBody"),
);
} finally {
setSavingPw(false);
}
};
const showSpecialty = PROVIDER_ROLES.has(member?.role ?? "");
const specialtyOptions = [
{ value: "", label: t("settings.careTeam.employee.noSpecialty") },
@@ -333,6 +381,42 @@ export function EmployeeDetailDialog({
</div>
</div>
)}
{editable && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("settings.careTeam.employee.resetPassword")}
</span>
<p className="text-muted-foreground text-xs">
{t("settings.careTeam.employee.resetPasswordHint")}
</p>
<Input
autoComplete="new-password"
onChange={(e) => setNewPw(e.target.value)}
placeholder={t("settings.careTeam.employee.newPassword")}
type="password"
value={newPw}
/>
<div className="flex items-center gap-2">
<Input
autoComplete="new-password"
onChange={(e) => setConfirmPw(e.target.value)}
placeholder={t("settings.careTeam.employee.confirmPassword")}
type="password"
value={confirmPw}
/>
<Button
disabled={savingPw || !newPw || !confirmPw}
onClick={resetPassword}
type="button"
>
{savingPw
? t("settings.careTeam.employee.saving")
: t("settings.careTeam.employee.setPassword")}
</Button>
</div>
</div>
)}
</DialogPanel>
<DialogFooter>
@@ -1,7 +1,7 @@
"use client";
import { Info, UserPlus } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AddStaffDialog } from "@/components/settings/add-staff-dialog";
@@ -49,7 +49,11 @@ function initials(name?: string | null, email?: string | null): string {
);
}
export function CareTeamPanel() {
export function CareTeamPanel({
initialMemberId,
}: {
initialMemberId?: string;
}) {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const [members, setMembers] = useState<StaffMember[]>([]);
@@ -79,6 +83,18 @@ export function CareTeamPanel() {
void load();
}, [load]);
// Deep-link: open a specific member (e.g. from a password-reset system card).
const appliedDeepLink = useRef(false);
useEffect(() => {
if (appliedDeepLink.current || !initialMemberId || members.length === 0)
return;
const target = members.find((m) => m.userId === initialMemberId);
if (target) {
setSelected(target);
appliedDeepLink.current = true;
}
}, [initialMemberId, members]);
const myRole = members.find((m) => m.userId === session?.user?.id)?.role;
const canManage = myRole === "owner" || myRole === "admin";
@@ -1,21 +1,183 @@
"use client";
import { KeyRound } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
CopyField,
FieldLabel,
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { API_BASE_URL } from "@/lib/api-client";
import {
type EmailConfig,
type EmailProvider,
getEmailConfig,
saveEmailConfig,
testEmailConfig,
} from "@/lib/email-settings";
import { notify } from "@/lib/toast";
const PROVIDERS: EmailProvider[] = [
"none",
"resend",
"postmark",
"sendgrid",
"smtp",
];
// Providers that authenticate with an API key (so we show the key field).
const API_KEY_PROVIDERS: EmailProvider[] = ["resend", "postmark", "sendgrid"];
const controlClass =
"h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
function EmailProviderCard() {
const { t } = useTranslation();
const [config, setConfig] = useState<EmailConfig | null>(null);
const [provider, setProvider] = useState<EmailProvider>("none");
const [fromAddress, setFromAddress] = useState("");
const [credentials, setCredentials] = useState(""); // empty = untouched
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
getEmailConfig()
.then((c) => {
setConfig(c);
setProvider(c.provider);
setFromAddress(c.fromAddress);
})
.catch(() => {
/* non-admins won't reach this tab */
});
}, []);
const save = async () => {
setSaving(true);
try {
const saved = await saveEmailConfig({
provider,
fromAddress: fromAddress.trim(),
...(credentials ? { credentials } : {}),
});
setConfig(saved);
setCredentials("");
notify.success(t("settings.developers.email.savedTitle"));
} catch {
notify.error(t("settings.developers.email.savedFailed"));
} finally {
setSaving(false);
}
};
const test = async () => {
setTesting(true);
try {
const r = await testEmailConfig();
notify.success(
t("settings.developers.email.testSentTitle"),
t("settings.developers.email.testSentBody", { to: r.to }),
);
} catch {
notify.error(t("settings.developers.email.testFailed"));
} finally {
setTesting(false);
}
};
const needsKey = API_KEY_PROVIDERS.includes(provider);
return (
<SettingsSection
action={
config ? (
<Badge variant={config.provider === "none" ? "outline" : "secondary"}>
{t(`settings.developers.email.providers.${config.provider}`)}
</Badge>
) : null
}
description={t("settings.developers.email.description")}
title={t("settings.developers.email.title")}
>
<SettingsCard className="space-y-5 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.developers.email.provider")}</FieldLabel>
<select
className={controlClass}
onChange={(e) => setProvider(e.target.value as EmailProvider)}
value={provider}
>
{PROVIDERS.map((p) => (
<option key={p} value={p}>
{t(`settings.developers.email.providers.${p}`)}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<FieldLabel>{t("settings.developers.email.from")}</FieldLabel>
<Input
onChange={(e) => setFromAddress(e.target.value)}
placeholder="temetro <no-reply@yourclinic.com>"
value={fromAddress}
/>
</div>
{needsKey ? (
<div className="space-y-1.5">
<FieldLabel>{t("settings.developers.email.apiKey")}</FieldLabel>
<Input
autoComplete="off"
onChange={(e) => setCredentials(e.target.value)}
placeholder={
config?.hasCredentials
? t("settings.developers.email.apiKeySet")
: t("settings.developers.email.apiKeyPlaceholder")
}
type="password"
value={credentials}
/>
</div>
) : provider === "smtp" ? (
<p className="text-muted-foreground text-xs">
{t("settings.developers.email.smtpHint")}
</p>
) : null}
<div className="flex items-center gap-2">
<Button disabled={saving} onClick={save} size="sm">
{saving
? t("settings.developers.email.saving")
: t("settings.developers.email.save")}
</Button>
<Button
disabled={testing || provider === "none"}
onClick={test}
size="sm"
variant="outline"
>
{testing
? t("settings.developers.email.testing")
: t("settings.developers.email.test")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
);
}
export function DevelopersPanel() {
const { t } = useTranslation();
return (
<>
<EmailProviderCard />
<SettingsSection
description={t("settings.developers.apiDescription")}
title={t("settings.developers.apiTitle")}
@@ -1,5 +1,6 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -31,7 +32,11 @@ type Tab = (typeof TABS)[number]["id"];
export function SettingsView() {
const { t } = useTranslation();
const role = useActiveRole();
const [tab, setTab] = useState<Tab>("profile");
const searchParams = useSearchParams();
// Deep-link support: /settings?tab=careTeam&member=<userId>.
const urlTab = searchParams.get("tab") as Tab | null;
const urlMember = searchParams.get("member");
const [tab, setTab] = useState<Tab>(urlTab ?? "profile");
// Only clinic owners/admins manage clinic-wide settings (care team, records,
// signing, developers). Everyone else gets their personal tabs (profile, AI).
@@ -71,7 +76,9 @@ export function SettingsView() {
{activeTab === "ai" && <AIPanel />}
{activeTab === "records" && <RecordsPanel />}
{activeTab === "signing" && <SigningPanel />}
{activeTab === "careTeam" && <CareTeamPanel />}
{activeTab === "careTeam" && (
<CareTeamPanel initialMemberId={urlMember ?? undefined} />
)}
{activeTab === "integrations" && <IntegrationsPanel />}
{activeTab === "developers" && <DevelopersPanel />}
</div>
@@ -21,7 +21,6 @@ import Image from "next/image";
import { useTranslation } from "react-i18next";
import type { Route } from "./nav-main";
import DashboardNavigation from "@/components/sidebar-02/nav-main";
import { NavChatHistory } from "@/components/sidebar-02/nav-chat-history";
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
import { NavUser } from "@/components/sidebar-02/nav-user";
import { useCallInvites } from "@/components/meetings/use-call-invites";
@@ -103,7 +102,6 @@ export function DashboardSidebar() {
</SidebarHeader>
<SidebarContent className="gap-4 px-2 py-4">
<DashboardNavigation routes={dashboardRoutes} />
{aiAllowed && <NavChatHistory />}
</SidebarContent>
<SidebarFooter className="p-2">
<NavUser />
@@ -1,82 +0,0 @@
"use client";
import { Trash2 } from "lucide-react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { type MouseEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSidebar } from "@/components/ui/sidebar";
import {
deleteThread,
listThreads,
THREADS_CHANGED_EVENT,
type ThreadSummary,
} from "@/lib/ai-chat-history";
import { cn } from "@/lib/utils";
// Claude-style list of saved AI chats in the sidebar. Refreshes when a chat is
// saved/deleted (via the THREADS_CHANGED_EVENT). Hidden when collapsed or empty.
export function NavChatHistory() {
const { t } = useTranslation();
const { state } = useSidebar();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeThread = searchParams.get("thread");
const [threads, setThreads] = useState<ThreadSummary[]>([]);
useEffect(() => {
const refresh = () => {
listThreads()
.then(setThreads)
.catch(() => {
/* not signed in / no clinic — just show nothing */
});
};
refresh();
window.addEventListener(THREADS_CHANGED_EVENT, refresh);
return () => window.removeEventListener(THREADS_CHANGED_EVENT, refresh);
}, []);
if (state === "collapsed" || threads.length === 0) return null;
const remove = async (event: MouseEvent, id: string) => {
event.preventDefault();
event.stopPropagation();
setThreads((prev) => prev.filter((x) => x.id !== id));
await deleteThread(id).catch(() => {
/* ignore */
});
};
return (
<div className="flex flex-col gap-0.5 px-2">
<span className="px-2 py-1 font-medium text-muted-foreground text-xs">
{t("chat.history.title")}
</span>
{threads.map((thread) => {
const active = pathname === "/" && activeThread === thread.id;
return (
<Link
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent",
active ? "bg-accent text-foreground" : "text-muted-foreground",
)}
href={`/?thread=${thread.id}`}
key={thread.id}
>
<span className="min-w-0 flex-1 truncate">{thread.title}</span>
<button
aria-label={t("chat.history.delete")}
className="shrink-0 opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
onClick={(event) => remove(event, thread.id)}
type="button"
>
<Trash2 className="size-3.5" />
</button>
</Link>
);
})}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { apiFetch } from "@/lib/api-client";
// Deployment-wide email provider config (Settings → Developers). The API key is
// never returned — `hasCredentials` only signals whether one is stored.
export type EmailProvider = "none" | "smtp" | "resend" | "postmark" | "sendgrid";
export type EmailConfig = {
provider: EmailProvider;
fromAddress: string;
hasCredentials: boolean;
};
export function getEmailConfig(): Promise<EmailConfig> {
return apiFetch<EmailConfig>("/api/settings/email");
}
export function saveEmailConfig(input: {
provider: EmailProvider;
fromAddress: string;
// undefined = leave existing key; "" = clear; string = set/replace.
credentials?: string;
}): Promise<EmailConfig> {
return apiFetch<EmailConfig>("/api/settings/email", {
method: "PUT",
body: JSON.stringify(input),
});
}
export function testEmailConfig(): Promise<{ ok: boolean; to: string }> {
return apiFetch("/api/settings/email/test", { method: "POST" });
}
+59 -5
View File
@@ -831,7 +831,12 @@
"apptPatient": "Patient",
"apptStatus": "Status"
},
"startCall": "Start a call with {{name}}"
"startCall": "Start a call with {{name}}",
"system": {
"label": "System message",
"passwordResetTitle": "Password reset requested",
"passwordResetBody": "{{name}} forgot their password. Tap to open their settings and set a new one."
}
},
"analysis": {
"title": "Overview",
@@ -1531,7 +1536,33 @@
"generateToken": "Generate token",
"resourcesTitle": "Resources",
"resourcesDescription": "Where to learn more",
"resourcesBody": "The API reference — covering patients, appointments, prescriptions, tasks, messaging, activity and analytics — lives in the project documentation under docs/api. temetro is open source, so the route handlers in backend/src/routes are the authoritative spec."
"resourcesBody": "The API reference — covering patients, appointments, prescriptions, tasks, messaging, activity and analytics — lives in the project documentation under docs/api. temetro is open source, so the route handlers in backend/src/routes are the authoritative spec.",
"email": {
"title": "Email provider",
"description": "How temetro sends verification, password-reset and invitation emails. This is a deployment-wide setting.",
"provider": "Provider",
"providers": {
"none": "Not configured",
"smtp": "SMTP",
"resend": "Resend",
"postmark": "Postmark",
"sendgrid": "SendGrid"
},
"from": "From address",
"apiKey": "API key",
"apiKeySet": "•••••••• (saved — leave blank to keep)",
"apiKeyPlaceholder": "Paste your provider API key",
"smtpHint": "SMTP uses the SMTP_HOST/PORT/USER/PASS environment variables on the server.",
"save": "Save",
"saving": "Saving…",
"savedTitle": "Email settings saved",
"savedFailed": "Couldn't save email settings.",
"test": "Send test",
"testing": "Sending…",
"testSentTitle": "Test email sent",
"testSentBody": "Sent a test email to {{to}}.",
"testFailed": "Couldn't send the test email."
}
},
"profile": {
"sectionTitle": "Clinician profile",
@@ -1657,7 +1688,20 @@
"read": "View",
"write": "Edit",
"delete": "Delete"
}
},
"resetPassword": "Reset password",
"resetPasswordHint": "Set a new password for this employee (e.g. they forgot theirs and no email provider is configured).",
"newPassword": "New password (min 12 chars)",
"confirmPassword": "Confirm password",
"setPassword": "Set password",
"pwTooShortTitle": "Password too short",
"pwTooShortBody": "Use at least 12 characters.",
"pwMismatchTitle": "Passwords don't match",
"pwMismatchBody": "Re-enter the same password in both fields.",
"pwUpdatedTitle": "Password updated",
"pwUpdatedBody": "{{name}} can now sign in with the new password.",
"pwFailedTitle": "Couldn't set password",
"pwFailedBody": "Please try again."
},
"remove": {
"title": "Remove team member?",
@@ -1810,14 +1854,24 @@
"date": "Date",
"time": "Time",
"reason": "Reason for visit (optional)",
"reasonPlaceholder": "e.g. Follow-up, check-up"
"reasonPlaceholder": "e.g. Follow-up, check-up",
"sex": "Sex",
"sexMale": "Male",
"sexFemale": "Female",
"age": "Age"
},
"book": {
"title": "Book an appointment",
"submit": "Request appointment",
"successTitle": "You're booked",
"successBody": "Your appointment is set for {{date}} at {{time}}. Please check in at the front desk.",
"errorGeneric": "Couldn't book the appointment. Please try again or ask the front desk."
"errorGeneric": "Couldn't book the appointment. Please try again or ask the front desk.",
"mode": {
"returning": "Returning patient",
"new": "New patient"
},
"newFileNote": "Your new file number is {{file}} — keep it for next time.",
"slotTaken": "That time is already taken. Please choose another."
},
"results": {
"title": "View my results",
+7 -1
View File
@@ -26,7 +26,13 @@ export type MessageAttachment =
mimeType: string;
size: number;
}
| { kind: "appointment"; appointment: AppointmentSnapshot };
| { kind: "appointment"; appointment: AppointmentSnapshot }
| {
kind: "passwordReset";
userId: string;
userName: string;
userEmail: string;
};
export type ConversationMessage = {
id: string;
+16
View File
@@ -65,6 +65,22 @@ export function getPortalClinic(clinic: string): Promise<PortalClinic> {
return portalFetch<PortalClinic>(`/${encodeURIComponent(clinic)}`);
}
export type PortalNewPatient = {
name: string;
sex?: string;
age?: number;
};
export function createPortalPatient(
clinic: string,
patient: PortalNewPatient,
): Promise<{ fileNumber: string; name: string }> {
return portalFetch(`/${encodeURIComponent(clinic)}/patients`, {
method: "POST",
body: JSON.stringify(patient),
});
}
export function bookPortalAppointment(
clinic: string,
booking: PortalBooking,
+12
View File
@@ -15,6 +15,18 @@ export function listProviders(): Promise<Provider[]> {
return apiFetch<Provider[]>("/api/staff/providers");
}
// Set a member's password directly (owner/admin only) — used when an employee
// forgot it and no email provider is configured.
export function setStaffPassword(
userId: string,
newPassword: string,
): Promise<{ ok: boolean }> {
return apiFetch(`/api/staff/${encodeURIComponent(userId)}/password`, {
method: "PATCH",
body: JSON.stringify({ newPassword }),
});
}
// Update a member's clinical specialty (owner/admin only). Pass null to clear.
export function updateStaffSpecialty(
userId: string,