diff --git a/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx b/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx new file mode 100644 index 000000000..d75ce396b --- /dev/null +++ b/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx @@ -0,0 +1,44 @@ +// 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. Please request a new one.", + )} +

+
+ +
+ ); +} diff --git a/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx b/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx new file mode 100644 index 000000000..36230c4c7 --- /dev/null +++ b/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx @@ -0,0 +1,44 @@ +// 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 MagicLinkExpiredPage() { + const { __ } = useTranslate(); + const navigate = useNavigate(); + + usePageTitle(__("Link Expired")); + + return ( +
+
+

{__("Link Expired")}

+

+ {__( + "This magic link has expired. Magic links are only valid for 15 minutes. Please request a new one.", + )} +

+
+ +
+ ); +} diff --git a/apps/console/src/pages/iam/auth/PortalLoginPage.tsx b/apps/console/src/pages/iam/auth/PortalLoginPage.tsx new file mode 100644 index 000000000..ada025d13 --- /dev/null +++ b/apps/console/src/pages/iam/auth/PortalLoginPage.tsx @@ -0,0 +1,234 @@ +// 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, Field, Google, Microsoft, useToast } from "@probo/ui"; +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router"; +import { z } from "zod"; + +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const schema = z.object({ + email: z.string().email(), +}); + +type FormData = z.infer; + +const timerDurationSeconds = 60; + +type OIDCProvider = { + name: string; + loginURL: string; +}; + +function buildAuthorizeContinueURL(authorizeParam: string | null): string | null { + if (!authorizeParam) { + return null; + } + + const url = new URL("/api/connect/v1/oauth2/authorize", window.location.origin); + const params = new URLSearchParams(authorizeParam); + for (const [key, value] of params.entries()) { + url.searchParams.set(key, value); + } + + return url.toString(); +} + +async function fetchOIDCProviders(): Promise { + const response = await fetch("/api/connect/v1/graphql", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + query: "query { oidcProviders { name loginURL } }", + }), + }); + + if (!response.ok) { + return []; + } + + const payload = await response.json() as { + data?: { oidcProviders?: OIDCProvider[] }; + }; + + return payload.data?.oidcProviders ?? []; +} + +export default function PortalLoginPage() { + const { __ } = useTranslate(); + const { toast } = useToast(); + const [searchParams] = useSearchParams(); + const authorizeParam = searchParams.get("authorize"); + const authorizeContinueURL = buildAuthorizeContinueURL(authorizeParam); + + const [magicLinkSent, setMagicLinkSent] = useState(false); + const interval = useRef>(undefined); + const [timer, setTimer] = useState(timerDurationSeconds); + const [oidcProviders, setOidcProviders] = useState([]); + + usePageTitle(__("Sign in to Compliance Page")); + + useEffect(() => { + void fetchOIDCProviders().then(setOidcProviders); + }, []); + + useEffect(() => { + if (!magicLinkSent && interval.current) { + clearInterval(interval.current); + interval.current = undefined; + } + if (magicLinkSent) { + clearInterval(interval.current); + interval.current = setInterval(() => { + setTimer(value => Math.max(value - 1, 0)); + }, 1000); + } + + return () => { + clearInterval(interval.current); + }; + }, [magicLinkSent]); + + const { + handleSubmit: handleSubmitWrapper, + register, + formState, + } = useFormWithSchema(schema, { + defaultValues: { email: "" }, + }); + + const handleSubmit = handleSubmitWrapper(async ({ email }: FormData) => { + if (!authorizeParam) { + toast({ + title: __("Error"), + description: __("Invalid sign-in request"), + variant: "error", + }); + return; + } + + const body = new URLSearchParams(); + body.set("email", email); + body.set("authorize", authorizeParam); + + const response = await fetch("/api/connect/v1/magic-link/send", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + credentials: "include", + body, + }); + + if (!response.ok) { + toast({ + title: __("Error"), + description: __("Cannot send magic link"), + variant: "error", + }); + return; + } + + toast({ + title: __("Success"), + description: __("Magic link sent!"), + variant: "success", + }); + setTimer(timerDurationSeconds); + setMagicLinkSent(true); + }); + + const providerIcons: Record = { + google: Google, + microsoft: Microsoft, + }; + + if (!authorizeContinueURL) { + return ( +

+ {__("Invalid sign-in request")} +

+ ); + } + + return ( +
+
+

{__("Sign in to Compliance Page")}

+

+ {__("Use your email or a connected account to continue")} +

+
+ + {oidcProviders.length > 0 && ( +
+ {oidcProviders.map(provider => { + const Icon = providerIcons[provider.name]; + const loginURL = new URL(provider.loginURL, window.location.origin); + loginURL.searchParams.set("continue", authorizeContinueURL); + + return ( + + ); + })} +
+ )} + +
void handleSubmit(e)} className="space-y-4"> + + + {magicLinkSent && ( +

+ {__( + "Magic link sent! Check your email and use the link to continue.", + )} +

+ )} + + + +
+ ); +} diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 864b0bb63..82cf24fec 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -118,6 +118,18 @@ const routes = [ () => import("./pages/iam/auth/ConsentPageLoader"), ), }, + { + path: "portal-login", + Component: lazy(() => import("./pages/iam/auth/PortalLoginPage")), + }, + { + path: "magic-link-expired", + Component: lazy(() => import("./pages/iam/auth/MagicLinkExpiredPage")), + }, + { + path: "magic-link-already-used", + Component: lazy(() => import("./pages/iam/auth/MagicLinkAlreadyUsedPage")), + }, ], }, {