diff --git a/apps/trust/src/pages/auth/MagicLinkAlreadyUsedPage.tsx b/apps/trust/src/pages/auth/MagicLinkAlreadyUsedPage.tsx new file mode 100644 index 000000000..80c44f5c0 --- /dev/null +++ b/apps/trust/src/pages/auth/MagicLinkAlreadyUsedPage.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 MagicLinkAlreadyUsedPage() { + const { __ } = useTranslate(); + const navigate = useNavigate(); + + usePageTitle(__("Link Already Used")); + + return ( +
+
+

{__("Link Already Used")}

+

+ {__( + "This magic link has already been used. Magic links can only be used once. Please request a new one if you need to sign in again.", + )} +

+
+
+ +
+
+ ); +} diff --git a/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx b/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx index 2c8d176e5..a3b86e327 100644 --- a/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx +++ b/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx @@ -65,6 +65,11 @@ export default function VerifyMagicLinkPagePageMutation() { void navigate("/magic-link-expired"); return; } + + if (err.extensions?.code === "TOKEN_ALREADY_USED") { + void navigate("/magic-link-already-used"); + return; + } } toast({ diff --git a/apps/trust/src/routes.tsx b/apps/trust/src/routes.tsx index 06937417c..cafc003f5 100644 --- a/apps/trust/src/routes.tsx +++ b/apps/trust/src/routes.tsx @@ -57,6 +57,10 @@ const routes = [ path: "/magic-link-expired", Component: lazy(() => import("#/pages/auth/MagicLinkExpiredPage")), }, + { + path: "/magic-link-already-used", + Component: lazy(() => import("#/pages/auth/MagicLinkAlreadyUsedPage")), + }, { path: "/full-name", Component: lazy(() => import("#/pages/auth/FullNamePage")), diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index dabefab82..4a12f9eaa 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -680,7 +680,7 @@ func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString s if err := token.LoadByHashedValueForUpdate(ctx, tx, hashedValue); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { - return NewInvalidTokenError() + return NewTokenAlreadyUsedError() } return fmt.Errorf("cannot load token by hashed value: %w", err) diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 571e18c76..f08ef1438 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -31,6 +31,16 @@ func (e ErrInvalidToken) Error() string { return e.message } +type ErrTokenAlreadyUsed struct{ message string } + +func NewTokenAlreadyUsedError() error { + return &ErrTokenAlreadyUsed{"this magic link has already been used"} +} + +func (e ErrTokenAlreadyUsed) Error() string { + return e.message +} + type ErrExpiredToken struct{ message string } func NewExpiredTokenError() error { diff --git a/pkg/server/api/trust/v1/auth_resolvers.go b/pkg/server/api/trust/v1/auth_resolvers.go index 382967886..d452b3a6f 100644 --- a/pkg/server/api/trust/v1/auth_resolvers.go +++ b/pkg/server/api/trust/v1/auth_resolvers.go @@ -83,6 +83,10 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri return nil, gqlutils.TokenExpired(ctx, err) } + if _, ok := errors.AsType[*iam.ErrTokenAlreadyUsed](err); ok { + return nil, gqlutils.TokenAlreadyUsed(ctx, err) + } + if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok { return nil, gqlutils.Invalid(ctx, err) } @@ -105,6 +109,10 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri return nil, gqlutils.TokenExpired(ctx, err) } + if _, ok := errors.AsType[*iam.ErrTokenAlreadyUsed](err); ok { + return nil, gqlutils.TokenAlreadyUsed(ctx, err) + } + if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok { return nil, gqlutils.Invalid(ctx, err) } diff --git a/pkg/server/gqlutils/errors.go b/pkg/server/gqlutils/errors.go index db192da5c..1f4df6361 100644 --- a/pkg/server/gqlutils/errors.go +++ b/pkg/server/gqlutils/errors.go @@ -150,6 +150,14 @@ func TokenExpired(ctx context.Context, err error) *gqlerror.Error { } } +func TokenAlreadyUsed(ctx context.Context, err error) *gqlerror.Error { + return &gqlerror.Error{ + Message: err.Error(), + Path: graphql.GetPath(ctx), + Extensions: map[string]any{"code": "TOKEN_ALREADY_USED"}, + } +} + func Invalid(ctx context.Context, err error) *gqlerror.Error { var details map[string]any