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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
Reference in New Issue
Block a user