From bc1f11a9013bb7eef1e98cba800d957d83a26c6b Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sun, 12 Apr 2026 02:47:17 +0000 Subject: [PATCH] feat(GRO-565): Better Auth Phase 3 - password change, OIDC discovery, session cleanup, email verification Co-Authored-By: Paperclip --- apps/api/src/lib/auth.ts | 71 ++++++++++++++++--- apps/api/src/services/reminders.ts | 14 ++++ apps/web/src/lib/auth-client.ts | 2 +- .../src/portal/sections/AccountSettings.tsx | 35 +++++++-- 4 files changed, 105 insertions(+), 17 deletions(-) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index bb68b23..d88b91c 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -3,6 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { genericOAuth } from "better-auth/plugins"; import { getDb, authProviderConfig, eq } from "@groombook/db"; import { decryptSecret } from "@groombook/db"; +import { sendEmail } from "../services/email.js"; const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; @@ -176,6 +177,52 @@ export async function initAuth(): Promise { const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); + // Fetch OIDC discovery document to derive canonical provider URLs. + // Replace the host of token/userinfo endpoints with internalBaseUrl when set, + // while keeping authorizationUrl public for browser redirects. + const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`; + let oidcConfig: Record = {}; + try { + const discoveryRes = await fetch(discoveryUrlStr); + if (discoveryRes.ok) { + const discovery = await discoveryRes.json() as { + authorization_endpoint?: string; + token_endpoint?: string; + userinfo_endpoint?: string; + }; + const replaceHost = (url: string, newHost: string) => { + try { + const parsed = new URL(url); + const newParsed = new URL(newHost); + return `${newParsed.origin}${parsed.pathname}${parsed.search}`; + } catch { + return url; + } + }; + const authzUrl = discovery.authorization_endpoint; + const tokenUrl = discovery.token_endpoint; + const userInfoUrl = discovery.userinfo_endpoint; + if (authzUrl && tokenUrl && userInfoUrl) { + oidcConfig = { + authorizationUrl: authzUrl, + tokenUrl: providerConfig.internalBaseUrl + ? replaceHost(tokenUrl, providerConfig.internalBaseUrl) + : tokenUrl, + userInfoUrl: providerConfig.internalBaseUrl + ? replaceHost(userInfoUrl, providerConfig.internalBaseUrl) + : userInfoUrl, + }; + console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId); + } else { + console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only"); + } + } else { + console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`); + } + } catch (err) { + console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`); + } + // Build Better-Auth instance using resolved config authInstance = betterAuth({ database: drizzleAdapter(db, { @@ -192,6 +239,19 @@ export async function initAuth(): Promise { account: { storeStateStrategy: "cookie" as const, }, + emailAndPassword: { + enabled: true, + emailVerification: { + sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => { + await sendEmail({ + to: user.email, + subject: "Verify your GroomBook email", + text: `Click the link to verify your email: ${url}`, + html: `

Click the link to verify your email:

${url}`, + }); + }, + }, + }, plugins: [ genericOAuth({ config: [ @@ -199,15 +259,8 @@ export async function initAuth(): Promise { providerId: providerConfig.providerId, clientId: providerConfig.clientId, clientSecret: providerConfig.clientSecret, - ...(providerConfig.internalBaseUrl - ? { - authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`, - tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`, - userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`, - } - : { - discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`, - }), + discoveryUrl: discoveryUrlStr, + ...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}), scopes: providerConfig.scopes.split(" ").filter(Boolean), }, ], diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 3bbfae0..442b6c3 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -12,6 +12,7 @@ import { services, staff, reminderLogs, + session, } from "@groombook/db"; import { buildReminderEmail, @@ -155,6 +156,19 @@ export function startReminderScheduler(): void { runReminderCheck().catch((err) => { console.error("[reminders] Error during reminder check:", err); }); + runSessionCleanup().catch((err) => { + console.error("[reminders] Error during session cleanup:", err); + }); }); console.log("[reminders] Reminder scheduler started"); } + +// Deletes expired sessions from the database. +// Runs every minute alongside reminder checks. +export async function runSessionCleanup(): Promise { + const db = getDb(); + const now = new Date(); + await db + .delete(session) + .where(lt(session.expiresAt, now)); +} diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 12ff8ed..6a9939a 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -4,4 +4,4 @@ export const authClient = createAuthClient({ baseURL: import.meta.env.VITE_API_URL ?? "", }); -export const { signIn, signOut, useSession } = authClient; \ No newline at end of file +export const { signIn, signOut, useSession, changePassword } = authClient; \ No newline at end of file diff --git a/apps/web/src/portal/sections/AccountSettings.tsx b/apps/web/src/portal/sections/AccountSettings.tsx index eb69ed0..7957c91 100644 --- a/apps/web/src/portal/sections/AccountSettings.tsx +++ b/apps/web/src/portal/sections/AccountSettings.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react"; import { PetForm } from "./PetForm.js"; +import { authClient } from "../../lib/auth-client.js"; interface Props { sessionId: string | null; @@ -148,9 +149,11 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) { const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); const passwordsMatch = newPassword === confirmPassword; - const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch; + const canSubmit = newPassword.length > 0 && passwordsMatch && !loading; if (readOnly) { return ( @@ -160,17 +163,34 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) { ); } - function handleSubmit() { + async function handleSubmit() { if (!canSubmit) return; if (newPassword !== confirmPassword) { setError("Passwords do not match."); return; } - // TODO: Wire up to actual password-change API endpoint once backend support exists setError(null); - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); + setLoading(true); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (authClient as any).changePassword({ + currentPassword, + newPassword, + }); + if (result.error) { + setError(result.error.message ?? "Failed to change password."); + } else { + setSuccess(true); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setTimeout(() => setSuccess(false), 4000); + } + } catch { + setError("An unexpected error occurred."); + } finally { + setLoading(false); + } } return ( @@ -205,12 +225,13 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) { /> {error &&

{error}

} + {success &&

Password updated successfully.

}