Implement email change

This commit is contained in:
adilallo
2026-04-26 07:47:25 -06:00
parent 68517796a9
commit 0ce05372bf
15 changed files with 1072 additions and 13 deletions
+87 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useTranslation } from "../../contexts/MessagesContext";
@@ -13,6 +13,7 @@ import {
fetchMyPublishedRules,
fetchServerDraftForProfile,
logout,
requestEmailChange,
type MyPublishedRule,
} from "../../../lib/create/api";
import {
@@ -55,6 +56,16 @@ export default function ProfilePageClient() {
const [accountDeleteOpen, setAccountDeleteOpen] = useState(false);
const [accountDeleteBusy, setAccountDeleteBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [emailChangeOpen, setEmailChangeOpen] = useState(false);
const [emailChangeInput, setEmailChangeInput] = useState("");
const [emailChangeBusy, setEmailChangeBusy] = useState(false);
const [emailChangeModalError, setEmailChangeModalError] = useState<
string | null
>(null);
const [profileSuccessMessage, setProfileSuccessMessage] = useState<
string | null
>(null);
const emailChangeQueryHandledRef = useRef(false);
const load = useCallback(async () => {
setActionError(null);
@@ -85,6 +96,72 @@ export default function ProfilePageClient() {
void load();
}, [load]);
useEffect(() => {
if (emailChangeQueryHandledRef.current) return;
if (typeof window === "undefined") return;
const search = window.location.search;
if (!search) return;
const params = new URLSearchParams(search);
const ok = params.get("email_change");
const err = params.get("error");
if (ok !== "ok" && !err?.startsWith("email_change_")) return;
emailChangeQueryHandledRef.current = true;
if (ok === "ok") {
setProfileSuccessMessage(t("emailChangeSuccess"));
void load().then(() => {
router.refresh();
});
} else if (err === "email_change_expired") {
setActionError(t("emailChangeVerifyExpired"));
} else if (err === "email_change_invalid") {
setActionError(t("emailChangeVerifyInvalid"));
} else if (err === "email_change_taken") {
setActionError(t("emailChangeVerifyTaken"));
} else if (err === "email_change_server") {
setActionError(t("actionError"));
}
router.replace("/profile", { scroll: false });
}, [load, router, t]);
const handleOpenEmailChange = useCallback(() => {
if (!user) return;
setActionError(null);
setProfileSuccessMessage(null);
setEmailChangeModalError(null);
setEmailChangeInput(user.email);
setEmailChangeOpen(true);
}, [user]);
const handleCloseEmailChange = useCallback(() => {
if (emailChangeBusy) return;
setEmailChangeOpen(false);
}, [emailChangeBusy]);
const handleSubmitEmailChange = useCallback(async () => {
const trimmed = emailChangeInput.trim();
if (!trimmed || emailChangeBusy) return;
setEmailChangeModalError(null);
setEmailChangeBusy(true);
const res = await requestEmailChange(trimmed);
setEmailChangeBusy(false);
if (res.ok === false) {
if (res.retryAfterMs != null && res.retryAfterMs > 0) {
const sec = Math.max(1, Math.ceil(res.retryAfterMs / 1000));
setEmailChangeModalError(
t("emailChangeRateLimited").replace(/\{\{seconds\}\}/g, String(sec)),
);
} else {
setEmailChangeModalError(res.error);
}
} else {
setEmailChangeOpen(false);
setProfileSuccessMessage(t("emailChangeRequestSent"));
}
}, [emailChangeBusy, emailChangeInput, t]);
const handleSignOut = useCallback(async () => {
setActionError(null);
await logout();
@@ -236,6 +313,15 @@ export default function ProfilePageClient() {
accountDeleteOpen={accountDeleteOpen}
accountDeleteBusy={accountDeleteBusy}
actionError={actionError}
profileSuccessMessage={profileSuccessMessage}
emailChangeOpen={emailChangeOpen}
emailChangeValue={emailChangeInput}
onEmailChangeValueChange={(value) => setEmailChangeInput(value)}
emailChangeBusy={emailChangeBusy}
emailChangeModalError={emailChangeModalError}
onOpenEmailChange={handleOpenEmailChange}
onCloseEmailChange={handleCloseEmailChange}
onSubmitEmailChange={handleSubmitEmailChange}
onSignOut={handleSignOut}
onDeleteRule={handleRequestDeleteRule}
onCloseDeleteRule={handleCloseDeleteRuleDialog}
@@ -3,6 +3,7 @@
import { useId, useMemo } from "react";
import Button from "../../../components/buttons/Button";
import RuleCard from "../../../components/cards/RuleCard";
import TextInput from "../../../components/controls/TextInput";
import List from "../../../components/layout/List";
import type { ListItem, ListSize } from "../../../components/layout/List";
import Dialog from "../../../components/modals/Dialog";
@@ -43,6 +44,15 @@ export type ProfilePageViewProps = {
accountDeleteOpen: boolean;
accountDeleteBusy: boolean;
actionError: string | null;
profileSuccessMessage: string | null;
emailChangeOpen: boolean;
emailChangeValue: string;
onEmailChangeValueChange: (value: string) => void;
emailChangeBusy: boolean;
emailChangeModalError: string | null;
onOpenEmailChange: () => void;
onCloseEmailChange: () => void;
onSubmitEmailChange: () => void;
onSignOut: () => void;
onDeleteRule: (id: string) => void;
onCloseDeleteRule: () => void;
@@ -156,6 +166,15 @@ export function ProfilePageView({
accountDeleteOpen,
accountDeleteBusy,
actionError,
profileSuccessMessage,
emailChangeOpen,
emailChangeValue,
onEmailChangeValueChange,
emailChangeBusy,
emailChangeModalError,
onOpenEmailChange,
onCloseEmailChange,
onSubmitEmailChange,
onSignOut,
onDeleteRule,
onCloseDeleteRule,
@@ -205,8 +224,8 @@ export function ProfilePageView({
id: "change-email",
title: t("optionChangeEmail"),
description: "",
onClick: onOpenEmailChange,
leadingIcon: "mail",
variant: "muted",
showDescription: false,
},
{
@@ -219,7 +238,7 @@ export function ProfilePageView({
showDescription: false,
},
];
}, [t, onSignOut, onOpenDeleteAccount]);
}, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange]);
const ruleCardShellClass =
"w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]";
@@ -258,6 +277,15 @@ export function ProfilePageView({
)}
</header>
{profileSuccessMessage ? (
<p
className="rounded-lg border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-secondary)] px-4 py-3 font-inter text-sm text-[var(--color-content-default-primary)]"
role="status"
>
{profileSuccessMessage}
</p>
) : null}
{actionError ? (
<p
className="rounded-lg border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-tertiary)] px-4 py-3 font-inter text-sm text-[var(--color-content-default-primary)]"
@@ -484,6 +512,60 @@ export function ProfilePageView({
</>
}
/>
<Dialog
isOpen={emailChangeOpen}
onClose={() => {
if (!emailChangeBusy) onCloseEmailChange();
}}
backdropVariant="blurredYellow"
title={t("emailChangeModalTitle")}
description={t("emailChangeModalDescription")}
footer={
<>
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onCloseEmailChange}
disabled={emailChangeBusy}
>
{t("emailChangeCancel")}
</Button>
<Button
type="button"
size="medium"
buttonType="filled"
palette="default"
onClick={onSubmitEmailChange}
disabled={emailChangeBusy}
>
{t("emailChangeSubmit")}
</Button>
</>
}
>
{emailChangeModalError ? (
<p
className="font-inter text-sm text-[var(--color-content-default-primary)]"
role="alert"
>
{emailChangeModalError}
</p>
) : null}
<TextInput
type="email"
inputSize="medium"
label={t("emailChangeNewEmailLabel")}
placeholder={t("emailChangeNewEmailPlaceholder")}
value={emailChangeValue}
onChange={(e) => onEmailChangeValueChange(e.target.value)}
disabled={emailChangeBusy}
error={Boolean(emailChangeModalError)}
autoComplete="email"
/>
</Dialog>
</>
);
}