-
-
Notifications
You must be signed in to change notification settings - Fork 366
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Enable changing user email address (#1493)
- Loading branch information
Showing
14 changed files
with
411 additions
and
22 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
154 changes: 154 additions & 0 deletions
154
apps/web/src/app/[locale]/(admin)/settings/profile/profile-email-address.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.