Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Enable changing user email address #1493

Merged
merged 8 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
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]);
lukevella marked this conversation as resolved.
Show resolved Hide resolved

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
Loading