Unify console sign-in for connect authorize

Remove the separate portal login page, show OAuth client branding on
sign-in, and preserve authorize continue URLs across auth methods.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-15 17:33:17 +02:00
parent 5133b5feeb
commit 36bc636a08
12 changed files with 371 additions and 267 deletions

View File

@@ -0,0 +1,27 @@
// 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 { useSafeContinueUrl } from "./useSafeContinueUrl";
export function usePostAuthRedirectUrl(): string {
const safeContinueUrl = useSafeContinueUrl();
return safeContinueUrl.href;
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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.
const oauth2AuthorizePathSuffix = "/oauth2/authorize";
function parseContinueUrl(continueParam: string | null): URL | null {
if (!continueParam) {
return null;
}
try {
return new URL(continueParam, window.location.origin);
} catch {
return null;
}
}
export function isOAuthAuthorizeContinueUrl(continueParam: string | null): boolean {
const url = parseContinueUrl(continueParam);
if (!url) {
return false;
}
return url.pathname.endsWith(oauth2AuthorizePathSuffix);
}
export function clientIdFromContinueUrl(continueParam: string | null): string | null {
const url = parseContinueUrl(continueParam);
if (!url) {
return null;
}
return url.searchParams.get("client_id");
}

View File

@@ -20,19 +20,26 @@
import { Card, Logo } from "@probo/ui";
import type { PropsWithChildren } from "react";
import { Outlet } from "react-router";
import { Outlet, useSearchParams } from "react-router";
import { isOAuthAuthorizeContinueUrl } from "#/lib/buildAuthorizeContinueURL";
import { IAMRelayProvider } from "#/providers/IAMRelayProvider";
export default function AuthLayout(props: PropsWithChildren) {
const { children } = props;
const [searchParams] = useSearchParams();
const isAuthorizeFlow = isOAuthAuthorizeContinueUrl(searchParams.get("continue"));
return (
<div className="min-h-screen text-txt-primary bg-level-0 flex flex-col items-center justify-center">
<Card className="w-full max-w-lg px-12 py-8 flex flex-col items-center justify-center">
<div className="w-full flex flex-col items-center justify-center gap-8">
<Logo withPicto className="w-[110px]" />
<div className="w-full border-t border-t-border-mid" />
{!isAuthorizeFlow && (
<>
<Logo withPicto className="w-[110px]" />
<div className="w-full border-t border-t-border-mid" />
</>
)}
</div>
<IAMRelayProvider>
{children ?? <Outlet />}

View File

@@ -1,234 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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, 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<typeof schema>;
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<OIDCProvider[]> {
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<ReturnType<typeof setTimeout>>(undefined);
const [timer, setTimer] = useState(timerDurationSeconds);
const [oidcProviders, setOidcProviders] = useState<OIDCProvider[]>([]);
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<string, typeof Google> = {
google: Google,
microsoft: Microsoft,
};
if (!authorizeContinueURL) {
return (
<p className="text-txt-tertiary text-center">
{__("Invalid sign-in request")}
</p>
);
}
return (
<div className="space-y-6 w-full">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">{__("Sign in to Compliance Page")}</h1>
<p className="text-txt-tertiary">
{__("Use your email or a connected account to continue")}
</p>
</div>
{oidcProviders.length > 0 && (
<div className="space-y-3">
{oidcProviders.map(provider => {
const Icon = providerIcons[provider.name];
const loginURL = new URL(provider.loginURL, window.location.origin);
loginURL.searchParams.set("continue", authorizeContinueURL);
return (
<Button
key={provider.name}
variant="secondary"
className="w-full h-10"
onClick={() => {
window.location.href = loginURL.toString();
}}
>
<span className="flex items-center gap-2">
{Icon && <Icon width={18} height={18} />}
{__(`Sign in with ${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`)}
</span>
</Button>
);
})}
</div>
)}
<form onSubmit={e => void handleSubmit(e)} className="space-y-4">
<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 email and use the link to continue.",
)}
</p>
)}
<Button
type="submit"
className="w-full h-10"
disabled={formState.isSubmitting || (magicLinkSent && timer !== 0)}
>
{magicLinkSent
? timer === 0
? __("Resend Link")
: `${__("Resend Link in")} ${timer}s`
: __("Send Magic Link")}
</Button>
</form>
</div>
);
}

