Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.

import { formatError } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
import { useState } from "react";
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.string().email(),
});

export default function ResendVerificationEmailPage() {
const { toast } = useToast();
const { __ } = useTranslate();
const [searchParams] = useSearchParams();

usePageTitle(__("Verify Email"));

const [emailSent, setEmailSent] = useState<boolean>(false);
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: __("Request failed"),
description: e.message,
variant: "error",
});
},
onCompleted: (_, e) => {
if (e) {
toast({
title: __("Request failed"),
description: formatError(
__("Failed to send verification email"),
e,
),
variant: "error",
});
return;
}

toast({
title: __("Success"),
description: __("Verification email sent"),
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">{__("Check your email")}</h1>
<p className="text-txt-tertiary">
{__(
"We've sent a verification link to your email address. Please check your inbox and click the link to verify your account.",
)}
</p>
</div>

<div className="text-center">
<p className="text-sm text-txt-tertiary">
{__("Didn't receive the email?")}
{" "}
<button
onClick={() => setEmailSent(false)}
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Try again")}
</button>
</p>
</div>

<div className="text-center">
<p className="text-sm text-txt-tertiary">
{__("Already verified?")}
{" "}
<Link
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Back to login")}
</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">
{__("Verify your email")}
</h1>
<p className="text-txt-tertiary">
{__(
"Your email address has not been verified yet. Enter your email below and we'll send you a verification link.",
)}
</p>
</div>

<form onSubmit={e => void onSubmit(e)} className="space-y-4">
<Field
label={__("Email")}
type="email"
placeholder={__("name@example.com")}
{...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
? __("Sending...")
: __("Send verification email")}
</Button>
</form>

<div className="text-center">
<p className="text-sm text-txt-tertiary">
{__("Already verified?")}
{" "}
<Link
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Back to login")}
</Link>
</p>
</div>
</div>
);
}
17 changes: 16 additions & 1 deletion apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useTranslate } from "@probo/i18n";
import { Button, Field, IconChevronLeft, useToast } from "@probo/ui";
import type { FormEventHandler } from "react";
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";
Expand All @@ -35,6 +35,7 @@ const signInMutation = graphql`

export default function PasswordSignInPage() {
const location = useLocation();
const navigate = useNavigate();
const safeContinueUrl = useSafeContinueUrl();

const { __ } = useTranslate();
Expand Down Expand Up @@ -67,6 +68,20 @@ export default function PasswordSignInPage() {
},
onCompleted: (_, error) => {
if (error) {
const errors = error as GraphQLError[];
const hasEmailNotVerified = errors.some(
e => e.extensions?.code === "EMAIL_NOT_VERIFIED",
);

if (hasEmailNotVerified) {
const search = new URLSearchParams([["email", emailValue]]);
void navigate({
pathname: "/auth/resend-verification-email",
search: "?" + search.toString(),
});
return;
}

toast({
title: __("Error"),
description: formatError(
Expand Down
6 changes: 6 additions & 0 deletions apps/console/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,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(
Expand Down
56 changes: 56 additions & 0 deletions pkg/iam/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,10 @@ func (s AuthService) CheckCredentials(
return NewInvalidCredentialsError("invalid email or password")
}

if !identity.EmailAddressVerified {
return NewEmailNotVerifiedError()
}

return nil
},
)
Expand Down Expand Up @@ -725,6 +729,58 @@ func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString s
return identity, session, payload.Data.Continue, nil
}

func (s AuthService) SendEmailVerification(ctx context.Context, email mail.Addr) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
identity := &coredata.Identity{}
if err := identity.LoadByEmail(ctx, tx, email); err != nil {
if err == coredata.ErrResourceNotFound {
return nil
}

return fmt.Errorf("cannot load identity: %w", err)
}

if identity.EmailAddressVerified {
return nil
}

confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
24*time.Hour,
EmailConfirmationData{IdentityID: identity.ID, Email: identity.EmailAddress},
)
if err != nil {
return fmt.Errorf("cannot generate confirmation token: %w", err)
}

emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, identity.FullName)

subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail(ctx, "/auth/verify-email", confirmationToken)
if err != nil {
return fmt.Errorf("cannot render confirmation email: %w", err)
}

confirmationEmail := coredata.NewEmail(
identity.FullName,
identity.EmailAddress,
subject,
textBody,
htmlBody,
nil,
)

if err := confirmationEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert confirmation email: %w", err)
}

return nil
},
)
}

func HashToken(token string) []byte {
return hash.SHA256String(token)
}
10 changes: 10 additions & 0 deletions pkg/iam/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,16 @@ func (e ErrUnsupportedPrincipalType) Error() string {
return fmt.Sprintf("unsupported principal type: %d", e.EntityType)
}

type ErrEmailNotVerified struct{}

func NewEmailNotVerifiedError() error {
return &ErrEmailNotVerified{}
}

func (e ErrEmailNotVerified) Error() string {
return "email address has not been verified"
}

type ErrSignupDisabled struct{}

func NewErrSignupDisabled() error {
Expand Down
11 changes: 11 additions & 0 deletions pkg/server/api/connect/v1/graphql/session.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ extend type Mutation {
@session(required: NONE)
verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload
@session(required: OPTIONAL)
resendVerificationEmail(
input: ResendVerificationEmailInput!
): ResendVerificationEmailPayload @session(required: NONE)
changePassword(input: ChangePasswordInput!): ChangePasswordPayload
@session(required: PRESENT)
changeEmail(input: ChangeEmailInput!): ChangeEmailPayload
Expand Down Expand Up @@ -152,6 +155,14 @@ type VerifyEmailPayload {
success: Boolean!
}

input ResendVerificationEmailInput {
email: EmailAddr!
}

type ResendVerificationEmailPayload {
success: Boolean!
}

type ChangePasswordPayload {
success: Boolean!
}
Expand Down
22 changes: 22 additions & 0 deletions pkg/server/api/connect/v1/session_resolvers.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ func (r *mutationResolver) SignIn(ctx context.Context, input types.SignInInput)
}
}

if _, ok := errors.AsType[*iam.ErrEmailNotVerified](err); ok {
return nil, &gqlerror.Error{
Message: "email address has not been verified",
Extensions: map[string]any{
"code": "EMAIL_NOT_VERIFIED",
},
}
}

r.logger.ErrorCtx(ctx, "cannot check credentials", log.Error(err))

return nil, gqlutils.Internal(ctx)
Expand Down Expand Up @@ -311,6 +320,19 @@ func (r *mutationResolver) VerifyEmail(ctx context.Context, input types.VerifyEm
}, nil
}

// ResendVerificationEmail is the resolver for the resendVerificationEmail field.
func (r *mutationResolver) ResendVerificationEmail(ctx context.Context, input types.ResendVerificationEmailInput) (*types.ResendVerificationEmailPayload, error) {
err := r.iam.AuthService.SendEmailVerification(ctx, input.Email)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot send verification email", log.Error(err))
return nil, gqlutils.Internal(ctx)
}

return &types.ResendVerificationEmailPayload{
Success: true,
}, nil
}

// ChangePassword is the resolver for the changePassword field.
func (r *mutationResolver) ChangePassword(ctx context.Context, input types.ChangePasswordInput) (*types.ChangePasswordPayload, error) {
identity := authn.IdentityFromContext(ctx)
Expand Down
Loading