Add confirm email page

This commit is contained in:
Arthur Belleville 2025-10-31 23:13:35 +01:00
parent db14f0129a
commit 40ce04ecc0
No known key found for this signature in database
7 changed files with 313 additions and 19 deletions

View file

@ -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 };
}

View file

@ -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: <UpdatePasswordPage />,
},
// Email confirmation waiting page (public)
{
path: "/confirm-email",
element: <ConfirmEmailPage />,
},
// Authentication pages (redirected to "/" if user is authenticated)
{
path: "/",

View file

@ -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."
}
}

View file

@ -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."
}
}

View file

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5">
<div
className={twMerge(
"w-full max-w-lg p-8 bg-card/80 backdrop-blur-lg rounded-2xl",
"border border-border",
"shadow-xl"
)}
>
<div className="text-center space-y-6">
<div className="flex justify-center">
<div className="rounded-full bg-blue-100 dark:bg-blue-900/20 p-3">
<MailIcon className="w-12 h-12 text-blue-600 dark:text-blue-400" />
</div>
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold text-foreground">{t("confirmEmail.title")}</h1>
<Text className="text-muted-foreground">{t("confirmEmail.subtitle")}</Text>
</div>
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="space-y-3">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-left">
<Text className="text-sm text-blue-800 dark:text-blue-200 font-medium">
{t("confirmEmail.emailSentTo")}
</Text>
<Text className="text-sm text-blue-700 dark:text-blue-300 font-mono mt-1">
{pendingEmail}
</Text>
</div>
</div>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-left space-y-3">
<Text className="text-sm font-medium text-foreground">
{t("confirmEmail.nextSteps")}
</Text>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>{t("confirmEmail.step1")}</li>
<li>{t("confirmEmail.step2")}</li>
<li>{t("confirmEmail.step3")}</li>
</ol>
</div>
<div className="pt-4 space-y-3">
<Text className="text-sm text-muted-foreground">{t("confirmEmail.didntReceive")}</Text>
<Button
variant="outline"
onClick={handleResendEmail}
disabled={isPending}
className="w-full"
>
{isPending ? (
<>
<Loader2Icon className="w-4 h-4 mr-2 animate-spin" />
{t("confirmEmail.resending")}
</>
) : (
t("confirmEmail.resendEmail")
)}
</Button>
</div>
<div className="pt-4 border-t border-border">
<Text className="text-sm text-muted-foreground mb-3">
{t("confirmEmail.wrongEmail")}
</Text>
<Link to="/signup" className="block">
<Button variant="ghost" className="w-full">
{t("confirmEmail.backToSignup")}
</Button>
</Link>
</div>
</div>
</div>
</div>
);
}

View file

@ -22,30 +22,30 @@ describe("ResetPasswordPage", () => {
it("renders form with email input", () => {
renderWithProviders(<ResetPasswordPage />);
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(<ResetPasswordPage />);
expect(screen.getByText(/Entrez votre adresse email/i)).toBeInTheDocument();
expect(screen.getByText(/resetPassword.description/i)).toBeInTheDocument();
});
it("shows link back to login", () => {
renderWithProviders(<ResetPasswordPage />);
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(<ResetPasswordPage />);
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(<ResetPasswordPage />);
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(<ResetPasswordPage />);
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(<ResetPasswordPage />);
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(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/Email/i);
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
expect(emailInput).toHaveAttribute("required");
});
it("requires valid email format", () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/Email/i);
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
expect(emailInput).toHaveAttribute("type", "email");
});
});

View file

@ -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({