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
+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,