diff --git a/apps/trust/src/pages/auth/MagicLinkExpiredPage.tsx b/apps/trust/src/pages/auth/MagicLinkExpiredPage.tsx new file mode 100644 index 000000000..1c38453e3 --- /dev/null +++ b/apps/trust/src/pages/auth/MagicLinkExpiredPage.tsx @@ -0,0 +1,46 @@ +// 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 { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { Button } from "@probo/ui"; +import { useNavigate } from "react-router"; + +export default function MagicLinkExpiredPage() { + const { __ } = useTranslate(); + const navigate = useNavigate(); + + usePageTitle(__("Link Expired")); + + return ( +
+
+

{__("Link Expired")}

+

+ {__( + "This magic link has expired. Magic links are only valid for 15 minutes. Please request a new one.", + )} +

+
+
+ +
+
+ ); +} diff --git a/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx b/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx index 9872a8f65..2c8d176e5 100644 --- a/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx +++ b/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx @@ -18,7 +18,7 @@ import { useTranslate } from "@probo/i18n"; import { useToast } from "@probo/ui"; import { useCallback, useEffect, useRef } from "react"; import { useMutation } from "react-relay"; -import { Link, useSearchParams } from "react-router"; +import { Link, useNavigate, useSearchParams } from "react-router"; import { graphql } from "relay-runtime"; import { getPathPrefix } from "#/utils/pathPrefix"; @@ -36,6 +36,7 @@ const verifyMagicLinkMutation = graphql` export default function VerifyMagicLinkPagePageMutation() { const { __ } = useTranslate(); const { toast } = useToast(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const submittedRef = useRef(false); @@ -59,17 +60,16 @@ export default function VerifyMagicLinkPagePageMutation() { window.location.href = window.location.origin + getPathPrefix(); return; } - } - const hasExpiredToken = errors.some( - err => err.message === "token has expired", - ); + if (err.extensions?.code === "TOKEN_EXPIRED") { + void navigate("/magic-link-expired"); + return; + } + } toast({ title: __("Error"), - description: hasExpiredToken - ? __("This magic link has expired. Please request a new one.") - : formatError(__("Failed to connect"), errors), + description: formatError(__("Failed to connect"), errors), variant: "error", }); return; @@ -102,7 +102,7 @@ export default function VerifyMagicLinkPagePageMutation() { }); }, }); - }, [__, toast, verifyMagicLink]); + }, [__, navigate, toast, verifyMagicLink]); useEffect(() => { const token = searchParams.get("token"); diff --git a/apps/trust/src/routes.tsx b/apps/trust/src/routes.tsx index d1d9f211d..06937417c 100644 --- a/apps/trust/src/routes.tsx +++ b/apps/trust/src/routes.tsx @@ -53,6 +53,10 @@ const routes = [ path: "/verify-magic-link", Component: lazy(() => import("#/pages/auth/VerifyMagicLinkPage")), }, + { + path: "/magic-link-expired", + Component: lazy(() => import("#/pages/auth/MagicLinkExpiredPage")), + }, { path: "/full-name", Component: lazy(() => import("#/pages/auth/FullNamePage")), diff --git a/pkg/server/api/trust/v1/auth_resolvers.go b/pkg/server/api/trust/v1/auth_resolvers.go index ab94d637d..382967886 100644 --- a/pkg/server/api/trust/v1/auth_resolvers.go +++ b/pkg/server/api/trust/v1/auth_resolvers.go @@ -59,7 +59,7 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri email, err := r.iam.AuthService.GetMagicLinkEmail(ctx, input.Token) if err != nil { if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok { - return nil, gqlutils.Invalid(ctx, err) + return nil, gqlutils.TokenExpired(ctx, err) } if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok { @@ -80,7 +80,7 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri identity, session, continueURL, err = r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token) if err != nil { if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok { - return nil, gqlutils.Invalid(ctx, err) + return nil, gqlutils.TokenExpired(ctx, err) } if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok { @@ -102,7 +102,7 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri identity, session, continueURL, err = r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token) if err != nil { if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok { - return nil, gqlutils.Invalid(ctx, err) + return nil, gqlutils.TokenExpired(ctx, err) } if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok { diff --git a/pkg/server/gqlutils/errors.go b/pkg/server/gqlutils/errors.go index 1a6347343..db192da5c 100644 --- a/pkg/server/gqlutils/errors.go +++ b/pkg/server/gqlutils/errors.go @@ -142,6 +142,14 @@ func Conflictf(ctx context.Context, format string, a ...any) *gqlerror.Error { return Conflict(ctx, fmt.Errorf(format, a...)) } +func TokenExpired(ctx context.Context, err error) *gqlerror.Error { + return &gqlerror.Error{ + Message: err.Error(), + Path: graphql.GetPath(ctx), + Extensions: map[string]any{"code": "TOKEN_EXPIRED"}, + } +} + func Invalid(ctx context.Context, err error) *gqlerror.Error { var details map[string]any