Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export function GeneralProfileFormProvider({
children,
}: {
initial: GeneralProfileSchemaType;
onSubmit: (data: GeneralProfileSchemaType) => void;
onSubmit: (
data: GeneralProfileSchemaType,
form: UseFormReturn<GeneralProfileSchemaType>,
) => void | Promise<void>;
children: React.ReactNode;
}) {
const form = useForm<GeneralProfileSchemaType>({
Expand All @@ -26,7 +29,9 @@ export function GeneralProfileFormProvider({

return (
<GeneralProfileFormContext.Provider value={{ form }}>
<form onSubmit={form.handleSubmit(onSubmit)}>{children}</form>
<form onSubmit={form.handleSubmit((data) => onSubmit(data, form))}>
{children}
</form>
</GeneralProfileFormContext.Provider>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -55,7 +57,11 @@ export function GeneralProfileSection({
control={control}
label="Email"
type="email"
disabled
description={
pendingEmailChange
? `Pending change requested to ${pendingEmailChange}`
: undefined
}
/>

<div className="flex justify-end">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,57 @@
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;
};

async function emailSubmit(
data: GeneralProfileSchemaType,
session: Session | null,
): Promise<boolean> {
const nextEmail = data.email.trim();
const currentEmail = session?.user?.email?.trim();
if (nextEmail && currentEmail && nextEmail !== currentEmail) {
const changeEmailResult = await authClient.changeEmail({
newEmail: nextEmail,
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));
}

toast.success(
`Profile updated. A confirmation link was sent to your current email. After you click it, we will send another verification email to ${nextEmail}.`,
);
return true;
}

return false;
}

export function useGeneralProfileSubmit() {
const { refetch: refetchSession } = authClient.useSession();
const { data: session, refetch: refetchSession } = authClient.useSession();
const { uploadImage } = useImageUpload();

const mutation = useMutation({
Expand All @@ -23,28 +68,82 @@ export function useGeneralProfileSubmit() {
}

// 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 emailSubmit(data, session);
} 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 };
}
return {
didRequestEmailChange,
currentSessionEmail: session?.user?.email?.trim(),
requestedEmail: data.email.trim(),
};
},
onError: (error: Error) => {
toast.error(error.message);
},
});

const [pendingEmailChange, setPendingEmailChange] = useState<string | null>(
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 ?? data.email.trim());

const currentSessionEmail = result.currentSessionEmail;
if (!currentSessionEmail) return;

form.setValue("email", currentSessionEmail, {
shouldDirty: false,
shouldTouch: false,
shouldValidate: true,
});
},
isPending: mutation.isPending,
pendingEmailChange,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand All @@ -73,6 +76,7 @@ export function ProfileSettingsContent() {
<GeneralProfileSection
fallbackName={user.name}
isPending={isGeneralPending}
pendingEmailChange={pendingEmailChange}
/>
</GeneralProfileFormProvider>

Expand Down
45 changes: 42 additions & 3 deletions src/lib/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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 { betterAuth, type BetterAuthOptions } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
Expand All @@ -37,6 +38,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",
Expand All @@ -56,7 +83,7 @@ export const auth = betterAuth({
databaseHooks: {
user: {
create: {
after: async (user) => {
after: async (user: { role: Role; id: string }) => {
switch (user.role) {
case Role.volunteer:
await db.insert(volunteer).values({ userId: user.id });
Expand All @@ -74,7 +101,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({
Expand All @@ -86,7 +119,13 @@ export const auth = betterAuth({
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
sendVerificationEmail: async ({
user,
url,
}: {
user: { name: string; email: string };
url: string;
}) => {
const scope = createRequestScope();
const { emailService } = scope.cradle;
const { html, text } = await renderVerifyEmail({
Expand Down
59 changes: 59 additions & 0 deletions src/server/emails/templates/request-change-email.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EmailLayout preview="Update your email address">
<Heading
as="h2"
className="m-0 mb-2 font-display text-xl font-semibold text-foreground"
>
Update Your Email Address
</Heading>
<Text className="mt-0 text-base leading-relaxed text-muted-foreground">
Hi{userName ? ` ${userName}` : ""},
</Text>
<Text className="text-base leading-relaxed text-muted-foreground">
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.
</Text>
<Text className="text-base leading-relaxed text-muted-foreground">
After you click this confirmation link, we will send a verification
email to {newEmail}. You must verify that inbox before the change is
completed.
</Text>
<Section className="my-6 text-center">
<Button
href={url}
className="rounded-lg bg-primary px-8 py-3 text-center text-base font-normal text-primary-foreground no-underline"
>
Confirm Change
</Button>
</Section>
<Text className="break-all text-sm leading-relaxed text-muted-foreground">
Or copy and paste this link: {url}
</Text>
<Text className="text-sm leading-relaxed text-muted-foreground">
If you did not request a change, you can disregard this email.
</Text>
</EmailLayout>
);
}

export default RequestChangeEmail;

export function renderRequestChangeEmail(props: RequestChangeEmailProps) {
return renderEmail(<RequestChangeEmail {...props} />);
}
2 changes: 1 addition & 1 deletion src/server/services/jobService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ export class JobService implements IJobService {
...definition.retryOpts,
});
}

const worker = async (job: any) => {
const jobItems = Array.isArray(job) ? job : [job];

Expand Down