View File

@@ -27,7 +27,7 @@ import { Link, matchPath, useLocation } from "react-router";
import { graphql } from "relay-runtime";
import type { PasswordSignInPageMutation } from "#/__generated__/iam/PasswordSignInPageMutation.graphql";
import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl";
import { usePostAuthRedirectUrl } from "#/hooks/usePostAuthRedirectUrl";
const signInMutation = graphql`
mutation PasswordSignInPageMutation($input: SignInInput!) {
@@ -41,7 +41,7 @@ const signInMutation = graphql`
export default function PasswordSignInPage() {
const location = useLocation();
const safeContinueUrl = useSafeContinueUrl();
const postAuthRedirectUrl = usePostAuthRedirectUrl();
const { __ } = useTranslate();
const { toast } = useToast();
@@ -59,7 +59,7 @@ export default function PasswordSignInPage() {
const match = matchPath(
{ path: "/organizations/:organizationId", caseSensitive: false, end: false },
safeContinueUrl.pathname,
new URL(postAuthRedirectUrl, window.location.origin).pathname,
);
signIn({
@@ -84,7 +84,7 @@ export default function PasswordSignInPage() {
return;
}
window.location.href = safeContinueUrl.href;
window.location.href = postAuthRedirectUrl;
},
onError: (e) => {
toast({
@@ -107,7 +107,7 @@ export default function PasswordSignInPage() {
</Link>
<h1 className="text-center text-2xl font-bold">
{__("Login with Email")}
{__("Sign in with password")}
</h1>
<p className="text-center text-txt-tertiary mt-1 mb-6">
{__("Enter your email and password")}

View File

@@ -30,6 +30,7 @@ import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import type { SSOSignInPageQuery } from "#/__generated__/iam/SSOSignInPageQuery.graphql";
import { usePostAuthRedirectUrl } from "#/hooks/usePostAuthRedirectUrl";
const ssoAvailabilityQuery = graphql`
query SSOSignInPageQuery($email: EmailAddr!) {
@@ -103,6 +104,7 @@ export default function SSOSignInPage() {
<NavigateToSSOLoginURL
onSSOAvailabilityCheck={setChecking}
queryRef={queryRef}
loginSearch={location.search}
/>
)}
</>
@@ -112,13 +114,15 @@ export default function SSOSignInPage() {
function NavigateToSSOLoginURL(props: {
queryRef: PreloadedQuery<SSOSignInPageQuery>;
onSSOAvailabilityCheck: (checking: boolean) => void;
loginSearch: string;
}) {
const { queryRef } = props;
const { queryRef, loginSearch } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const postAuthRedirectUrl = usePostAuthRedirectUrl();
const { ssoLoginURL } = usePreloadedQuery<SSOSignInPageQuery>(
ssoAvailabilityQuery,
@@ -136,7 +140,7 @@ function NavigateToSSOLoginURL(props: {
variant: "error",
});
void navigate("/auth/login");
void navigate({ pathname: "/auth/login", search: loginSearch });
return;
}
@@ -150,10 +154,15 @@ function NavigateToSSOLoginURL(props: {
}
const url = new URL(ssoLoginURL.value);
url.search = searchParams.toString();
url.searchParams.set("continue", postAuthRedirectUrl);
for (const [key, value] of searchParams.entries()) {
if (key !== "continue") {
url.searchParams.set(key, value);
}
}
window.location.href = url.toString();
}, [__, navigate, ssoLoginURL, toast, searchParams]);
}, [__, loginSearch, navigate, postAuthRedirectUrl, searchParams, ssoLoginURL, toast]);
return null;
}

View File

@@ -18,22 +18,34 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button } from "@probo/ui";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { Link, useLocation } from "react-router";
import { Link, useLocation, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import type { SignInPageQuery } from "#/__generated__/iam/SignInPageQuery.graphql";
import { usePostAuthRedirectUrl } from "#/hooks/usePostAuthRedirectUrl";
import { isOAuthAuthorizeContinueUrl } from "#/lib/buildAuthorizeContinueURL";
import { Divider } from "./_components/Divider";
import { MagicLinkForm } from "./_components/MagicLinkForm";
import { OAuthClientBrandingSection } from "./_components/OAuthClientBrandingSection";
import { OIDCButton } from "./_components/OIDCButton";
export const signInPageQuery = graphql`
query SignInPageQuery {
query SignInPageQuery($clientId: String) {
oidcProviders {
...OIDCButtonFragment
}
oauthClientBranding(clientId: $clientId) {
name
clientURL
logo {
downloadUrl
}
}
}
`;
@@ -44,20 +56,68 @@ type Props = {
export default function SignInPage(props: Props) {
const { __ } = useTranslate();
const location = useLocation();
const [searchParams] = useSearchParams();
const postAuthRedirectUrl = usePostAuthRedirectUrl();
const continueParam = searchParams.get("continue");
const isAuthorizeFlow = isOAuthAuthorizeContinueUrl(continueParam);
const data = usePreloadedQuery<SignInPageQuery>(signInPageQuery, props.queryRef);
return (
<div className="w-full max-w-sm mx-auto pt-8">
<h1 className="text-2xl font-bold">
{__("Sign in to your account")}
</h1>
const clientBranding = data.oauthClientBranding;
const authorizeHeading = clientBranding?.name
? __("Sign in")
: __("Sign in to continue");
<div className="mt-6 space-y-4">
usePageTitle(
isAuthorizeFlow
? clientBranding?.name
? `${__("Sign in to")} ${clientBranding.name}`
: authorizeHeading
: __("Sign in to your account"),
);
const oidcContinueURL = isAuthorizeFlow ? postAuthRedirectUrl : undefined;
return (
<div className="w-full max-w-sm mx-auto pt-8 space-y-6">
{isAuthorizeFlow && clientBranding && (
<>
<OAuthClientBrandingSection
name={clientBranding.name}
logoDownloadUrl={clientBranding.logo?.downloadUrl}
clientURL={clientBranding.clientURL}
/>
<div className="w-full border-t border-t-border-mid" />
</>
)}
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">
{isAuthorizeFlow
? authorizeHeading
: __("Sign in to your account")}
</h1>
{isAuthorizeFlow && (
<p className="text-txt-tertiary">
{__("Use your email or a connected account to continue")}
</p>
)}
</div>
<div className="space-y-4">
{data.oidcProviders.map((providerRef, index) => (
<OIDCButton key={index} providerRef={providerRef} />
<OIDCButton
key={index}
providerRef={providerRef}
continueURL={oidcContinueURL}
/>
))}
<MagicLinkForm />
<Divider>{__("Or")}</Divider>
<Button
variant="secondary"
className="w-full h-10"
@@ -66,18 +126,16 @@ export default function SignInPage(props: Props) {
{__("Sign in with SSO")}
</Button>
<Divider>{__("Or")}</Divider>
<Button
variant="secondary"
className="w-full h-10"
to={{ pathname: "/auth/password-login", search: location.search }}
>
{__("Sign in with email")}
{__("Sign in with password")}
</Button>
</div>
<p className="mt-8 text-center text-sm text-txt-secondary">
<p className="text-center text-sm text-txt-secondary">
{__("New to Probo?")}
{" "}
<Link

View File

@@ -18,20 +18,28 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useQueryLoader } from "react-relay";
import { useSearchParams } from "react-router";
import type { SignInPageQuery } from "#/__generated__/iam/SignInPageQuery.graphql";
import { clientIdFromContinueUrl } from "#/lib/buildAuthorizeContinueURL";
import SignInPage, { signInPageQuery } from "./SignInPage";
function SignInPageQueryLoader() {
const [searchParams] = useSearchParams();
const clientId = useMemo(
() => clientIdFromContinueUrl(searchParams.get("continue")),
[searchParams],
);
const [queryRef, loadQuery]
= useQueryLoader<SignInPageQuery>(signInPageQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
loadQuery({ clientId });
}, [clientId, loadQuery]);
if (!queryRef) return null;

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { usePostAuthRedirectUrl } from "#/hooks/usePostAuthRedirectUrl";
const schema = z.object({
email: z.string().email(),
});
type FormData = z.infer<typeof schema>;
const timerDurationSeconds = 60;
export function MagicLinkForm() {
const { __ } = useTranslate();
const { toast } = useToast();
const postAuthRedirectUrl = usePostAuthRedirectUrl();
const [magicLinkSent, setMagicLinkSent] = useState(false);
const interval = useRef<ReturnType<typeof setInterval>>(undefined);
const [timer, setTimer] = useState(timerDurationSeconds);
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) => {
const body = new URLSearchParams();
body.set("email", email);
body.set("continue", postAuthRedirectUrl);
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);
});
return (
<form onSubmit={e => void handleSubmit(e)} className="space-y-4">
<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 email and use the link to continue.",
)}
</p>
)}
<Button
type="submit"
className="w-full h-10"
disabled={formState.isSubmitting || (magicLinkSent && timer !== 0)}
>
{magicLinkSent
? timer === 0
? __("Resend Link")
: `${__("Resend Link in")} ${timer}s`
: __("Send Magic Link")}
</Button>
</form>
);
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 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.
type OAuthClientBrandingSectionProps = {
name: string;
logoDownloadUrl?: string | null;
clientURL?: string | null;
};
function clientURLHost(clientURL: string): string {
try {
return new URL(clientURL).host;
} catch {
return clientURL;
}
}
export function OAuthClientBrandingSection({
name,
logoDownloadUrl,
clientURL,
}: OAuthClientBrandingSectionProps) {
return (
<div className="flex flex-col items-center gap-3 text-center">
{logoDownloadUrl && (
<img
src={logoDownloadUrl}
alt=""
className="h-12 w-auto max-w-[180px] object-contain"
/>
)}
<p className="text-lg font-semibold">{name}</p>
{clientURL && (
<a
href={clientURL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-txt-tertiary hover:text-txt-primary transition-colors"
>
{clientURLHost(clientURL)}
</a>
)}
</div>
);
}

View File

@@ -45,8 +45,10 @@ const providerIcons: Record<
export function OIDCButton({
providerRef,
continueURL,
}: {
providerRef: OIDCButtonFragment$key;
continueURL?: string;
}) {
const { __ } = useTranslate();
const [searchParams] = useSearchParams();
@@ -54,6 +56,7 @@ export function OIDCButton({
const provider = useFragment(fragment, providerRef);
const Icon = providerIcons[provider.name];
const organizationId = searchParams.get("organization-id");
const targetContinue = continueURL ?? safeContinueUrl.toString();
return (
<Button
@@ -61,7 +64,7 @@ export function OIDCButton({
className="w-full h-10"
onClick={() => {
const loginURL = new URL(provider.loginURL, window.location.origin);
loginURL.searchParams.set("continue", safeContinueUrl.toString());
loginURL.searchParams.set("continue", targetContinue);
if (organizationId) {
loginURL.searchParams.set("organization_id", organizationId);
}

View File

@@ -118,10 +118,6 @@ 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")),