Replace trust magic-link flow with OAuth connect

Redirect visitors through the compliance portal OAuth initiate
endpoint and remove the verify-magic-link page and routes.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-15 10:59:32 +02:00
parent b73780f818
commit 2fd0fbe3d7
4 changed files with 13 additions and 303 deletions

View File

@@ -18,145 +18,40 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import type { GraphQLError } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
import { useEffect, useRef, useState } from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { Button } from "@probo/ui";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl";
import type { ConnectPageMutation, SendMagicLinkInput } from "./__generated__/ConnectPageMutation.graphql";
import type { ConnectPageQuery } from "./__generated__/ConnectPageQuery.graphql";
import { Divider } from "./_components/Divider";
import { OIDCButton } from "./_components/OIDCButton";
export const connectPageQuery = graphql`
query ConnectPageQuery {
currentTrustCenter @required(action: THROW) {
title
}
oidcProviders {
...OIDCButtonFragment
}
}
`;
const sendMagicLinkMutation = graphql`
mutation ConnectPageMutation($input: SendMagicLinkInput!) {
sendMagicLink(input: $input) {
success
}
}
`;
const schema = z.object({
email: z.string().email(),
});
type FormData = z.infer<typeof schema>;
const timerDurationSeconds = 60;
export function ConnectPage(props: {
queryRef: PreloadedQuery<ConnectPageQuery>;
}) {
const { queryRef } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const [magicLinkSent, setMagicLinkSent] = useState<boolean>(false);
const interval = useRef<ReturnType<typeof setTimeout>>(undefined);
const [timer, setTimer] = useState<number>(timerDurationSeconds);
const safeContinueUrl = useSafeContinueUrl();
const {
currentTrustCenter: { title },
oidcProviders,
} = usePreloadedQuery<ConnectPageQuery>(connectPageQuery, queryRef);
useEffect(() => {
if (!magicLinkSent && interval.current) {
clearInterval(interval.current);
interval.current = undefined;
}
if (magicLinkSent) {
clearInterval(interval.current);
interval.current = setInterval(() => {
setTimer(timer => Math.max(timer - 1, 0));
}, 1000);
}
return () => {
clearInterval(interval.current);
};
}, [magicLinkSent]);
usePageTitle(__(`Connect to ${title}'s Compliance Page`));
const {
handleSubmit: handleSubmitWrapper,
register,
formState,
} = useFormWithSchema(schema, {
defaultValues: {
email: "",
},
});
const [sendMagicLink] = useMutation<ConnectPageMutation>(
sendMagicLinkMutation,
);
const handleSubmit = handleSubmitWrapper(({ email }: FormData) => {
const input: SendMagicLinkInput = { email };
if (safeContinueUrl) {
input.continue = safeContinueUrl.toString();
}
sendMagicLink({
variables: {
input: {
email,
continue: safeContinueUrl.toString(),
},
},
onCompleted: (_, errors: GraphQLError[] | null) => {
if (errors) {
for (const err of errors) {
if (err.extensions?.code === "ALREADY_AUTHENTICATED") {
window.location.href = "/";
return;
}
}
toast({
title: __("Error"),
description: __("Cannot send magic link"),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Magic link sent!"),
variant: "success",
});
setTimer(timerDurationSeconds);
setMagicLinkSent(true);
},
onError: (error) => {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
});
});
const initiateURL = new URL("/initiate", window.location.origin);
initiateURL.searchParams.set("continue", safeContinueUrl.toString());
return (
<div className="space-y-6 w-full max-w-md mx-auto pt-8">
@@ -171,45 +66,14 @@ export function ConnectPage(props: {
</p>
</div>
{oidcProviders.length > 0 && (
<div className="space-y-4">
{oidcProviders.map((providerRef, index) => (
<OIDCButton key={index} providerRef={providerRef} />
))}
<Divider>{__("Or")}</Divider>
</div>
)}
<form onSubmit={e => void handleSubmit(e)} className="space-y-6">
<Field
label={__("Email")}
placeholder="john.doe@acme.com"
{...register("email")}
type="email"
required
error={formState.errors.email?.message}
/>
{magicLinkSent && (
<p className="text-txt-primary text-sm">
{__(
"Magic Link Sent! Check your emails and use the link to connect.",
)}
</p>
)}
<Button
type="submit"
className="w-xs h-10 mx-auto"
disabled={formState.isSubmitting || (magicLinkSent && timer !== 0)}
>
{magicLinkSent
? timer === 0
? __("Resend Link")
: `${__("Resend Link in")} ${timer}s`
: __("Send Magic Link")}
</Button>
</form>
<Button
className="w-full h-10"
onClick={() => {
window.location.href = initiateURL.toString();
}}
>
{__("Get Access")}
</Button>
</div>
);
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { useToast } from "@probo/ui";
import { useCallback, useEffect, useRef } from "react";
import { useMutation } from "react-relay";
import { Link, useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import type { VerifyMagicLinkPageMutation } from "./__generated__/VerifyMagicLinkPageMutation.graphql";
const verifyMagicLinkMutation = graphql`
mutation VerifyMagicLinkPageMutation($input: VerifyMagicLinkInput!) {
verifyMagicLink(input: $input) {
continue
}
}
`;
export default function VerifyMagicLinkPagePageMutation() {
const { __ } = useTranslate();
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const submittedRef = useRef<boolean>(false);
usePageTitle(__("Verify Magic Link"));
const [verifyMagicLink] = useMutation<VerifyMagicLinkPageMutation>(
verifyMagicLinkMutation,
);
const handleVerifyMagicToken = useCallback((token: string) => {
if (submittedRef.current) return;
verifyMagicLink({
variables: {
input: { token },
},
onCompleted: (response, errors: GraphQLError[] | null) => {
if (errors) {
for (const err of errors) {
if (err.extensions?.code === "ALREADY_AUTHENTICATED") {
window.location.href = window.location.origin;
return;
}
if (err.extensions?.code === "TOKEN_EXPIRED") {
void navigate("/magic-link-expired");
return;
}
if (err.extensions?.code === "TOKEN_ALREADY_USED") {
void navigate("/magic-link-already-used");
return;
}
}
toast({
title: __("Error"),
description: formatError(__("Failed to connect"), errors),
variant: "error",
});
return;
}
const { verifyMagicLink } = response;
toast({
title: __("Success"),
description: __("Your have successfully signed in"),
variant: "success",
});
if (verifyMagicLink?.continue) {
try {
const continueUrl = new URL(verifyMagicLink.continue, window.location.origin);
window.location.href = window.location.origin + continueUrl.pathname + continueUrl.search;
} catch {
window.location.href = window.location.origin;
}
} else {
window.location.href = window.location.origin;
}
},
onError: (err) => {
toast({
title: __("Error"),
description: err.message,
variant: "error",
});
},
});
}, [__, navigate, toast, verifyMagicLink]);
useEffect(() => {
const token = searchParams.get("token");
if (!submittedRef.current && token) {
void handleVerifyMagicToken(token.trim());
submittedRef.current = true;
}
}, [handleVerifyMagicToken, searchParams]);
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">{__("Email Confirmation")}</h1>
<p className="text-txt-tertiary">
{__("Confirming your email address to complete registration…")}
</p>
</div>
<div className="text-center mt-6 text-sm text-txt-secondary">
<Link
to="/connect"
className="underline hover:text-txt-primary"
>
{__("Go back")}
</Link>
</div>
</div>
);
}

View File

@@ -43,7 +43,7 @@ export function buildEndpoint(): string {
const url = new URL(formattedHost);
// Compliance pages are always served at the root of a dedicated host.
url.pathname = `/api/trust/v1/graphql`;
url.pathname = "/graphql";
return url.toString();
}

View File

@@ -55,18 +55,6 @@ const routes = [
path: "/connect",
Component: lazy(() => import("#/pages/auth/ConnectPageLoader")),
},
{
path: "/verify-magic-link",
Component: lazy(() => import("#/pages/auth/VerifyMagicLinkPage")),
},
{
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")),