diff --git a/src/components/settings/pages/profile/general/general-form-provider.tsx b/src/components/settings/pages/profile/general/general-form-provider.tsx index ec5b667c..410e62a3 100644 --- a/src/components/settings/pages/profile/general/general-form-provider.tsx +++ b/src/components/settings/pages/profile/general/general-form-provider.tsx @@ -14,7 +14,10 @@ export function GeneralProfileFormProvider({ children, }: { initial: GeneralProfileSchemaType; - onSubmit: (data: GeneralProfileSchemaType) => void; + onSubmit: ( + data: GeneralProfileSchemaType, + form: UseFormReturn, + ) => void | Promise; children: React.ReactNode; }) { const form = useForm({ @@ -26,7 +29,9 @@ export function GeneralProfileFormProvider({ return ( -
{children}
+
onSubmit(data, form))}> + {children} +
); } diff --git a/src/components/settings/pages/profile/general/general-profile-section.tsx b/src/components/settings/pages/profile/general/general-profile-section.tsx index 2812ec84..42c4805b 100644 --- a/src/components/settings/pages/profile/general/general-profile-section.tsx +++ b/src/components/settings/pages/profile/general/general-profile-section.tsx @@ -11,9 +11,11 @@ import { Button } from "@/components/primitives/button"; export function GeneralProfileSection({ fallbackName, isPending, + pendingEmailChange, }: { fallbackName: string; isPending: boolean; + pendingEmailChange?: string | null; }) { const { form: { control, watch, setValue }, @@ -55,7 +57,11 @@ export function GeneralProfileSection({ control={control} label="Email" type="email" - disabled + description={ + pendingEmailChange + ? `Pending change requested to ${pendingEmailChange}` + : undefined + } />
diff --git a/src/components/settings/pages/profile/general/hooks/use-general-profile-submit.ts b/src/components/settings/pages/profile/general/hooks/use-general-profile-submit.ts index 0eb7e7ef..defbcf3b 100644 --- a/src/components/settings/pages/profile/general/hooks/use-general-profile-submit.ts +++ b/src/components/settings/pages/profile/general/hooks/use-general-profile-submit.ts @@ -1,50 +1,177 @@ import { useMutation } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { authClient } from "@/lib/auth/client"; import { getBetterAuthErrorMessage } from "@/lib/auth/extensions/get-better-auth-error"; import { useImageUpload } from "@/hooks/use-image-upload"; import type { GeneralProfileSchemaType } from "../schema"; +import type { Session } from "@/lib/auth"; + +type GeneralProfileFormApi = { + setValue: ( + name: "email", + value: string, + options?: { + shouldDirty?: boolean; + shouldTouch?: boolean; + shouldValidate?: boolean; + }, + ) => void; +}; + +type SubmitResult = { + didRequestEmailChange: boolean; + currentSessionEmail?: string; + requestedEmail?: string; +}; + +function getRequestedEmail(data: GeneralProfileSchemaType): string { + return data.email.trim(); +} + +function getCurrentSessionEmail(session: Session | null): string { + return session?.user?.email?.trim() ?? ""; +} + +function getImageKeyFromValue(image: string | null | undefined): string | null { + if (!image) return null; + const parts = image.split("/"); + return parts[parts.length - 1] ?? image; +} + +function buildProfileUpdateSuccessMessage(requestedEmail?: string): string { + if (!requestedEmail) { + return "Your profile has been successfully updated!"; + } + + return `Your profile has been successfully updated! A confirmation link was sent to your current email. After you click it, we will send a verification email to ${requestedEmail}.`; +} + +async function requestEmailChangeIfNeeded({ + requestedEmail, + currentSessionEmail, +}: { + requestedEmail: string; + currentSessionEmail: string; +}): Promise { + if (!requestedEmail || !currentSessionEmail) return false; + if (requestedEmail === currentSessionEmail) return false; + + const changeEmailResult = await authClient.changeEmail({ + newEmail: requestedEmail, + callbackURL: window.location.origin, + }); + + if (!changeEmailResult) { + throw new Error("Profile updated, but email change could not be started. Please try again."); + } + if (changeEmailResult.error) { + throw new Error(getBetterAuthErrorMessage(changeEmailResult.error.code)); + } + return true; +} export function useGeneralProfileSubmit() { - const { refetch: refetchSession } = authClient.useSession(); + const { data: session, refetch: refetchSession } = authClient.useSession(); const { uploadImage } = useImageUpload(); const mutation = useMutation({ mutationFn: async (data: GeneralProfileSchemaType) => { - let imageKey: string | null = null; + const requestedEmail = getRequestedEmail(data); + const currentSessionEmail = getCurrentSessionEmail(session); + let imageKey = getImageKeyFromValue(data.image); if (data.image?.startsWith("blob:")) { // New image selected - upload it imageKey = await uploadImage(data.image); - } else if (data.image) { - // Existing image URL - extract key from URL or use as-is - const parts = data.image.split("/"); - imageKey = parts[parts.length - 1] ?? data.image; } - // If data.image is null/undefined, imageKey stays null (clear image) - const { error } = await authClient.updateUser({ + const updateResult = await authClient.updateUser({ name: data.firstName, lastName: data.lastName, image: imageKey, }); - if (error) { - throw new Error(getBetterAuthErrorMessage(error.code)); + if (!updateResult) { + throw new Error("Failed to update profile. Please try again."); + } + + if (updateResult.error) { + throw new Error(getBetterAuthErrorMessage(updateResult.error.code)); } + let emailChangeErrorMessage: string | null = null; + let didRequestEmailChange = false; + try { + didRequestEmailChange = await requestEmailChangeIfNeeded({ + requestedEmail, + currentSessionEmail, + }); + } catch (error) { + emailChangeErrorMessage = + error instanceof Error + ? error.message + : "Failed to start email change request."; + } await refetchSession(); - }, - onSuccess: () => { - toast.success("Your profile has been successfully updated!"); + if (emailChangeErrorMessage) { + toast.warning( + `Everything except your email was updated. ${emailChangeErrorMessage}`, + ); + return { didRequestEmailChange: false }; + } + + toast.success( + buildProfileUpdateSuccessMessage( + didRequestEmailChange ? requestedEmail : undefined, + ), + ); + + return { + didRequestEmailChange, + currentSessionEmail, + requestedEmail, + } satisfies SubmitResult; }, onError: (error: Error) => { toast.error(error.message); }, }); + const [pendingEmailChange, setPendingEmailChange] = useState( + null, + ); + + useEffect(() => { + const currentSessionEmail = session?.user?.email?.trim(); + if ( + pendingEmailChange && + currentSessionEmail && + pendingEmailChange === currentSessionEmail + ) { + setPendingEmailChange(null); + } + }, [pendingEmailChange, session?.user?.email]); + return { - onSubmit: mutation.mutateAsync, + onSubmit: async ( + data: GeneralProfileSchemaType, + form: GeneralProfileFormApi, + ) => { + const result = await mutation.mutateAsync(data); + if (!result?.didRequestEmailChange) return; + setPendingEmailChange(result.requestedEmail ?? getRequestedEmail(data)); + + const currentSessionEmail = result.currentSessionEmail; + if (!currentSessionEmail) return; + + form.setValue("email", currentSessionEmail, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: true, + }); + }, isPending: mutation.isPending, + pendingEmailChange, }; } diff --git a/src/components/settings/pages/profile/profile-settings-content.tsx b/src/components/settings/pages/profile/profile-settings-content.tsx index 0132b1ee..37ac150b 100644 --- a/src/components/settings/pages/profile/profile-settings-content.tsx +++ b/src/components/settings/pages/profile/profile-settings-content.tsx @@ -55,8 +55,11 @@ export function ProfileSettingsContent() { const imageUrl = useImageUrl(user?.image); - const { onSubmit: onGeneralSubmit, isPending: isGeneralPending } = - useGeneralProfileSubmit(); + const { + onSubmit: onGeneralSubmit, + isPending: isGeneralPending, + pendingEmailChange, + } = useGeneralProfileSubmit(); const { onSubmit: onVolunteerSubmit, isPending: isVolunteerPending } = useVolunteerProfileSubmit(user?.id ?? ""); @@ -73,6 +76,7 @@ export function ProfileSettingsContent() { diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 4d5882da..11fd7fcc 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -16,7 +16,9 @@ import { } from "@/server/db/schema/auth"; import { user, volunteer } from "@/server/db/schema/user"; import { renderForgotPassword } from "@/server/emails/templates/forgot-password"; +import { renderRequestChangeEmail } from "@/server/emails/templates/request-change-email"; import { renderVerifyEmail } from "@/server/emails/templates/verify-email"; +import { renderVerifyNewEmail } from "@/server/emails/templates/verify-new-email"; import { betterAuth, type BetterAuthOptions } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; @@ -37,6 +39,32 @@ export const auth = betterAuth({ }, lastName: { type: "string" }, }, + changeEmail: { + enabled: true, + sendChangeEmailConfirmation: async ({ + user, + newEmail, + url, + }: { + user: { name: string; email: string }; + newEmail: string; + url: string; + }) => { + const scope = createRequestScope(); + const { emailService } = scope.cradle; + const { html, text } = await renderRequestChangeEmail({ + url, + userName: user.name, + newEmail: newEmail, + }); + await emailService.send( + user.email, + `Confirm your email address change to ${newEmail}`, + text, + html, + ); + }, + }, }, database: drizzleAdapter(db, { provider: "pg", @@ -74,7 +102,13 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: true, - sendResetPassword: async ({ user, url }) => { + sendResetPassword: async ({ + user, + url, + }: { + user: { name: string; email: string }; + url: string; + }) => { const scope = createRequestScope(); const { emailService } = scope.cradle; const { html, text } = await renderForgotPassword({ @@ -86,16 +120,30 @@ export const auth = betterAuth({ }, emailVerification: { sendOnSignUp: true, - sendVerificationEmail: async ({ user, url }) => { + sendVerificationEmail: async ({ + user, + url, + }: { + user: { name: string; email: string; emailVerified: boolean }; + url: string; + }) => { const scope = createRequestScope(); const { emailService } = scope.cradle; - const { html, text } = await renderVerifyEmail({ - url, - userName: user.name, - }); + const isEmailChange = user.emailVerified; + const { html, text } = isEmailChange + ? await renderVerifyNewEmail({ + url, + userName: user.name, + }) + : await renderVerifyEmail({ + url, + userName: user.name, + }); await emailService.send( user.email, - "Verify your email address", + isEmailChange + ? "Verify your new email address" + : "Verify your email address", text, html, ); diff --git a/src/server/emails/templates/request-change-email.tsx b/src/server/emails/templates/request-change-email.tsx new file mode 100644 index 00000000..faee5385 --- /dev/null +++ b/src/server/emails/templates/request-change-email.tsx @@ -0,0 +1,59 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface RequestChangeEmailProps { + url: string; + userName?: string; + newEmail: string; +} + +export function RequestChangeEmail({ + url, + userName, + newEmail, +}: RequestChangeEmailProps) { + return ( + + + Update Your Email Address + + + Hi{userName ? ` ${userName}` : ""}, + + + We received a request to update the email address for your account to{" "} + {newEmail}. Use the button below to confirm the change. This link is + only valid for a limited time. + + + After you click this confirmation link, we will send a verification + email to {newEmail}. You must verify that inbox before the change is + completed. + +
+ +
+ + Or copy and paste this link: {url} + + + If you did not request a change, you can disregard this email. + +
+ ); +} + +export default RequestChangeEmail; + +export function renderRequestChangeEmail(props: RequestChangeEmailProps) { + return renderEmail(); +} diff --git a/src/server/emails/templates/verify-new-email.tsx b/src/server/emails/templates/verify-new-email.tsx new file mode 100644 index 00000000..82588bf4 --- /dev/null +++ b/src/server/emails/templates/verify-new-email.tsx @@ -0,0 +1,49 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface VerifyNewEmailProps { + url: string; + userName?: string; +} + +export function VerifyNewEmail({ url, userName }: VerifyNewEmailProps) { + return ( + + + Verify Your New Email Address + + + Hi{userName ? ` ${userName}` : ""}, + + + You recently requested to change the email address associated with your + BC Brain Wellness Program account. Please verify this new email address + by clicking the button below. + +
+ +
+ + Or copy and paste this link: {url} + + + If you did not request this change, you can safely ignore this email. + +
+ ); +} + +export default VerifyNewEmail; + +export function renderVerifyNewEmail(props: VerifyNewEmailProps) { + return renderEmail(); +} diff --git a/src/server/services/jobService.ts b/src/server/services/jobService.ts index 3d3dde7e..a3eec152 100644 --- a/src/server/services/jobService.ts +++ b/src/server/services/jobService.ts @@ -357,7 +357,7 @@ export class JobService implements IJobService { ...definition.retryOpts, }); } - + const worker = async (job: any) => { const jobItems = Array.isArray(job) ? job : [job];