mirror of
https://github.com/temetro/temetro.git
synced 2026-08-20 23:22:18 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user