Enable changing user email address (#1493)

This commit is contained in:
Luke Vella 2025-01-13 19:32:50 +00:00 committed by GitHub
parent 31dc85bbbb
commit 8c77047f74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 411 additions and 22 deletions

View file

@ -1,9 +1,8 @@
const typescriptTransform = require("i18next-scanner-typescript");
module.exports = {
input: ["src/**/*.{ts,tsx}", "!src/utils/auth.ts"],
input: ["src/**/*.{ts,tsx}", "!src/auth.ts"],
options: {
keySeparator: ".",
nsSeparator: false,
defaultNs: "app",
defaultValue: "__STRING_NOT_TRANSLATED__",

View file

@ -279,5 +279,15 @@
"subscribe": "Subscribe",
"cancelAnytime": "Cancel anytime from your <a>billing page</a>.",
"unsubscribeToastTitle": "You have disabled notifications",
"unsubscribeToastDescription": "You will no longer receive notifications for this poll"
"unsubscribeToastDescription": "You will no longer receive notifications for this poll",
"emailChangeSuccess": "Email changed successfully",
"emailChangeSuccessDescription": "Your email has been updated",
"emailChangeFailed": "Email change failed",
"emailChangeInvalidToken": "The verification link is invalid or has expired. Please try again.",
"emailChangeError": "An error occurred while changing your email",
"emailChangeRequestSent": "Verify your new email address",
"emailChangeRequestSentDescription": "To complete the change, please check your email for a verification link.",
"profileEmailAddress": "Email Address",
"profileEmailAddressDescription": "Your email address is used to log in to your account",
"emailAlreadyInUse": "Email already in use. Please try a different one or delete the existing account."
}

View file

@ -0,0 +1,154 @@
import { usePostHog } from "@rallly/posthog/client";
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
import { Button } from "@rallly/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@rallly/ui/form";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Input } from "@rallly/ui/input";
import Cookies from "js-cookie";
import { InfoIcon } from "lucide-react";
import React from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { trpc } from "@/trpc/client";
export const ProfileEmailAddress = () => {
const { user, refresh } = useUser();
const requestEmailChange = trpc.user.requestEmailChange.useMutation();
const posthog = usePostHog();
const form = useForm<{
name: string;
email: string;
}>({
defaultValues: {
name: user.isGuest ? "" : user.name,
email: user.email ?? "",
},
});
const { t } = useTranslation("app");
const { toast } = useToast();
const [didRequestEmailChange, setDidRequestEmailChange] =
React.useState(false);
React.useEffect(() => {
const success = Cookies.get("email-change-success");
const error = Cookies.get("email-change-error");
if (success) {
posthog.capture("email change completed");
toast({
title: t("emailChangeSuccess", {
defaultValue: "Email changed successfully",
}),
description: t("emailChangeSuccessDescription", {
defaultValue: "Your email has been updated",
}),
});
}
if (error) {
posthog.capture("email change failed", { error });
toast({
variant: "destructive",
title: t("emailChangeFailed", {
defaultValue: "Email change failed",
}),
description:
error === "invalidToken"
? t("emailChangeInvalidToken", {
defaultValue:
"The verification link is invalid or has expired. Please try again.",
})
: t("emailChangeError", {
defaultValue: "An error occurred while changing your email",
}),
});
}
}, [posthog, refresh, t, toast]);
const { handleSubmit, formState, reset } = form;
return (
<div className="grid gap-y-4">
<Form {...form}>
<form
onSubmit={handleSubmit(async (data) => {
reset(data);
if (data.email !== user.email) {
posthog.capture("email change requested");
const res = await requestEmailChange.mutateAsync({
email: data.email,
});
if (res.success === false) {
if (res.reason === "emailAlreadyInUse") {
form.setError("email", {
message: t("emailAlreadyInUse", {
defaultValue:
"This email address is already associated with another account. Please use a different email address.",
}),
});
}
} else {
setDidRequestEmailChange(true);
}
}
await refresh();
})}
>
<div className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="email" />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{didRequestEmailChange ? (
<Alert icon={InfoIcon}>
<AlertTitle>
<Trans
i18nKey="emailChangeRequestSent"
defaults="Verify your new email address"
/>
</AlertTitle>
<AlertDescription>
<Trans
i18nKey="emailChangeRequestSentDescription"
defaults="To complete the change, please check your email for a verification link."
/>
</AlertDescription>
</Alert>
) : null}
<div className="mt-4 flex">
<Button
loading={formState.isSubmitting}
variant="primary"
type="submit"
disabled={!formState.isDirty}
>
<Trans i18nKey="save" />
</Button>
</div>
</div>
</form>
</Form>
</div>
);
};

View file

@ -20,6 +20,8 @@ import {
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { ProfileEmailAddress } from "./profile-email-address";
export const ProfilePage = () => {
const { t } = useTranslation();
const { user } = useUser();
@ -78,6 +80,19 @@ export const ProfilePage = () => {
>
<ProfileSettings />
</SettingsSection>
<SettingsSection
title={
<Trans i18nKey="profileEmailAddress" defaults="Email Address" />
}
description={
<Trans
i18nKey="profileEmailAddressDescription"
defaults="Your email address is used to log in to your account"
/>
}
>
<ProfileEmailAddress />
</SettingsSection>
<hr />
<SettingsSection

View file

@ -17,6 +17,7 @@ import { trpc } from "@/trpc/client";
export const ProfileSettings = () => {
const { user, refresh } = useUser();
const changeName = trpc.user.changeName.useMutation();
const form = useForm<{
name: string;
email: string;
@ -33,9 +34,11 @@ export const ProfileSettings = () => {
<Form {...form}>
<form
onSubmit={handleSubmit(async (data) => {
await changeName.mutateAsync({ name: data.name });
await refresh();
if (data.name !== user.name) {
await changeName.mutateAsync({ name: data.name });
}
reset(data);
await refresh();
})}
>
<div className="flex flex-col gap-y-4">
@ -54,20 +57,7 @@ export const ProfileSettings = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="email" />
</FormLabel>
<FormControl>
<Input {...field} disabled={true} />
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 flex">
<Button
loading={formState.isSubmitting}

View file

@ -29,6 +29,7 @@ const handler = (request: Request) => {
isGuest: session.user.email === null,
locale: session.user.locale ?? undefined,
image: session.user.image ?? undefined,
email: session.user.email ?? undefined,
getEmailClient: () =>
getEmailClient(session.user?.locale ?? undefined),
},

View file

@ -0,0 +1,64 @@
import { prisma } from "@rallly/database";
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerSession } from "@/auth";
import { decryptToken } from "@/utils/session";
type EmailChangePayload = {
fromEmail: string;
toEmail: string;
};
const COOKIE_CONFIG = {
path: "/",
httpOnly: false,
secure: false,
expires: new Date(Date.now() + 5 * 1000), // 5 seconds
} as const;
const setEmailChangeCookie = (
type: "success" | "error",
value: string = "1",
) => {
cookies().set(`email-change-${type}`, value, COOKIE_CONFIG);
};
const handleEmailChange = async (token: string) => {
const payload = await decryptToken<EmailChangePayload>(token);
if (!payload) {
setEmailChangeCookie("error", "invalidToken");
return false;
}
await prisma.user.update({
where: { email: payload.fromEmail },
data: { email: payload.toEmail },
});
setEmailChangeCookie("success");
return true;
};
export const GET = async (request: NextRequest) => {
const token = request.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "No token provided" }, { status: 400 });
}
const session = await getServerSession();
if (!session || !session.user.email) {
return NextResponse.redirect(
new URL(`/login?callbackUrl=${request.url}`, request.url),
);
}
await handleEmailChange(token);
return NextResponse.redirect(new URL("/settings/profile", request.url));
};

View file

@ -7,6 +7,7 @@ export type TRPCContext = {
locale?: string;
getEmailClient: (locale?: string) => EmailClient;
image?: string;
email?: string;
};
ip?: string;
};

View file

@ -1,12 +1,14 @@
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils/absolute-url";
import { TRPCError } from "@trpc/server";
import { waitUntil } from "@vercel/functions";
import { z } from "zod";
import { env } from "@/env";
import { getS3Client } from "@/utils/s3";
import { createToken } from "@/utils/session";
import { getSubscriptionStatus } from "@/utils/subscription";
import {
@ -120,6 +122,46 @@ export const user = router({
return { success: true };
}),
requestEmailChange: privateProcedure
.use(rateLimitMiddleware)
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
// check if the email is already in use
const existingUser = await prisma.user.count({
where: { email: input.email },
});
if (existingUser) {
return {
success: false as const,
reason: "emailAlreadyInUse" as const,
};
}
// create a verification token
const token = await createToken(
{
fromEmail: ctx.user.email,
toEmail: input.email,
},
{
ttl: 60 * 10,
},
);
ctx.user.getEmailClient().sendTemplate("ChangeEmailRequest", {
to: input.email,
props: {
verificationUrl: absoluteUrl(
`/api/user/verify-email-change?token=${token}`,
),
fromEmail: ctx.user.email,
toEmail: input.email,
},
});
return { success: true as const };
}),
getAvatarUploadUrl: privateProcedure
.use(rateLimitMiddleware)
.input(

View file

@ -64,13 +64,22 @@ export const proProcedure = t.procedure.use(
export const privateProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => {
if (ctx.user.isGuest) {
const email = ctx.user.email;
if (!email) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required",
});
}
return next();
return next({
ctx: {
user: {
...ctx.user,
email,
},
},
});
}),
);

