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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
180
apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx
Normal file
180
apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user