Add dedicated expired magic link page
Instead of a toast, expired magic link tokens now return a TOKEN_EXPIRED GraphQL error code and redirect users to a dedicated /magic-link-expired page with a clear CTA to request a new link. Signed-off-by: Bryan Frimin <bryan@getprobo.com> Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
46
apps/trust/src/pages/auth/MagicLinkExpiredPage.tsx
Normal file
46
apps/trust/src/pages/auth/MagicLinkExpiredPage.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// 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 { 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 (
|
||||||
|
<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">{__("Link Expired")}</h1>
|
||||||
|
<p className="text-txt-tertiary">
|
||||||
|
{__(
|
||||||
|
"This magic link has expired. Magic links are only valid for 15 minutes. Please request a new one.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
className="w-xs h-10"
|
||||||
|
onClick={() => void navigate("/connect")}
|
||||||
|
>
|
||||||
|
{__("Request a new link")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { useTranslate } from "@probo/i18n";
|
|||||||
import { useToast } from "@probo/ui";
|
import { useToast } from "@probo/ui";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useMutation } from "react-relay";
|
import { useMutation } from "react-relay";
|
||||||
import { Link, useSearchParams } from "react-router";
|
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import { getPathPrefix } from "#/utils/pathPrefix";
|
import { getPathPrefix } from "#/utils/pathPrefix";
|
||||||
@@ -36,6 +36,7 @@ const verifyMagicLinkMutation = graphql`
|
|||||||
export default function VerifyMagicLinkPagePageMutation() {
|
export default function VerifyMagicLinkPagePageMutation() {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const submittedRef = useRef<boolean>(false);
|
const submittedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
@@ -59,17 +60,16 @@ export default function VerifyMagicLinkPagePageMutation() {
|
|||||||
window.location.href = window.location.origin + getPathPrefix();
|
window.location.href = window.location.origin + getPathPrefix();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const hasExpiredToken = errors.some(
|
if (err.extensions?.code === "TOKEN_EXPIRED") {
|
||||||
err => err.message === "token has expired",
|
void navigate("/magic-link-expired");
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: __("Error"),
|
title: __("Error"),
|
||||||
description: hasExpiredToken
|
description: formatError(__("Failed to connect"), errors),
|
||||||
? __("This magic link has expired. Please request a new one.")
|
|
||||||
: formatError(__("Failed to connect"), errors),
|
|
||||||
variant: "error",
|
variant: "error",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -102,7 +102,7 @@ export default function VerifyMagicLinkPagePageMutation() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [__, toast, verifyMagicLink]);
|
}, [__, navigate, toast, verifyMagicLink]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = searchParams.get("token");
|
const token = searchParams.get("token");
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ const routes = [
|
|||||||
path: "/verify-magic-link",
|
path: "/verify-magic-link",
|
||||||
Component: lazy(() => import("#/pages/auth/VerifyMagicLinkPage")),
|
Component: lazy(() => import("#/pages/auth/VerifyMagicLinkPage")),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/magic-link-expired",
|
||||||
|
Component: lazy(() => import("#/pages/auth/MagicLinkExpiredPage")),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/full-name",
|
path: "/full-name",
|
||||||
Component: lazy(() => import("#/pages/auth/FullNamePage")),
|
Component: lazy(() => import("#/pages/auth/FullNamePage")),
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri
|
|||||||
email, err := r.iam.AuthService.GetMagicLinkEmail(ctx, input.Token)
|
email, err := r.iam.AuthService.GetMagicLinkEmail(ctx, input.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok {
|
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 {
|
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)
|
identity, session, continueURL, err = r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok {
|
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 {
|
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)
|
identity, session, continueURL, err = r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok {
|
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 {
|
if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok {
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ func Conflictf(ctx context.Context, format string, a ...any) *gqlerror.Error {
|
|||||||
return Conflict(ctx, fmt.Errorf(format, a...))
|
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 {
|
func Invalid(ctx context.Context, err error) *gqlerror.Error {
|
||||||
var details map[string]any
|
var details map[string]any
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user