From 40ce04ecc0c7525bafe7af51caed22b5f47cc67c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 31 Oct 2025 23:13:35 +0100 Subject: [PATCH] Add confirm email page --- apps/main/src/hooks/auth.ts | 75 ++++++++++++- apps/main/src/lib/routes.tsx | 6 + apps/main/src/locales/en/auth.json | 30 +++++ apps/main/src/locales/fr/auth.json | 30 +++++ apps/main/src/pages/confirm-email.tsx | 106 ++++++++++++++++++ apps/main/src/pages/reset-password.test.tsx | 32 +++--- apps/main/src/providers/UserStoreProvider.tsx | 53 +++++++++ 7 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 apps/main/src/pages/confirm-email.tsx diff --git a/apps/main/src/hooks/auth.ts b/apps/main/src/hooks/auth.ts index 66735a0..8e909e3 100644 --- a/apps/main/src/hooks/auth.ts +++ b/apps/main/src/hooks/auth.ts @@ -52,6 +52,7 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { email: data.email, password: data.password, options: { + emailRedirectTo: `${window.location.origin}/`, data: { first_name: data.first_name, last_name: data.last_name, @@ -60,13 +61,20 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { }, }); if (error) throw error; + + // Only sign up to stream if user is immediately confirmed (auto-confirm enabled in Supabase) if (response.session?.access_token) { await signUpToStream(response.session.access_token); } return response; }, - onSuccess: () => { - if (redirectUrl) { + onSuccess: (data) => { + // If there's no session, it means email confirmation is required + if (!data.user?.email_confirmed_at) { + // Store the email for the confirmation page + localStorage.setItem("pendingConfirmationEmail", data.user?.email || ""); + navigate("/confirm-email"); + } else if (redirectUrl) { localStorage.removeItem("redirectUrl"); navigate(decodeURIComponent(redirectUrl)); } else { @@ -120,7 +128,22 @@ export function useLoginEmail({ redirectUrl }: { redirectUrl: string | null }) { } return response; }, - onSuccess: () => { + onSuccess: (data) => { + // Check if email is verified + if (data.user && !data.user.email_confirmed_at) { + // Email not verified, redirect to confirmation page + localStorage.setItem("pendingConfirmationEmail", data.user.email || ""); + toast.add({ + title: "Email non vérifié", + description: "Veuillez vérifier votre email avant de vous connecter", + type: "warning", + position: "top-center", + }); + navigate("/confirm-email"); + return; + } + + // Email verified, proceed with login if (redirectUrl) { localStorage.removeItem("redirectUrl"); navigate(decodeURIComponent(redirectUrl)); @@ -133,6 +156,20 @@ export function useLoginEmail({ redirectUrl }: { redirectUrl: string | null }) { .with("invalid_credentials", () => { setErrors({ email: "Email ou mot de passe incorrect" }); }) + .with("email_not_confirmed", () => { + toast.add( + { + title: "Email non vérifié", + description: "Veuillez vérifier votre email avant de vous connecter", + type: "warning", + position: "top-center", + }, + { + timeout: 7000, + } + ); + navigate("/confirm-email"); + }) .otherwise(() => { toast.add( { @@ -199,3 +236,35 @@ export const useAuthedApi = (): AxiosInstance => { }, }); }; + +export function useResendConfirmationEmail() { + const { mutate, isPending } = useMutation({ + mutationFn: async (email: string) => { + const { error } = await supabase.auth.resend({ + type: "signup", + email: email, + options: { + emailRedirectTo: `${window.location.origin}/`, + }, + }); + if (error) throw error; + }, + onSuccess: () => { + toast.add({ + title: "Email envoyé", + description: "Un nouvel email de confirmation vous a été envoyé", + type: "success", + position: "top-center", + }); + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible d'envoyer l'email de confirmation", + type: "error", + position: "top-center", + }); + }, + }); + return { mutate, isPending }; +} diff --git a/apps/main/src/lib/routes.tsx b/apps/main/src/lib/routes.tsx index 1707c52..211f18a 100644 --- a/apps/main/src/lib/routes.tsx +++ b/apps/main/src/lib/routes.tsx @@ -22,6 +22,7 @@ import { SignUpPage } from "../pages/signup"; import { TabloPage } from "../pages/tablo"; import { TabloDetailsPage } from "../pages/tablo-details"; import { UpdatePasswordPage } from "../pages/update-password"; +import { ConfirmEmailPage } from "../pages/confirm-email"; import ChatProvider from "../providers/ChatProvider"; export const routes: RouteObject[] = [ @@ -132,6 +133,11 @@ export const routes: RouteObject[] = [ path: "/update-password", element: , }, + // Email confirmation waiting page (public) + { + path: "/confirm-email", + element: , + }, // Authentication pages (redirected to "/" if user is authenticated) { path: "/", diff --git a/apps/main/src/locales/en/auth.json b/apps/main/src/locales/en/auth.json index f3dca44..2931e11 100644 --- a/apps/main/src/locales/en/auth.json +++ b/apps/main/src/locales/en/auth.json @@ -70,5 +70,35 @@ "goToLogin": "Go to login", "error": "An error occurred while updating your password. Please try again or request a new reset link.", "errorTitle": "Error" + }, + "confirmEmail": { + "title": "Check your email", + "subtitle": "We've sent you a confirmation email to verify your account.", + "emailSentTo": "Email sent to:", + "nextSteps": "Next steps:", + "step1": "Check your email inbox (and spam folder)", + "step2": "Click the confirmation link in the email", + "step3": "You'll be redirected to your account", + "didntReceive": "Didn't receive the email?", + "resendEmail": "Resend confirmation email", + "resending": "Sending...", + "wrongEmail": "Wrong email address?", + "backToSignup": "Back to sign up" + }, + "verifyEmail": { + "verifying": "Verifying your email...", + "pleaseWait": "Please wait while we verify your email address.", + "successTitle": "Email verified!", + "successDescription": "Your email has been successfully verified. Welcome to Xtablo!", + "redirecting": "You will be redirected to your account in a few seconds...", + "goToApp": "Go to app", + "errorTitle": "Verification failed", + "error": "We couldn't verify your email address.", + "noSession": "No active session found. Please try logging in.", + "errorHelp": "The verification link may have expired or already been used. Please try signing up again or contact support.", + "tryAgain": "Try signing up again", + "backToLogin": "Back to login", + "alreadyVerified": "Already verified", + "alreadyVerifiedDescription": "Your email has already been verified. You can access your account." } } diff --git a/apps/main/src/locales/fr/auth.json b/apps/main/src/locales/fr/auth.json index 548c2c9..6cf4069 100644 --- a/apps/main/src/locales/fr/auth.json +++ b/apps/main/src/locales/fr/auth.json @@ -70,5 +70,35 @@ "goToLogin": "Aller à la connexion", "error": "Une erreur est survenue lors de la mise à jour de votre mot de passe. Veuillez réessayer ou demander un nouveau lien de réinitialisation.", "errorTitle": "Erreur" + }, + "confirmEmail": { + "title": "Vérifiez votre email", + "subtitle": "Nous vous avons envoyé un email de confirmation pour vérifier votre compte.", + "emailSentTo": "Email envoyé à :", + "nextSteps": "Prochaines étapes :", + "step1": "Vérifiez votre boîte de réception (et le dossier spam)", + "step2": "Cliquez sur le lien de confirmation dans l'email", + "step3": "Vous serez redirigé vers votre compte", + "didntReceive": "Vous n'avez pas reçu l'email ?", + "resendEmail": "Renvoyer l'email de confirmation", + "resending": "Envoi en cours...", + "wrongEmail": "Mauvaise adresse email ?", + "backToSignup": "Retour à l'inscription" + }, + "verifyEmail": { + "verifying": "Vérification de votre email...", + "pleaseWait": "Veuillez patienter pendant que nous vérifions votre adresse email.", + "successTitle": "Email vérifié !", + "successDescription": "Votre email a été vérifié avec succès. Bienvenue sur Xtablo !", + "redirecting": "Vous serez redirigé vers votre compte dans quelques secondes...", + "goToApp": "Aller à l'application", + "errorTitle": "Échec de la vérification", + "error": "Nous n'avons pas pu vérifier votre adresse email.", + "noSession": "Aucune session active trouvée. Veuillez essayer de vous connecter.", + "errorHelp": "Le lien de vérification a peut-être expiré ou a déjà été utilisé. Veuillez réessayer de vous inscrire ou contacter le support.", + "tryAgain": "Réessayer de s'inscrire", + "backToLogin": "Retour à la connexion", + "alreadyVerified": "Déjà vérifié", + "alreadyVerifiedDescription": "Votre email a déjà été vérifié. Vous pouvez accéder à votre compte." } } diff --git a/apps/main/src/pages/confirm-email.tsx b/apps/main/src/pages/confirm-email.tsx new file mode 100644 index 0000000..3f362a9 --- /dev/null +++ b/apps/main/src/pages/confirm-email.tsx @@ -0,0 +1,106 @@ +import { Button } from "@xtablo/ui/components/button"; +import { Text } from "@xtablo/ui/components/typography"; +import { CheckCircle2, Loader2Icon, MailIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { twMerge } from "tailwind-merge"; +import { useResendConfirmationEmail } from "../hooks/auth"; + +export function ConfirmEmailPage() { + const { t } = useTranslation("auth"); + const navigate = useNavigate(); + const pendingEmail = localStorage.getItem("pendingConfirmationEmail"); + const { mutate: resendEmail, isPending } = useResendConfirmationEmail(); + + if (!pendingEmail) { + // If no pending email, redirect to signup + navigate("/signup"); + return; + } + + const handleResendEmail = () => { + resendEmail(pendingEmail); + }; + + return ( +
+
+
+
+
+ +
+
+ +
+

{t("confirmEmail.title")}

+ {t("confirmEmail.subtitle")} +
+ +
+
+
+ +
+ + {t("confirmEmail.emailSentTo")} + + + {pendingEmail} + +
+
+
+
+ +
+ + {t("confirmEmail.nextSteps")} + +
    +
  1. {t("confirmEmail.step1")}
  2. +
  3. {t("confirmEmail.step2")}
  4. +
  5. {t("confirmEmail.step3")}
  6. +
+
+ +
+ {t("confirmEmail.didntReceive")} + +
+ +
+ + {t("confirmEmail.wrongEmail")} + + + + +
+
+
+
+ ); +} diff --git a/apps/main/src/pages/reset-password.test.tsx b/apps/main/src/pages/reset-password.test.tsx index 67f5609..08fe1fb 100644 --- a/apps/main/src/pages/reset-password.test.tsx +++ b/apps/main/src/pages/reset-password.test.tsx @@ -22,30 +22,30 @@ describe("ResetPasswordPage", () => { it("renders form with email input", () => { renderWithProviders(); - expect(screen.getByText(/Mot de passe oublié/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Email/i)).toBeInTheDocument(); + expect(screen.getByText(/resetPassword.title/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/resetPassword.emailLabel/i)).toBeInTheDocument(); expect( - screen.getByRole("button", { name: /Réinitialiser le mot de passe/i }) + screen.getByRole("button", { name: /resetPassword.submit/i }) ).toBeInTheDocument(); }); it("displays help text", () => { renderWithProviders(); - expect(screen.getByText(/Entrez votre adresse email/i)).toBeInTheDocument(); + expect(screen.getByText(/resetPassword.description/i)).toBeInTheDocument(); }); it("shows link back to login", () => { renderWithProviders(); - const loginLink = screen.getByText(/Retour à la connexion/i); + const loginLink = screen.getByText(/resetPassword.backToLogin/i); expect(loginLink).toBeInTheDocument(); }); it("updates email input on change", () => { renderWithProviders(); - const emailInput = screen.getByLabelText(/Email/i) as HTMLInputElement; + const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i) as HTMLInputElement; fireEvent.change(emailInput, { target: { value: "test@example.com" } }); expect(emailInput.value).toBe("test@example.com"); @@ -54,22 +54,22 @@ describe("ResetPasswordPage", () => { it.skip("submits form and shows success message", async () => { renderWithProviders(); - const emailInput = screen.getByLabelText(/Email/i); - const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i }); + const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i); + const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.click(submitButton); await waitFor(() => { - expect(screen.getByText(/Email envoyé/i)).toBeInTheDocument(); + expect(screen.getByText(/resetPassword.emailSent/i)).toBeInTheDocument(); }); }); it.skip("displays email in success message", async () => { renderWithProviders(); - const emailInput = screen.getByLabelText(/Email/i); - const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i }); + const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i); + const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.click(submitButton); @@ -82,28 +82,28 @@ describe("ResetPasswordPage", () => { it.skip("shows return to login button in success state", async () => { renderWithProviders(); - const emailInput = screen.getByLabelText(/Email/i); - const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i }); + const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i); + const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.click(submitButton); await waitFor(() => { - expect(screen.getByRole("button", { name: /Retour à la connexion/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /resetPassword.backToLogin/i })).toBeInTheDocument(); }); }); it("requires email field to be filled", () => { renderWithProviders(); - const emailInput = screen.getByLabelText(/Email/i); + const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i); expect(emailInput).toHaveAttribute("required"); }); it("requires valid email format", () => { renderWithProviders(); - const emailInput = screen.getByLabelText(/Email/i); + const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i); expect(emailInput).toHaveAttribute("type", "email"); }); }); diff --git a/apps/main/src/providers/UserStoreProvider.tsx b/apps/main/src/providers/UserStoreProvider.tsx index 8e68824..3e36fae 100644 --- a/apps/main/src/providers/UserStoreProvider.tsx +++ b/apps/main/src/providers/UserStoreProvider.tsx @@ -34,6 +34,59 @@ export const UserStoreProvider = ({ children }: { children: React.ReactNode }) = enabled: shouldFetchUser, }); + // Handle email verification + // useEffect(() => { + // const checkEmailVerification = async () => { + // // Check if we're coming from an email verification link + // const hashParams = new URLSearchParams(window.location.hash.substring(1)); + // const type = hashParams.get("type"); + + // if (type === "signup" || type === "email") { + // try { + // const { + // data: { user: supabaseUser }, + // } = await supabase.auth.getUser(); + + // if (supabaseUser && supabaseUser.email_confirmed_at) { + // // Email was just verified + // const wasJustVerified = !sessionStorage.getItem("email_verified_shown"); + + // if (wasJustVerified) { + // // Clear the pending confirmation email + // localStorage.removeItem("pendingConfirmationEmail"); + + // // Mark that we've shown the verification success + // sessionStorage.setItem("email_verified_shown", "true"); + + // // Show success message + // toast.add({ + // title: t("verifyEmail.successTitle"), + // description: t("verifyEmail.successDescription"), + // type: "success", + // position: "top-center", + // }); + + // // Clean up the URL hash + // window.history.replaceState(null, "", window.location.pathname); + // } + // } + // } catch (error) { + // console.error("Email verification check error:", error); + // toast.add({ + // title: t("verifyEmail.errorTitle"), + // description: t("verifyEmail.error"), + // type: "error", + // position: "top-center", + // }); + // } + // } + // }; + + // if (session) { + // checkEmailVerification(); + // } + // }, [session, t]); + useEffect(() => { if (user) { datadogRum.setUser({