Gate password sign-in on email verification

Unverified password identities were able to open sessions after
signing out. Reject sign-in with EMAIL_NOT_VERIFIED and add a
resend-confirmation flow so users can complete verification.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-28 10:15:25 +02:00
parent bb9fb22913
commit 5d0882778f
19 changed files with 621 additions and 60 deletions

View File

@@ -1287,6 +1287,17 @@
"sent": { "title": "Check your email", "description": "We've sent password reset instructions to your email address", "didNotReceive": "Didn't receive the email?" },
"actions": { "tryAgain": "Try again", "backToLogin": "Back to login", "sendingInstructions": "Sending instructions...", "sendInstructions": "Send reset instructions" }
},
"resendVerificationEmailPage": {
"pageTitle": "Verify your email",
"title": "Verify your email",
"description": "Your email address has not been verified yet. Enter your email and we'll send you a new verification link",
"alreadyVerified": "Already verified?",
"messages": { "verificationSent": "Verification email sent" },
"errors": { "requestFailed": "Request failed", "sendVerification": "Failed to send verification email" },
"fields": { "email": "Email", "emailPlaceholder": "name@example.com" },
"sent": { "title": "Check your email", "description": "We've sent a verification link to your email address", "didNotReceive": "Didn't receive the email?" },
"actions": { "tryAgain": "Try again", "backToLogin": "Back to login", "sendingVerification": "Sending verification email...", "sendVerification": "Send verification email" }
},
"resetPasswordPage": {
"pageTitle": "Reset password",
"title": "Reset password",

View File

@@ -2072,6 +2072,34 @@
"sendInstructions": "Envoyer les instructions de réinitialisation"
}
},
"resendVerificationEmailPage": {
"pageTitle": "Vérifiez votre e-mail",
"title": "Vérifiez votre e-mail",
"description": "Votre adresse e-mail n’a pas encore été vérifiée. Saisissez votre e-mail et nous vous enverrons un nouveau lien de vérification",
"alreadyVerified": "Déjà vérifié ?",
"messages": {
"verificationSent": "E-mail de vérification envoyé"
},
"errors": {
"requestFailed": "La requête a échoué",
"sendVerification": "Échec de l’envoi de l’e-mail de vérification"
},
"fields": {
"email": "E-mail",
"emailPlaceholder": "name@example.com"
},
"sent": {
"title": "Consultez votre e-mail",
"description": "Nous avons envoyé un lien de vérification à votre adresse e-mail",
"didNotReceive": "Vous n’avez pas reçu l’e-mail ?"
},
"actions": {
"tryAgain": "Réessayer",
"backToLogin": "Retour à la connexion",
"sendingVerification": "Envoi de l’e-mail de vérification...",
"sendVerification": "Envoyer l’e-mail de vérification"
}
},
"resetPasswordPage": {
"pageTitle": "Réinitialiser le mot de passe",
"title": "Réinitialiser le mot de passe",

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { formatError } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { Button, Field, useToast } from "@probo/ui";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-relay";
import { Link, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { ResendVerificationEmailPageMutation } from "#/__generated__/iam/ResendVerificationEmailPageMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const resendVerificationEmailMutation = graphql`
mutation ResendVerificationEmailPageMutation($input: ResendVerificationEmailInput!) {
resendVerificationEmail(input: $input) {
success
}
}
`;
const schema = z.object({
email: z.email(),
});
export default function ResendVerificationEmailPage() {
const { toast } = useToast();
const { t } = useTranslation();
const [searchParams] = useSearchParams();
usePageTitle(t("resendVerificationEmailPage.pageTitle"));
const [emailSent, setEmailSent] = useState<boolean>();
const { register, handleSubmit, formState } = useFormWithSchema(schema, {
defaultValues: {
email: searchParams.get("email") ?? "",
},
});
const [resendVerificationEmail] = useMutation<ResendVerificationEmailPageMutation>(
resendVerificationEmailMutation,
);
const onSubmit = handleSubmit(({ email }) => {
resendVerificationEmail({
variables: {
input: { email },
},
onError: (e: Error) => {
toast({
title: t("resendVerificationEmailPage.errors.requestFailed"),
description: e.message,
variant: "error",
});
},
onCompleted: (_, e) => {
if (e) {
toast({
title: t("resendVerificationEmailPage.errors.requestFailed"),
description: formatError(
t("resendVerificationEmailPage.errors.sendVerification"),
e,
),
variant: "error",
});
return;
}
toast({
title: t("common.success"),
description: t("resendVerificationEmailPage.messages.verificationSent"),
variant: "success",
});
setEmailSent(true);
},
});
});
return emailSent
? (
<div className="space-y-6 w-full max-w-md mx-auto pt-8">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{t("resendVerificationEmailPage.sent.title")}</h1>
<p className="text-txt-tertiary">
{t("resendVerificationEmailPage.sent.description")}
</p>
</div>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{t("resendVerificationEmailPage.sent.didNotReceive")}
{" "}
<button
onClick={() => setEmailSent(false)}
className="underline text-txt-primary hover:text-txt-secondary"
>
{t("resendVerificationEmailPage.actions.tryAgain")}
</button>
</p>
</div>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{t("resendVerificationEmailPage.alreadyVerified")}
{" "}
<Link
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{t("resendVerificationEmailPage.actions.backToLogin")}
</Link>
</p>
</div>
</div>
)
: (
<div className="space-y-6 w-full max-w-md mx-auto pt-8">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{t("resendVerificationEmailPage.title")}</h1>
<p className="text-txt-tertiary">
{t("resendVerificationEmailPage.description")}
</p>
</div>
<form onSubmit={e => void onSubmit(e)} className="space-y-4">
<Field
label={t("resendVerificationEmailPage.fields.email")}
type="email"
placeholder={t("resendVerificationEmailPage.fields.emailPlaceholder")}
{...register("email")}
required
error={formState.errors.email?.message}
/>
<Button
type="submit"
className="w-xs h-10 mx-auto mt-6"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? t("resendVerificationEmailPage.actions.sendingVerification")
: t("resendVerificationEmailPage.actions.sendVerification")}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{t("resendVerificationEmailPage.alreadyVerified")}
{" "}
<Link
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{t("resendVerificationEmailPage.actions.backToLogin")}
</Link>
</p>
</div>
</div>
);
}

View File

@@ -18,12 +18,12 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { formatError } from "@probo/helpers";
import { formatError, type GraphQLError } from "@probo/helpers";
import { Button, Field, IconChevronLeft, useToast } from "@probo/ui";
import type { FormEventHandler } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-relay";
import { Link, matchPath, useLocation } from "react-router";
import { Link, matchPath, useLocation, useNavigate } from "react-router";
import { graphql } from "relay-runtime";
import type { PasswordSignInPageMutation } from "#/__generated__/iam/PasswordSignInPageMutation.graphql";
@@ -41,6 +41,7 @@ const signInMutation = graphql`
export default function PasswordSignInPage() {
const location = useLocation();
const navigate = useNavigate();
const postAuthRedirectUrl = usePostAuthRedirectUrl();
const { t } = useTranslation();
@@ -73,6 +74,16 @@ export default function PasswordSignInPage() {
},
onCompleted: (_, error) => {
if (error) {
const errors = Array.isArray(error) ? error : [error];
const emailNotVerified = errors.some(
e => (e as GraphQLError).extensions?.code === "EMAIL_NOT_VERIFIED",
);
if (emailNotVerified) {
const search = new URLSearchParams({ email: emailValue }).toString();
void navigate(`/auth/resend-verification-email?${search}`);
return;
}
toast({
title: t("common.error"),
description: formatError(

View File

@@ -85,6 +85,12 @@ const routes = [
path: "verify-email",
Component: lazy(() => import("./pages/iam/auth/VerifyEmailPage")),
},
{
path: "resend-verification-email",
Component: lazy(
() => import("./pages/iam/auth/ResendVerificationEmailPage"),
),
},
{
path: "activate-account",
Component: lazy(