Skip to content

Commit

Permalink
✨ Enable changing user email address (#1493)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukevella authored Jan 13, 2025
1 parent 31dc85b commit 8c77047
Show file tree
Hide file tree
Showing 14 changed files with 411 additions and 22 deletions.
3 changes: 1 addition & 2 deletions apps/web/i18next-scanner.config.js
Original file line number Diff line number Diff line change
@@ -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__",
Expand Down
12 changes: 11 additions & 1 deletion apps/web/public/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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">
Expand All @@ -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}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/app/api/user/verify-email-change/route.ts
Original file line number Diff line number Diff line change
@@ -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));
};
1 change: 1 addition & 0 deletions apps/web/src/trpc/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type TRPCContext = {
locale?: string;
getEmailClient: (locale?: string) => EmailClient;
image?: string;
email?: string;
};
ip?: string;
};
42 changes: 42 additions & 0 deletions apps/web/src/trpc/routers/user.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 8c77047

Please sign in to comment.