diff --git a/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx b/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx new file mode 100644 index 0000000000..62bc5654a7 --- /dev/null +++ b/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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(false); + const { register, handleSubmit, formState } = useFormWithSchema(schema, { + defaultValues: { + email: searchParams.get("email") ?? "", + }, + }); + + const [resendVerificationEmail] + = useMutation( + 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 + ? ( +
+
+

{__("Check your email")}

+

+ {__( + "We've sent a verification link to your email address. Please check your inbox and click the link to verify your account.", + )} +

+
+ +
+

+ {__("Didn't receive the email?")} + {" "} + +

+
+ +
+

+ {__("Already verified?")} + {" "} + + {__("Back to login")} + +

+
+
+ ) + : ( +
+
+

+ {__("Verify your email")} +

+

+ {__( + "Your email address has not been verified yet. Enter your email below and we'll send you a verification link.", + )} +

+
+ +
void onSubmit(e)} className="space-y-4"> + + + + + +
+

+ {__("Already verified?")} + {" "} + + {__("Back to login")} + +

+
+
+ ); +} diff --git a/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx b/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx index 07bb10b12f..855c487aa5 100644 --- a/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx +++ b/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx @@ -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"; @@ -35,6 +35,7 @@ const signInMutation = graphql` export default function PasswordSignInPage() { const location = useLocation(); + const navigate = useNavigate(); const safeContinueUrl = useSafeContinueUrl(); const { __ } = useTranslate(); @@ -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( diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index d0ff003e45..7c2dad9304 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -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( diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index dabefab825..782a5cc78d 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -518,6 +518,10 @@ func (s AuthService) CheckCredentials( return NewInvalidCredentialsError("invalid email or password") } + if !identity.EmailAddressVerified { + return NewEmailNotVerifiedError() + } + return nil }, ) @@ -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) } diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 571e18c764..8d65c6152c 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -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 { diff --git a/pkg/server/api/connect/v1/graphql/session.graphql b/pkg/server/api/connect/v1/graphql/session.graphql index 5b829a5b3a..8f7b629de4 100644 --- a/pkg/server/api/connect/v1/graphql/session.graphql +++ b/pkg/server/api/connect/v1/graphql/session.graphql @@ -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 @@ -152,6 +155,14 @@ type VerifyEmailPayload { success: Boolean! } +input ResendVerificationEmailInput { + email: EmailAddr! +} + +type ResendVerificationEmailPayload { + success: Boolean! +} + type ChangePasswordPayload { success: Boolean! } diff --git a/pkg/server/api/connect/v1/session_resolvers.go b/pkg/server/api/connect/v1/session_resolvers.go index 23c1986ebb..3c3608ed33 100644 --- a/pkg/server/api/connect/v1/session_resolvers.go +++ b/pkg/server/api/connect/v1/session_resolvers.go @@ -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) @@ -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)