View file

@ -46,5 +46,12 @@
"common_viewOn": "View on {{domain}}",
"newComment_preview": "Go to your poll to see what they said.",
"newComment_heading": "New Comment",
"newPoll_preview": "Share your participant link to start collecting responses."
"newPoll_preview": "Share your participant link to start collecting responses.",
"changeEmailRequest_preview": "Please verify your email address",
"changeEmailRequest_heading": "Verify Your New Email Address",
"changeEmailRequest_text2": "To complete this change, please click the button below:",
"changeEmailRequest_button": "Verify Email Address",
"changeEmailRequest_subject": "Verify your new email address",
"changeEmailRequest_text3": "This link will expire in 10 minutes. If you did not request this change, please contact support.",
"changeEmailRequest_text1": "We've received a request to change the email address for your account from <b>{{fromEmail}}</b> to <b>{{toEmail}}</b>."
}

View file

@ -0,0 +1,13 @@
import { previewEmailContext } from "../components/email-context";
import { ChangeEmailRequest } from "../templates/change-email-request";
export default function ChangeEmailRequestPreview() {
return (
<ChangeEmailRequest
fromEmail="john@example.com"
toEmail="jane@example.com"
verificationUrl="https://rallly.co/verify-email-change?token=1234567890"
ctx={previewEmailContext}
/>
);
}

