diff --git a/backend/src/index.ts b/backend/src/index.ts index 12b6c88..b42ed76 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import { env } from "./env.js"; import { errorHandler, notFound } from "./middleware/error.js"; import { initRealtime } from "./realtime.js"; import { activityRouter } from "./routes/activity.js"; +import { authHelpersRouter } from "./routes/auth-helpers.js"; import { aiRouter } from "./routes/ai.js"; import { analyticsRouter } from "./routes/analytics.js"; import { attachmentsRouter } from "./routes/attachments.js"; @@ -86,6 +87,7 @@ app.use("/api/ai", aiRouter); app.use("/api/chat", chatRouter); app.use("/api/integrations", integrationsRouter); app.use("/api/portal", portalRouter); +app.use("/api/auth-helpers", authHelpersRouter); app.use(notFound); app.use(errorHandler); diff --git a/backend/src/routes/auth-helpers.ts b/backend/src/routes/auth-helpers.ts new file mode 100644 index 0000000..a179526 --- /dev/null +++ b/backend/src/routes/auth-helpers.ts @@ -0,0 +1,48 @@ +import { eq } from "drizzle-orm"; +import { Router } from "express"; +import { z } from "zod"; + +import { auth } from "../auth.js"; +import { db } from "../db/index.js"; +import { user } from "../db/schema/auth.js"; + +// Public auth helpers that sit alongside Better Auth's own /api/auth handler. +// +// Better Auth's password-reset endpoint is keyed by email, but staff +// provisioned by an admin sign in with a *username* (and may only have a +// synthetic `username@slug.temetro.local` address). This lets them start a reset +// by username: we resolve the username to its account, then hand off to the +// normal reset flow (which emails a link if a provider is configured, or alerts +// the clinic admins otherwise — see src/auth.ts sendResetPassword). +export const authHelpersRouter = Router(); + +const resetByUsernameSchema = z.object({ + username: z.string().trim().min(1).max(64), + redirectTo: z.string().trim().max(2048).optional(), +}); + +// POST /api/auth-helpers/reset-by-username +// Always responds 200 with a generic body — never reveals whether the username +// exists (avoids account enumeration) and never echoes the resolved email. +authHelpersRouter.post("/reset-by-username", async (req, res, next) => { + try { + const { username, redirectTo } = resetByUsernameSchema.parse(req.body); + + const [account] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.username, username.toLowerCase())) + .limit(1); + + if (account?.email) { + // Reuse Better Auth's reset flow so the same dispatch/fallback logic runs. + await auth.api.requestPasswordReset({ + body: { email: account.email, redirectTo }, + }); + } + + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); diff --git a/backend/src/services/messaging.ts b/backend/src/services/messaging.ts index 3568f5e..8c82954 100644 --- a/backend/src/services/messaging.ts +++ b/backend/src/services/messaging.ts @@ -145,10 +145,14 @@ async function buildSummaries( : (others[0]?.name ?? "Conversation")); const lastMessage = lastByConv.get(b.convId) ?? null; const unreadCount = unreadByConv.get(b.convId) ?? 0; + // A one-way System notice (e.g. forgot-password alerts): the reserved system + // user is a participant. The UI hides the composer/call and styles it apart. + const isSystem = participants.some((p) => p.id === SYSTEM_USER_ID); return { id: b.convId, name: displayName, isGroup: b.isGroup, + isSystem, participants, lastMessage, unread: unreadCount > 0, diff --git a/backend/src/types/messaging.ts b/backend/src/types/messaging.ts index 8452dc6..60f63cf 100644 --- a/backend/src/types/messaging.ts +++ b/backend/src/types/messaging.ts @@ -50,6 +50,7 @@ export type ConversationSummary = { id: string; name: string; // display name: group name, or the other participant for a DM isGroup: boolean; + isSystem: boolean; // a one-way System notice (no replies / calls) participants: Participant[]; lastMessage: ConversationMessage | null; unread: boolean; diff --git a/frontend/app/(auth)/forgot-password/page.tsx b/frontend/app/(auth)/forgot-password/page.tsx index c10b7c2..7b2dac9 100644 --- a/frontend/app/(auth)/forgot-password/page.tsx +++ b/frontend/app/(auth)/forgot-password/page.tsx @@ -7,12 +7,19 @@ import { useTranslation } from "react-i18next"; import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; import { authClient } from "@/lib/auth-client"; +import { requestResetByUsername } from "@/lib/auth-helpers"; import { notify } from "@/lib/toast"; +type Mode = "email" | "username"; + export default function ForgotPasswordPage() { const { t } = useTranslation(); - const [email, setEmail] = useState(""); + // Owners reset by the email they signed up with; admin-provisioned staff reset + // by their username (their email may be a synthetic placeholder). + const [mode, setMode] = useState("email"); + const [identifier, setIdentifier] = useState(""); const [sent, setSent] = useState(false); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -23,18 +30,33 @@ export default function ForgotPasswordPage() { setSubmitting(true); setError(null); - const { error: err } = await authClient.requestPasswordReset({ - email: email.trim(), - redirectTo: `${window.location.origin}/reset-password`, - }); + const redirectTo = `${window.location.origin}/reset-password`; - setSubmitting(false); - if (err) { - const message = err.message ?? t("auth.forgotPassword.error"); - setError(message); - notify.error(t("auth.forgotPassword.failedToastTitle"), message); - return; + if (mode === "email") { + const { error: err } = await authClient.requestPasswordReset({ + email: identifier.trim(), + redirectTo, + }); + setSubmitting(false); + if (err) { + const message = err.message ?? t("auth.forgotPassword.error"); + setError(message); + notify.error(t("auth.forgotPassword.failedToastTitle"), message); + return; + } + } else { + try { + await requestResetByUsername(identifier.trim(), redirectTo); + } catch { + setSubmitting(false); + const message = t("auth.forgotPassword.error"); + setError(message); + notify.error(t("auth.forgotPassword.failedToastTitle"), message); + return; + } + setSubmitting(false); } + notify.success( t("auth.forgotPassword.sentToastTitle"), t("auth.forgotPassword.sentToastBody"), @@ -54,20 +76,50 @@ export default function ForgotPasswordPage() { > {sent ? ( - {t("auth.forgotPassword.sent", { email })} + {mode === "email" + ? t("auth.forgotPassword.sent", { email: identifier }) + : t("auth.forgotPassword.sentUsername")} ) : (
{error && {error}} - + { + setMode(value as Mode); + setError(null); + setIdentifier(""); + }} + value={mode} + > + + + {t("auth.forgotPassword.modeEmail")} + + + {t("auth.forgotPassword.modeUsername")} + + + + setEmail(e.target.value)} - placeholder={t("auth.forgotPassword.emailPlaceholder")} + autoComplete={mode === "email" ? "email" : "username"} + id="identifier" + onChange={(e) => setIdentifier(e.target.value)} + placeholder={ + mode === "email" + ? t("auth.forgotPassword.emailPlaceholder") + : t("auth.forgotPassword.usernamePlaceholder") + } required - type="email" - value={email} + type={mode === "email" ? "email" : "text"} + value={identifier} /> + {count > 0 && ( + + + {count} + + )} + + + ); }) )} @@ -435,6 +465,35 @@ export function MeetingsView() { + !open && setRoomToDelete(null)} + open={roomToDelete !== null} + > + + + {t("meetings.deleteRoomConfirmTitle")} + + {t("meetings.deleteRoomConfirmBody", { + name: roomToDelete?.name ?? "", + })} + + + + }> + {t("meetings.cancel")} + + + + + + - + {c.isSystem ? ( + + ) : ( + + )}