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:
27
apps/console/src/hooks/usePostAuthRedirectUrl.ts
Normal file
27
apps/console/src/hooks/usePostAuthRedirectUrl.ts
Normal 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;
|
||||||
|
}
|
||||||
45
apps/console/src/lib/buildAuthorizeContinueURL.ts
Normal file
45
apps/console/src/lib/buildAuthorizeContinueURL.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -20,19 +20,26 @@
|
|||||||
|
|
||||||
import { Card, Logo } from "@probo/ui";
|
import { Card, Logo } from "@probo/ui";
|
||||||
import type { PropsWithChildren } from "react";
|
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";
|
import { IAMRelayProvider } from "#/providers/IAMRelayProvider";
|
||||||
|
|
||||||
export default function AuthLayout(props: PropsWithChildren) {
|
export default function AuthLayout(props: PropsWithChildren) {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const isAuthorizeFlow = isOAuthAuthorizeContinueUrl(searchParams.get("continue"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-txt-primary bg-level-0 flex flex-col items-center justify-center">
|
<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">
|
<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">
|
<div className="w-full flex flex-col items-center justify-center gap-8">
|
||||||
|
{!isAuthorizeFlow && (
|
||||||
|
<>
|
||||||
<Logo withPicto className="w-[110px]" />
|
<Logo withPicto className="w-[110px]" />
|
||||||
<div className="w-full border-t border-t-border-mid" />
|
<div className="w-full border-t border-t-border-mid" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<IAMRelayProvider>
|
<IAMRelayProvider>
|
||||||
{children ?? <Outlet />}
|
{children ?? <Outlet />}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ import { Link, matchPath, useLocation } from "react-router";
|
|||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { PasswordSignInPageMutation } from "#/__generated__/iam/PasswordSignInPageMutation.graphql";
|
import type { PasswordSignInPageMutation } from "#/__generated__/iam/PasswordSignInPageMutation.graphql";
|
||||||
import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl";
|
import { usePostAuthRedirectUrl } from "#/hooks/usePostAuthRedirectUrl";
|
||||||
|
|
||||||
const signInMutation = graphql`
|
const signInMutation = graphql`
|
||||||
mutation PasswordSignInPageMutation($input: SignInInput!) {
|
mutation PasswordSignInPageMutation($input: SignInInput!) {
|
||||||
@@ -41,7 +41,7 @@ const signInMutation = graphql`
|
|||||||
|
|
||||||
export default function PasswordSignInPage() {
|
export default function PasswordSignInPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const safeContinueUrl = useSafeContinueUrl();
|
const postAuthRedirectUrl = usePostAuthRedirectUrl();
|
||||||
|
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -59,7 +59,7 @@ export default function PasswordSignInPage() {
|
|||||||
|
|
||||||
const match = matchPath(
|
const match = matchPath(
|
||||||
{ path: "/organizations/:organizationId", caseSensitive: false, end: false },
|
{ path: "/organizations/:organizationId", caseSensitive: false, end: false },
|
||||||
safeContinueUrl.pathname,
|
new URL(postAuthRedirectUrl, window.location.origin).pathname,
|
||||||
);
|
);
|
||||||
|
|
||||||
signIn({
|
signIn({
|
||||||
@@ -84,7 +84,7 @@ export default function PasswordSignInPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = safeContinueUrl.href;
|
window.location.href = postAuthRedirectUrl;
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -107,7 +107,7 @@ export default function PasswordSignInPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-center text-2xl font-bold">
|
<h1 className="text-center text-2xl font-bold">
|
||||||
{__("Login with Email")}
|
{__("Sign in with password")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-center text-txt-tertiary mt-1 mb-6">
|
<p className="text-center text-txt-tertiary mt-1 mb-6">
|
||||||
{__("Enter your email and password")}
|
{__("Enter your email and password")}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
|||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { SSOSignInPageQuery } from "#/__generated__/iam/SSOSignInPageQuery.graphql";
|
import type { SSOSignInPageQuery } from "#/__generated__/iam/SSOSignInPageQuery.graphql";
|
||||||
|
import { usePostAuthRedirectUrl } from "#/hooks/usePostAuthRedirectUrl";
|
||||||
|
|
||||||
const ssoAvailabilityQuery = graphql`
|
const ssoAvailabilityQuery = graphql`
|
||||||
query SSOSignInPageQuery($email: EmailAddr!) {
|
query SSOSignInPageQuery($email: EmailAddr!) {
|
||||||
@@ -103,6 +104,7 @@ export default function SSOSignInPage() {
|
|||||||
<NavigateToSSOLoginURL
|
<NavigateToSSOLoginURL
|
||||||
onSSOAvailabilityCheck={setChecking}
|
onSSOAvailabilityCheck={setChecking}
|
||||||
queryRef={queryRef}
|
queryRef={queryRef}
|
||||||
|
loginSearch={location.search}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -112,13 +114,15 @@ export default function SSOSignInPage() {
|
|||||||
function NavigateToSSOLoginURL(props: {
|
function NavigateToSSOLoginURL(props: {
|
||||||
queryRef: PreloadedQuery<SSOSignInPageQuery>;
|
queryRef: PreloadedQuery<SSOSignInPageQuery>;
|
||||||
onSSOAvailabilityCheck: (checking: boolean) => void;
|
onSSOAvailabilityCheck: (checking: boolean) => void;
|
||||||
|
loginSearch: string;
|
||||||
}) {
|
}) {
|
||||||
const { queryRef } = props;
|
const { queryRef, loginSearch } = props;
|
||||||
|
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const postAuthRedirectUrl = usePostAuthRedirectUrl();
|
||||||
|
|
||||||
const { ssoLoginURL } = usePreloadedQuery<SSOSignInPageQuery>(
|
const { ssoLoginURL } = usePreloadedQuery<SSOSignInPageQuery>(
|
||||||
ssoAvailabilityQuery,
|
ssoAvailabilityQuery,
|
||||||
@@ -136,7 +140,7 @@ function NavigateToSSOLoginURL(props: {
|
|||||||
variant: "error",
|
variant: "error",
|
||||||
});
|
});
|
||||||
|
|
||||||
void navigate("/auth/login");
|
void navigate({ pathname: "/auth/login", search: loginSearch });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,10 +154,15 @@ function NavigateToSSOLoginURL(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(ssoLoginURL.value);
|
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();
|
window.location.href = url.toString();
|
||||||
}, [__, navigate, ssoLoginURL, toast, searchParams]);
|
}, [__, loginSearch, navigate, postAuthRedirectUrl, searchParams, ssoLoginURL, toast]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,22 +18,34 @@
|
|||||||
// 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 { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button } from "@probo/ui";
|
import { Button } from "@probo/ui";
|
||||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
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 { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { SignInPageQuery } from "#/__generated__/iam/SignInPageQuery.graphql";
|
import type { SignInPageQuery } from "#/__generated__/iam/SignInPageQuery.graphql";
|
||||||
|
import { usePostAuthRedirectUrl } from "#/hooks/usePostAuthRedirectUrl";
|
||||||
|
import { isOAuthAuthorizeContinueUrl } from "#/lib/buildAuthorizeContinueURL";
|
||||||
|
|
||||||
import { Divider } from "./_components/Divider";
|
import { Divider } from "./_components/Divider";
|
||||||
|
import { MagicLinkForm } from "./_components/MagicLinkForm";
|
||||||
|
import { OAuthClientBrandingSection } from "./_components/OAuthClientBrandingSection";
|
||||||
import { OIDCButton } from "./_components/OIDCButton";
|
import { OIDCButton } from "./_components/OIDCButton";
|
||||||
|
|
||||||
export const signInPageQuery = graphql`
|
export const signInPageQuery = graphql`
|
||||||
query SignInPageQuery {
|
query SignInPageQuery($clientId: String) {
|
||||||
oidcProviders {
|
oidcProviders {
|
||||||
...OIDCButtonFragment
|
...OIDCButtonFragment
|
||||||
}
|
}
|
||||||
|
oauthClientBranding(clientId: $clientId) {
|
||||||
|
name
|
||||||
|
clientURL
|
||||||
|
logo {
|
||||||
|
downloadUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -44,20 +56,68 @@ type Props = {
|
|||||||
export default function SignInPage(props: Props) {
|
export default function SignInPage(props: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const location = useLocation();
|
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);
|
const data = usePreloadedQuery<SignInPageQuery>(signInPageQuery, props.queryRef);
|
||||||
|
|
||||||
return (
|
const clientBranding = data.oauthClientBranding;
|
||||||
<div className="w-full max-w-sm mx-auto pt-8">
|
const authorizeHeading = clientBranding?.name
|
||||||
<h1 className="text-2xl font-bold">
|
? __("Sign in")
|
||||||
{__("Sign in to your account")}
|
: __("Sign in to continue");
|
||||||
</h1>
|
|
||||||
|
|
||||||
<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) => (
|
{data.oidcProviders.map((providerRef, index) => (
|
||||||
<OIDCButton key={index} providerRef={providerRef} />
|
<OIDCButton
|
||||||
|
key={index}
|
||||||
|
providerRef={providerRef}
|
||||||
|
continueURL={oidcContinueURL}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<MagicLinkForm />
|
||||||
|
|
||||||
|
<Divider>{__("Or")}</Divider>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full h-10"
|
className="w-full h-10"
|
||||||
@@ -66,18 +126,16 @@ export default function SignInPage(props: Props) {
|
|||||||
{__("Sign in with SSO")}
|
{__("Sign in with SSO")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Divider>{__("Or")}</Divider>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full h-10"
|
className="w-full h-10"
|
||||||
to={{ pathname: "/auth/password-login", search: location.search }}
|
to={{ pathname: "/auth/password-login", search: location.search }}
|
||||||
>
|
>
|
||||||
{__("Sign in with email")}
|
{__("Sign in with password")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-8 text-center text-sm text-txt-secondary">
|
<p className="text-center text-sm text-txt-secondary">
|
||||||
{__("New to Probo?")}
|
{__("New to Probo?")}
|
||||||
{" "}
|
{" "}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -18,20 +18,28 @@
|
|||||||
// 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 { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useQueryLoader } from "react-relay";
|
import { useQueryLoader } from "react-relay";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
import type { SignInPageQuery } from "#/__generated__/iam/SignInPageQuery.graphql";
|
import type { SignInPageQuery } from "#/__generated__/iam/SignInPageQuery.graphql";
|
||||||
|
import { clientIdFromContinueUrl } from "#/lib/buildAuthorizeContinueURL";
|
||||||
|
|
||||||
import SignInPage, { signInPageQuery } from "./SignInPage";
|
import SignInPage, { signInPageQuery } from "./SignInPage";
|
||||||
|
|
||||||
function SignInPageQueryLoader() {
|
function SignInPageQueryLoader() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const clientId = useMemo(
|
||||||
|
() => clientIdFromContinueUrl(searchParams.get("continue")),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const [queryRef, loadQuery]
|
const [queryRef, loadQuery]
|
||||||
= useQueryLoader<SignInPageQuery>(signInPageQuery);
|
= useQueryLoader<SignInPageQuery>(signInPageQuery);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuery({});
|
loadQuery({ clientId });
|
||||||
}, [loadQuery]);
|
}, [clientId, loadQuery]);
|
||||||
|
|
||||||
if (!queryRef) return null;
|
if (!queryRef) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,8 +45,10 @@ const providerIcons: Record<
|
|||||||
|
|
||||||
export function OIDCButton({
|
export function OIDCButton({
|
||||||
providerRef,
|
providerRef,
|
||||||
|
continueURL,
|
||||||
}: {
|
}: {
|
||||||
providerRef: OIDCButtonFragment$key;
|
providerRef: OIDCButtonFragment$key;
|
||||||
|
continueURL?: string;
|
||||||
}) {
|
}) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -54,6 +56,7 @@ export function OIDCButton({
|
|||||||
const provider = useFragment(fragment, providerRef);
|
const provider = useFragment(fragment, providerRef);
|
||||||
const Icon = providerIcons[provider.name];
|
const Icon = providerIcons[provider.name];
|
||||||
const organizationId = searchParams.get("organization-id");
|
const organizationId = searchParams.get("organization-id");
|
||||||
|
const targetContinue = continueURL ?? safeContinueUrl.toString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -61,7 +64,7 @@ export function OIDCButton({
|
|||||||
className="w-full h-10"
|
className="w-full h-10"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const loginURL = new URL(provider.loginURL, window.location.origin);
|
const loginURL = new URL(provider.loginURL, window.location.origin);
|
||||||
loginURL.searchParams.set("continue", safeContinueUrl.toString());
|
loginURL.searchParams.set("continue", targetContinue);
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
loginURL.searchParams.set("organization_id", organizationId);
|
loginURL.searchParams.set("organization_id", organizationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,10 +118,6 @@ const routes = [
|
|||||||
() => import("./pages/iam/auth/ConsentPageLoader"),
|
() => import("./pages/iam/auth/ConsentPageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "portal-login",
|
|
||||||
Component: lazy(() => import("./pages/iam/auth/PortalLoginPage")),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "magic-link-expired",
|
path: "magic-link-expired",
|
||||||
Component: lazy(() => import("./pages/iam/auth/MagicLinkExpiredPage")),
|
Component: lazy(() => import("./pages/iam/auth/MagicLinkExpiredPage")),
|
||||||
|
|||||||
Reference in New Issue
Block a user