View file

@ -1,3 +1,4 @@
import { ChangeEmailRequest } from "./templates/change-email-request";
import { FinalizeHostEmail } from "./templates/finalized-host";
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
import { LoginEmail } from "./templates/login";
@ -17,6 +18,7 @@ const templates = {
NewParticipantConfirmationEmail,
NewPollEmail,
RegisterEmail,
ChangeEmailRequest,
};
export const emailTemplates = Object.keys(templates) as TemplateName[];

View file

@ -0,0 +1,82 @@
import { Section } from "@react-email/section";
import { Trans } from "react-i18next/TransWithoutContext";
import { EmailLayout } from "../components/email-layout";
import { Button, Heading, Text } from "../components/styled-components";
import type { EmailContext } from "../types";
interface ChangeEmailRequestProps {
ctx: EmailContext;
verificationUrl: string;
fromEmail: string;
toEmail: string;
}
export const ChangeEmailRequest = ({
ctx,
verificationUrl,
fromEmail,
toEmail,
}: ChangeEmailRequestProps) => {
return (
<EmailLayout
ctx={ctx}
preview={ctx.t("changeEmailRequest_preview", {
ns: "emails",
defaultValue: "Please verify your email address",
})}
>
<Heading>
{ctx.t("changeEmailRequest_heading", {
defaultValue: "Verify Your New Email Address",
ns: "emails",
})}
</Heading>
<Text>
<Trans
i18n={ctx.i18n}
t={ctx.t}
i18nKey="changeEmailRequest_text1"
ns="emails"
defaults="We've received a request to change the email address for your account from <b>{{fromEmail}}</b> to <b>{{toEmail}}</b>."
values={{ fromEmail, toEmail }}
components={{ b: <b /> }}
/>
</Text>
<Text>
{ctx.t("changeEmailRequest_text2", {
defaultValue:
"To complete this change, please click the button below:",
ns: "emails",
})}
</Text>
<Section>
<Button href={verificationUrl}>
{ctx.t("changeEmailRequest_button", {
ns: "emails",
defaultValue: "Verify Email Address",
})}
</Button>
</Section>
<Text light>
{ctx.t("changeEmailRequest_text3", {
ns: "emails",
defaultValue:
"This link will expire in 10 minutes. If you did not request this change, please contact support.",
})}
</Text>
</EmailLayout>
);
};
ChangeEmailRequest.getSubject = (
_props: ChangeEmailRequestProps,
ctx: EmailContext,
) => {
return ctx.t("changeEmailRequest_subject", {
defaultValue: "Verify your new email address",
ns: "emails",
});
};
export default ChangeEmailRequest;