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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import type { GraphQLError } from "@probo/helpers";
|
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button, Field, useToast } from "@probo/ui";
|
import { Button } from "@probo/ui";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
|
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
|
||||||
import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl";
|
import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl";
|
||||||
|
|
||||||
import type { ConnectPageMutation, SendMagicLinkInput } from "./__generated__/ConnectPageMutation.graphql";
|
|
||||||
import type { ConnectPageQuery } from "./__generated__/ConnectPageQuery.graphql";
|
import type { ConnectPageQuery } from "./__generated__/ConnectPageQuery.graphql";
|
||||||
import { Divider } from "./_components/Divider";
|
|
||||||
import { OIDCButton } from "./_components/OIDCButton";
|
|
||||||
|
|
||||||
export const connectPageQuery = graphql`
|
export const connectPageQuery = graphql`
|
||||||
query ConnectPageQuery {
|
query ConnectPageQuery {
|
||||||
currentTrustCenter @required(action: THROW) {
|
currentTrustCenter @required(action: THROW) {
|
||||||
title
|
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: {
|
export function ConnectPage(props: {
|
||||||
queryRef: PreloadedQuery<ConnectPageQuery>;
|
queryRef: PreloadedQuery<ConnectPageQuery>;
|
||||||
}) {
|
}) {
|
||||||
const { queryRef } = props;
|
const { queryRef } = props;
|
||||||
|
|
||||||
const { __ } = useTranslate();
|
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 safeContinueUrl = useSafeContinueUrl();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentTrustCenter: { title },
|
currentTrustCenter: { title },
|
||||||
oidcProviders,
|
|
||||||
} = usePreloadedQuery<ConnectPageQuery>(connectPageQuery, queryRef);
|
} = 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`));
|
usePageTitle(__(`Connect to ${title}'s Compliance Page`));
|
||||||
|
|
||||||
const {
|
const initiateURL = new URL("/initiate", window.location.origin);
|
||||||
handleSubmit: handleSubmitWrapper,
|
initiateURL.searchParams.set("continue", safeContinueUrl.toString());
|
||||||
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",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 w-full max-w-md mx-auto pt-8">
|
<div className="space-y-6 w-full max-w-md mx-auto pt-8">
|
||||||
@@ -171,45 +66,14 @@ export function ConnectPage(props: {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{oidcProviders.length > 0 && (
|
<Button
|
||||||
<div className="space-y-4">
|
className="w-full h-10"
|
||||||
{oidcProviders.map((providerRef, index) => (
|
onClick={() => {
|
||||||
<OIDCButton key={index} providerRef={providerRef} />
|
window.location.href = initiateURL.toString();
|
||||||
))}
|
}}
|
||||||
<Divider>{__("Or")}</Divider>
|
>
|
||||||
</div>
|
{__("Get Access")}
|
||||||
)}
|
</Button>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</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);
|
const url = new URL(formattedHost);
|
||||||
|
|
||||||
// Compliance pages are always served at the root of a dedicated host.
|
// Compliance pages are always served at the root of a dedicated host.
|
||||||
url.pathname = `/api/trust/v1/graphql`;
|
url.pathname = "/graphql";
|
||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,18 +55,6 @@ const routes = [
|
|||||||
path: "/connect",
|
path: "/connect",
|
||||||
Component: lazy(() => import("#/pages/auth/ConnectPageLoader")),
|
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",
|
path: "/full-name",
|
||||||
Component: lazy(() => import("#/pages/auth/FullNamePage")),
|
Component: lazy(() => import("#/pages/auth/FullNamePage")),
|
||||||
|
|||||||
Reference in New Issue
Block a user