mirror of
https://github.com/temetro/temetro.git
synced 2026-07-27 12:18:56 +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:
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "email_settings" (
|
||||
"id" text PRIMARY KEY DEFAULT 'default' NOT NULL,
|
||||
"provider" text DEFAULT 'none' NOT NULL,
|
||||
"from_address" text DEFAULT '' NOT NULL,
|
||||
"credentials" text,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,13 @@
|
||||
"when": 1781969782874,
|
||||
"tag": "0026_many_wolfsbane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1781973588708,
|
||||
"tag": "0027_romantic_kylun",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+19
-4
@@ -35,10 +35,25 @@ export const auth = betterAuth({
|
||||
maxPasswordLength: 256,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your temetro password",
|
||||
text: `Reset your password by opening this link:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`,
|
||||
// With a provider configured, email the reset link. Otherwise fall back to
|
||||
// alerting the clinic admin(s) so they can set a new password (dynamic
|
||||
// imports keep the Better Auth CLI's static graph minimal at generate time).
|
||||
const { isEmailConfigured } = await import("./services/email-config.js");
|
||||
if (await isEmailConfigured()) {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your temetro password",
|
||||
text: `Reset your password by opening this link:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { notifyAdminsPasswordReset } = await import(
|
||||
"./services/auth-fallback.js"
|
||||
);
|
||||
await notifyAdminsPasswordReset({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
// Deployment-wide email-provider configuration — a single row (id = "default").
|
||||
// Email (verification, password reset, invitations) is sent while the user is
|
||||
// logged out / before any clinic exists, so this can't be per-clinic; it's one
|
||||
// config for the whole deployment, set by any clinic admin from Settings →
|
||||
// Developers. The provider's API key is stored encrypted (lib/crypto.ts).
|
||||
export const emailSettings = pgTable("email_settings", {
|
||||
id: text("id").primaryKey().default("default"),
|
||||
// "none" | "smtp" | "resend" | "postmark" | "sendgrid"
|
||||
provider: text("provider").notNull().default("none"),
|
||||
fromAddress: text("from_address").notNull().default(""),
|
||||
// Encrypted API key (Resend/Postmark/SendGrid). SMTP uses env credentials.
|
||||
credentials: text("credentials"),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export * from "./activity.js";
|
||||
export * from "./messaging.js";
|
||||
export * from "./notifications.js";
|
||||
export * from "./settings.js";
|
||||
export * from "./email-settings.js";
|
||||
export * from "./ai.js";
|
||||
export * from "./ai-chat.js";
|
||||
export * from "./org-ai-policy.js";
|
||||
|
||||
+126
-26
@@ -1,6 +1,7 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { env } from "../env.js";
|
||||
import { getActiveConfig } from "../services/email-config.js";
|
||||
|
||||
type SendArgs = {
|
||||
to: string;
|
||||
@@ -9,10 +10,9 @@ type SendArgs = {
|
||||
html?: string;
|
||||
};
|
||||
|
||||
// Lazily build a transport. With SMTP_HOST configured we send real mail;
|
||||
// otherwise we fall back to logging the message (and any links) to the
|
||||
// server console — zero setup for local / open-source development.
|
||||
const transport = env.SMTP_HOST
|
||||
// SMTP transport (built from env) — used when the active provider is "smtp" or,
|
||||
// for backward compatibility, when SMTP_HOST is set and no provider is chosen.
|
||||
const smtpTransport = env.SMTP_HOST
|
||||
? nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT ?? 587,
|
||||
@@ -24,26 +24,126 @@ const transport = env.SMTP_HOST
|
||||
})
|
||||
: null;
|
||||
|
||||
export async function sendEmail({ to, subject, text, html }: SendArgs): Promise<void> {
|
||||
if (!transport) {
|
||||
console.info(
|
||||
[
|
||||
"",
|
||||
"✉️ [email:console] No SMTP configured — printing instead of sending.",
|
||||
` to: ${to}`,
|
||||
` subject: ${subject}`,
|
||||
` body: ${text}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html: html ?? text,
|
||||
});
|
||||
function logToConsole({ to, subject, text }: SendArgs): void {
|
||||
console.info(
|
||||
[
|
||||
"",
|
||||
"✉️ [email:console] No email provider configured — printing instead of sending.",
|
||||
` to: ${to}`,
|
||||
` subject: ${subject}`,
|
||||
` body: ${text}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
async function sendViaResend(
|
||||
apiKey: string,
|
||||
from: string,
|
||||
{ to, subject, text, html }: SendArgs,
|
||||
): Promise<void> {
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ from, to, subject, text, html: html ?? text }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resend failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function sendViaPostmark(
|
||||
apiKey: string,
|
||||
from: string,
|
||||
{ to, subject, text, html }: SendArgs,
|
||||
): Promise<void> {
|
||||
const res = await fetch("https://api.postmarkapp.com/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Postmark-Server-Token": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
TextBody: text,
|
||||
HtmlBody: html ?? text,
|
||||
MessageStream: "outbound",
|
||||
}),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Postmark failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function sendViaSendgrid(
|
||||
apiKey: string,
|
||||
from: string,
|
||||
{ to, subject, text, html }: SendArgs,
|
||||
): Promise<void> {
|
||||
const res = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
personalizations: [{ to: [{ email: to }] }],
|
||||
from: { email: from },
|
||||
subject,
|
||||
content: [
|
||||
{ type: "text/plain", value: text },
|
||||
{ type: "text/html", value: html ?? text },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`SendGrid failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Send an email via the deployment's configured provider. Falls back to logging
|
||||
// when nothing is configured so local/open-source dev needs zero setup.
|
||||
export async function sendEmail(args: SendArgs): Promise<void> {
|
||||
const cfg = await getActiveConfig();
|
||||
// A real "from": the configured address, else the SMTP default.
|
||||
const from = cfg.fromAddress || env.SMTP_FROM;
|
||||
|
||||
switch (cfg.provider) {
|
||||
case "resend":
|
||||
if (!cfg.credentials) return logToConsole(args);
|
||||
return sendViaResend(cfg.credentials, from, args);
|
||||
case "postmark":
|
||||
if (!cfg.credentials) return logToConsole(args);
|
||||
return sendViaPostmark(cfg.credentials, from, args);
|
||||
case "sendgrid":
|
||||
if (!cfg.credentials) return logToConsole(args);
|
||||
return sendViaSendgrid(cfg.credentials, from, args);
|
||||
case "smtp": {
|
||||
if (!smtpTransport) return logToConsole(args);
|
||||
await smtpTransport.sendMail({
|
||||
from,
|
||||
to: args.to,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
html: args.html ?? args.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
// No provider chosen — honour a pre-existing SMTP env setup if present.
|
||||
if (smtpTransport) {
|
||||
await smtpTransport.sendMail({
|
||||
from,
|
||||
to: args.to,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
html: args.html ?? args.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return logToConsole(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import { organization } from "../db/schema/auth.js";
|
||||
import { appointmentInputSchema } from "../lib/appointment-validation.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { initialsFromName } from "../lib/initials.js";
|
||||
import { patientInputSchema } from "../lib/patient-validation.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { createAppointment, listAppointments } from "../services/appointments.js";
|
||||
import { getPatient } from "../services/patients.js";
|
||||
import { createPatient, getPatient } from "../services/patients.js";
|
||||
|
||||
// Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the
|
||||
// waiting room). Scoped by the clinic slug in the URL — there is no session.
|
||||
@@ -52,6 +53,39 @@ const bookingSchema = z.object({
|
||||
type: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
const newPatientSchema = z.object({
|
||||
name: z.string().trim().min(1, "Your name is required.").max(200),
|
||||
sex: z.string().trim().optional(),
|
||||
age: z.coerce.number().int().min(0).max(150).optional(),
|
||||
});
|
||||
|
||||
// POST /api/portal/:clinic/patients — register a new (demographics-only) patient
|
||||
// from the kiosk so a first-time visitor can get a file number and then book.
|
||||
// Writes only demographics (no clinical PHI) from this unauthenticated surface.
|
||||
portalRouter.post("/:clinic/patients", async (req, res, next) => {
|
||||
try {
|
||||
const clinic = await resolveClinic(req);
|
||||
const body = newPatientSchema.parse(req.body);
|
||||
const input = patientInputSchema.parse({
|
||||
name: body.name,
|
||||
sex: body.sex ?? "M",
|
||||
age: body.age ?? 0,
|
||||
source: "manual",
|
||||
});
|
||||
const created = await createPatient(clinic.id, "", input, true);
|
||||
await recordActivity({
|
||||
orgId: clinic.id,
|
||||
actor: { id: "", name: created.name },
|
||||
action: `Patient portal registration — ${created.name}`,
|
||||
entityType: "patient",
|
||||
entityId: created.fileNumber,
|
||||
});
|
||||
res.status(201).json({ fileNumber: created.fileNumber, name: created.name });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/portal/:clinic/appointments — self-service booking for a registered
|
||||
// patient. Verifies the file number + name, then creates a confirmed appointment
|
||||
// that shows up on the clinic's Appointments page.
|
||||
@@ -84,6 +118,24 @@ portalRouter.post("/:clinic/appointments", async (req, res, next) => {
|
||||
status: "confirmed",
|
||||
source: "manual",
|
||||
});
|
||||
|
||||
// Prevent double-booking the same slot: a provider can't have two
|
||||
// appointments at the same date+time (clinic-wide when the provider is
|
||||
// unknown). Cancelled appointments don't count.
|
||||
const taken = (await listAppointments(clinic.id)).some(
|
||||
(a) =>
|
||||
a.status !== "cancelled" &&
|
||||
a.date === input.date &&
|
||||
a.time === input.time &&
|
||||
(!input.provider || !a.provider || a.provider === input.provider),
|
||||
);
|
||||
if (taken) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"That time slot is already taken. Please choose another time.",
|
||||
);
|
||||
}
|
||||
|
||||
const created = await createAppointment(clinic.id, "", input);
|
||||
await recordActivity({
|
||||
orgId: clinic.id,
|
||||
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { sendEmail } from "../lib/email.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import {
|
||||
type EmailProvider,
|
||||
getPublicConfig,
|
||||
saveConfig,
|
||||
} from "../services/email-config.js";
|
||||
import { createPatient, listPatients } from "../services/patients.js";
|
||||
|
||||
export const settingsRouter = Router();
|
||||
@@ -20,6 +26,75 @@ export const settingsRouter = Router();
|
||||
// no active organization or RBAC permission.
|
||||
settingsRouter.use(requireAuth);
|
||||
|
||||
// --- Email provider (deployment-wide, admin-only) ------------------------
|
||||
// One config for the whole deployment (email is sent while logged out, so it
|
||||
// can't be per-clinic). Gated by `member: ["create"]` — any clinic admin sets
|
||||
// the deployment's provider. The API key is never returned.
|
||||
|
||||
const emailConfigSchema = z.object({
|
||||
provider: z.enum(["none", "smtp", "resend", "postmark", "sendgrid"]),
|
||||
fromAddress: z.string().trim().max(200).default(""),
|
||||
// undefined = leave key as-is; "" = clear; string = set/replace.
|
||||
credentials: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
settingsRouter.get(
|
||||
"/email",
|
||||
requireOrg,
|
||||
requirePermission({ member: ["create"] }),
|
||||
async (_req, res, next) => {
|
||||
try {
|
||||
res.json(await getPublicConfig());
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
settingsRouter.put(
|
||||
"/email",
|
||||
requireOrg,
|
||||
requirePermission({ member: ["create"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const input = emailConfigSchema.parse(req.body);
|
||||
const saved = await saveConfig({
|
||||
provider: input.provider as EmailProvider,
|
||||
fromAddress: input.fromAddress,
|
||||
credentials: input.credentials,
|
||||
});
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Updated email provider — ${saved.provider}`,
|
||||
entityType: "settings",
|
||||
entityId: "email",
|
||||
});
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
settingsRouter.post(
|
||||
"/email/test",
|
||||
requireOrg,
|
||||
requirePermission({ member: ["create"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await sendEmail({
|
||||
to: req.user!.email,
|
||||
subject: "temetro email test",
|
||||
text: `This is a test email from temetro. If you received it, your email provider is configured correctly.`,
|
||||
});
|
||||
res.json({ ok: true, to: req.user!.email });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// --- Records import / export (clinic-wide, admin-only) -------------------
|
||||
// Gated by `member: ["create"]` — the same admin/owner marker the staff route
|
||||
// uses — so only clinic admins can bulk-move records.
|
||||
|
||||
@@ -230,3 +230,42 @@ staffRouter.patch(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Set a member's password directly (admin-driven reset — e.g. the employee
|
||||
// forgot it and no email provider is configured). Owner/admin only, and the
|
||||
// target must be a member of this clinic. Uses Better Auth's internal context to
|
||||
// hash + store the password (the same calls its admin plugin makes), so no admin
|
||||
// plugin is required.
|
||||
const passwordInputSchema = z.object({
|
||||
newPassword: z.string().min(12).max(256),
|
||||
});
|
||||
|
||||
staffRouter.patch(
|
||||
"/:userId/password",
|
||||
requirePermission({ member: ["update"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.params.userId ?? "");
|
||||
const { newPassword } = passwordInputSchema.parse(req.body);
|
||||
|
||||
const [target] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, req.organizationId!),
|
||||
eq(member.userId, userId),
|
||||
),
|
||||
);
|
||||
if (!target) throw new HttpError(404, "Member not found.");
|
||||
|
||||
const ctx = await auth.$context;
|
||||
const hashed = await ctx.password.hash(newPassword);
|
||||
await ctx.internalAdapter.updatePassword(userId, hashed);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { member } from "../db/schema/auth.js";
|
||||
import { emitToUser } from "../realtime.js";
|
||||
import { createSystemMessage } from "./messaging.js";
|
||||
import { createNotification } from "./notifications.js";
|
||||
|
||||
// When an employee asks for a password reset but no email provider is configured,
|
||||
// alert the admin(s) of each clinic the user belongs to: a "System" message card
|
||||
// in Messages + a bell notification, both deep-linking to that member's settings
|
||||
// so an admin can set a new password. The reset URL is never exposed.
|
||||
export async function notifyAdminsPasswordReset(u: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}): Promise<void> {
|
||||
const orgs = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, u.id));
|
||||
const orgIds = [...new Set(orgs.map((o) => o.organizationId))];
|
||||
|
||||
for (const orgId of orgIds) {
|
||||
try {
|
||||
const admins = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgId),
|
||||
inArray(member.role, ["owner", "admin"]),
|
||||
),
|
||||
);
|
||||
const adminIds = admins.map((a) => a.userId).filter((id) => id !== u.id);
|
||||
if (adminIds.length === 0) continue;
|
||||
|
||||
const body = `${u.name} requested a password reset, but no email provider is configured. Reset their password from their settings.`;
|
||||
const { message, recipientIds } = await createSystemMessage(
|
||||
orgId,
|
||||
adminIds,
|
||||
body,
|
||||
{
|
||||
kind: "passwordReset",
|
||||
userId: u.id,
|
||||
userName: u.name,
|
||||
userEmail: u.email,
|
||||
},
|
||||
);
|
||||
for (const rid of recipientIds) emitToUser(rid, "message:new", message);
|
||||
|
||||
for (const rid of adminIds) {
|
||||
const n = await createNotification({
|
||||
orgId,
|
||||
userId: rid,
|
||||
type: "password_reset",
|
||||
text: `${u.name} needs a password reset`,
|
||||
entityType: "user",
|
||||
entityId: u.id,
|
||||
actorName: u.name,
|
||||
});
|
||||
if (n) emitToUser(rid, "notification:new", n);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("password-reset fallback failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { emailSettings } from "../db/schema/email-settings.js";
|
||||
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
|
||||
|
||||
export type EmailProvider = "none" | "smtp" | "resend" | "postmark" | "sendgrid";
|
||||
|
||||
const ROW_ID = "default";
|
||||
|
||||
// Providers that authenticate with an API key (so the UI knows to ask for one).
|
||||
const API_KEY_PROVIDERS: EmailProvider[] = ["resend", "postmark", "sendgrid"];
|
||||
|
||||
export type PublicEmailConfig = {
|
||||
provider: EmailProvider;
|
||||
fromAddress: string;
|
||||
hasCredentials: boolean;
|
||||
};
|
||||
|
||||
export type ActiveEmailConfig = {
|
||||
provider: EmailProvider;
|
||||
fromAddress: string;
|
||||
credentials: string | null;
|
||||
};
|
||||
|
||||
async function getRow() {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(emailSettings)
|
||||
.where(eq(emailSettings.id, ROW_ID))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function getPublicConfig(): Promise<PublicEmailConfig> {
|
||||
const row = await getRow();
|
||||
return {
|
||||
provider: (row?.provider as EmailProvider) ?? "none",
|
||||
fromAddress: row?.fromAddress ?? "",
|
||||
hasCredentials: Boolean(row?.credentials),
|
||||
};
|
||||
}
|
||||
|
||||
// Internal — includes the decrypted API key. Used by lib/email.ts at send time.
|
||||
export async function getActiveConfig(): Promise<ActiveEmailConfig> {
|
||||
const row = await getRow();
|
||||
return {
|
||||
provider: (row?.provider as EmailProvider) ?? "none",
|
||||
fromAddress: row?.fromAddress ?? "",
|
||||
credentials: row?.credentials ? decryptSecret(row.credentials) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// True when the deployment can actually deliver email (a real provider is set,
|
||||
// and API-key providers have a key). SMTP relies on env, treated as configured.
|
||||
export async function isEmailConfigured(): Promise<boolean> {
|
||||
const cfg = await getActiveConfig();
|
||||
if (cfg.provider === "none") return false;
|
||||
if (API_KEY_PROVIDERS.includes(cfg.provider)) return Boolean(cfg.credentials);
|
||||
return true; // smtp
|
||||
}
|
||||
|
||||
export async function saveConfig(input: {
|
||||
provider: EmailProvider;
|
||||
fromAddress: string;
|
||||
// undefined = leave existing key untouched; "" = clear it.
|
||||
credentials?: string;
|
||||
}): Promise<PublicEmailConfig> {
|
||||
const existing = await getRow();
|
||||
const credentials =
|
||||
input.credentials === undefined
|
||||
? (existing?.credentials ?? null)
|
||||
: input.credentials
|
||||
? encryptSecret(input.credentials)
|
||||
: null;
|
||||
|
||||
await db
|
||||
.insert(emailSettings)
|
||||
.values({
|
||||
id: ROW_ID,
|
||||
provider: input.provider,
|
||||
fromAddress: input.fromAddress,
|
||||
credentials,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: emailSettings.id,
|
||||
set: {
|
||||
provider: input.provider,
|
||||
fromAddress: input.fromAddress,
|
||||
credentials,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return getPublicConfig();
|
||||
}
|
||||
@@ -482,6 +482,111 @@ export async function markRead(
|
||||
);
|
||||
}
|
||||
|
||||
// --- System messages -------------------------------------------------------
|
||||
|
||||
// A reserved user that "sends" system messages. It has no account row, so it can
|
||||
// never log in; the messages.senderId FK just needs a user to exist.
|
||||
export const SYSTEM_USER_ID = "system";
|
||||
export const SYSTEM_USER_NAME = "temetro System";
|
||||
|
||||
async function ensureSystemUser(): Promise<void> {
|
||||
await db
|
||||
.insert(user)
|
||||
.values({
|
||||
id: SYSTEM_USER_ID,
|
||||
name: SYSTEM_USER_NAME,
|
||||
email: "system@temetro.local",
|
||||
emailVerified: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// Ensure the per-clinic "System" conversation exists and includes the system
|
||||
// user plus the given recipients, returning its id.
|
||||
async function ensureSystemConversation(
|
||||
orgId: string,
|
||||
recipientIds: string[],
|
||||
): Promise<string> {
|
||||
await ensureSystemUser();
|
||||
const [existing] = await db
|
||||
.select({ id: conversations.id })
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(
|
||||
eq(conversations.organizationId, orgId),
|
||||
eq(conversations.name, "System"),
|
||||
eq(conversations.createdBy, SYSTEM_USER_ID),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
let convId = existing?.id;
|
||||
if (!convId) {
|
||||
const [created] = await db
|
||||
.insert(conversations)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
name: "System",
|
||||
isGroup: true,
|
||||
createdBy: SYSTEM_USER_ID,
|
||||
})
|
||||
.returning();
|
||||
convId = created!.id;
|
||||
}
|
||||
const current = new Set(await participantIds(convId));
|
||||
const toAdd = [SYSTEM_USER_ID, ...recipientIds].filter(
|
||||
(id) => !current.has(id),
|
||||
);
|
||||
if (toAdd.length > 0) {
|
||||
await db
|
||||
.insert(conversationParticipants)
|
||||
.values(
|
||||
toAdd.map((uid) => ({
|
||||
conversationId: convId!,
|
||||
userId: uid,
|
||||
lastReadAt: uid === SYSTEM_USER_ID ? new Date() : null,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return convId;
|
||||
}
|
||||
|
||||
// Post a message from the system user into the clinic's System conversation.
|
||||
export async function createSystemMessage(
|
||||
orgId: string,
|
||||
recipientIds: string[],
|
||||
body: string,
|
||||
attachment: MessageAttachment,
|
||||
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
|
||||
const convId = await ensureSystemConversation(orgId, recipientIds);
|
||||
const now = new Date();
|
||||
const [row] = await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
conversationId: convId,
|
||||
senderId: SYSTEM_USER_ID,
|
||||
body,
|
||||
attachments: [attachment],
|
||||
})
|
||||
.returning();
|
||||
await db
|
||||
.update(conversations)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(conversations.id, convId));
|
||||
return {
|
||||
message: {
|
||||
id: row!.id,
|
||||
conversationId: convId,
|
||||
senderId: SYSTEM_USER_ID,
|
||||
senderName: SYSTEM_USER_NAME,
|
||||
body: row!.body,
|
||||
attachments: row!.attachments,
|
||||
createdAt: row!.createdAt.toISOString(),
|
||||
},
|
||||
recipientIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listClinicMembers(
|
||||
orgId: string,
|
||||
excludeUserId: string,
|
||||
|
||||
@@ -9,7 +9,8 @@ export type ActivityEntityType =
|
||||
| "invoice"
|
||||
| "inventory"
|
||||
| "dispense"
|
||||
| "task";
|
||||
| "task"
|
||||
| "settings";
|
||||
|
||||
export type ActivityEntry = {
|
||||
id: string;
|
||||
|
||||
@@ -25,7 +25,16 @@ export type MessageAttachment =
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
| { kind: "appointment"; appointment: AppointmentSnapshot };
|
||||
| { kind: "appointment"; appointment: AppointmentSnapshot }
|
||||
// A system-generated alert (e.g. an employee asked for a password reset but no
|
||||
// email provider is configured). Rendered as a distinct "System" card that
|
||||
// deep-links an admin to the member's settings.
|
||||
| {
|
||||
kind: "passwordReset";
|
||||
userId: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export type ConversationMessage = {
|
||||
id: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user