mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 10:16:32 +02:00
✨ Enable changing user email address (#1493)
This commit is contained in:
parent
31dc85bbbb
commit
8c77047f74
14 changed files with 411 additions and 22 deletions
|
@ -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__",
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
64
apps/web/src/app/api/user/verify-email-change/route.ts
Normal file
64
apps/web/src/app/api/user/verify-email-change/route.ts
Normal 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));
|
||||
};
|
|
@ -7,6 +7,7 @@ export type TRPCContext = {
|
|||
locale?: string;
|
||||
getEmailClient: (locale?: string) => EmailClient;
|
||||
image?: string;
|
||||
email?: string;
|
||||
};
|
||||
ip?: string;
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
@ -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>."
|
||||
}
|
||||
|
|
13
packages/emails/src/previews/change-email-request.tsx
Normal file
13
packages/emails/src/previews/change-email-request.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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[];
|
||||
|
|
82
packages/emails/src/templates/change-email-request.tsx
Normal file
82
packages/emails/src/templates/change-email-request.tsx
Normal 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;
|
Loading…
Add table
Reference in a new issue