Add OIDC login support to compliance page

Add Google and Microsoft sign-in buttons to the trust center connect
page, matching the console sign-in experience. The backend OIDC flow
already supports flexible continue URLs, so only the GraphQL schema
and frontend needed changes.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-22 10:41:41 +01:00
parent 95803e4bca
commit 3e4a7d3638
3 changed files with 105 additions and 3 deletions

View File

@@ -1,10 +1,11 @@
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 { Button, Field, Google, Microsoft, useToast } from "@probo/ui";
import { type ComponentProps, Suspense, useEffect, useRef, useState } from "react";
import {
type PreloadedQuery,
useLazyLoadQuery,
useMutation,
usePreloadedQuery,
} from "react-relay";
@@ -16,6 +17,7 @@ import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { getPathPrefix } from "#/utils/pathPrefix";
import type { ConnectPageMutation, SendMagicLinkInput } from "./__generated__/ConnectPageMutation.graphql";
import type { ConnectPageOIDCQuery } from "./__generated__/ConnectPageOIDCQuery.graphql";
import type { ConnectPageQuery } from "./__generated__/ConnectPageQuery.graphql";
export const connectPageQuery = graphql`
@@ -28,6 +30,15 @@ export const connectPageQuery = graphql`
}
`;
const oidcProvidersQuery = graphql`
query ConnectPageOIDCQuery {
oidcProviders {
name
loginURL
}
}
`;
const sendMagicLinkMutation = graphql`
mutation ConnectPageMutation($input: SendMagicLinkInput!) {
sendMagicLink(input: $input) {
@@ -36,6 +47,14 @@ const sendMagicLinkMutation = graphql`
}
`;
const providerIcons: Record<
string,
(props: ComponentProps<"svg">) => React.ReactNode
> = {
google: Google,
microsoft: Microsoft,
};
const schema = z.object({
email: z.string().email(),
});
@@ -44,6 +63,58 @@ type FormData = z.infer<typeof schema>;
const timerDurationSeconds = 60;
function Divider({ children }: { children: React.ReactNode }) {
return (
<div className="relative my-6 w-full">
<div className="border-t border-border-mid" />
<span className="px-4 text-xs uppercase text-txt-secondary bg-level-0 absolute top-0 left-1/2 -translate-1/2">
{children}
</span>
</div>
);
}
function OIDCButtons({ safeContinueUrl }: { safeContinueUrl: string }) {
const { __ } = useTranslate();
const data = useLazyLoadQuery<ConnectPageOIDCQuery>(oidcProvidersQuery, {});
if (data.oidcProviders.length === 0) {
return null;
}
const continueUrl = new URL(safeContinueUrl);
return (
<>
{data.oidcProviders.map((provider) => {
const Icon = providerIcons[provider.name];
return (
<Button
key={provider.name}
variant="secondary"
className="w-full h-10"
onClick={() => {
window.location.href
= provider.loginURL
+ "?continue="
+ encodeURIComponent(
continueUrl.pathname + continueUrl.search,
);
}}
>
<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>
);
})}
<Divider>{__("Or")}</Divider>
</>
);
}
export function ConnectPage(props: {
queryRef: PreloadedQuery<ConnectPageQuery>;
}) {
@@ -164,11 +235,17 @@ export function ConnectPage(props: {
</h1>
<p className="text-txt-tertiary">
{__(
"Enter your email address to connect with a magic link and start requesting access to documents",
"Sign in to start requesting access to documents",
)}
</p>
</div>
<div className="space-y-4">
<Suspense fallback={null}>
<OIDCButtons safeContinueUrl={safeContinueUrl} />
</Suspense>
</div>
<form onSubmit={e => void handleSubmit(e)} className="space-y-6">
<Field
label={__("Email")}

View File

@@ -880,10 +880,18 @@ type RecordSigningEventPayload {
success: Boolean!
}
type OIDCProviderInfo {
name: String!
loginURL: String!
}
type Query {
viewer: Identity
node(id: ID!): Node
currentTrustCenter: TrustCenter
oidcProviders: [OIDCProviderInfo!]!
@goField(forceResolver: true)
@session(required: OPTIONAL)
}
type Mutation {

View File

@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"net"
"strings"
"time"
"go.gearno.de/kit/log"
@@ -993,6 +994,22 @@ func (r *queryResolver) CurrentTrustCenter(ctx context.Context) (*types.TrustCen
return response, nil
}
// OidcProviders is the resolver for the oidcProviders field.
func (r *queryResolver) OidcProviders(ctx context.Context) ([]*types.OIDCProviderInfo, error) {
providers := r.iam.OIDCService.EnabledProviders()
result := make([]*types.OIDCProviderInfo, 0, len(providers))
for _, p := range providers {
name := strings.ToLower(p.String())
result = append(result, &types.OIDCProviderInfo{
Name: name,
LoginURL: r.baseURL.WithPath("/api/connect/v1/oidc/" + name + "/login").MustString(),
})
}
return result, nil
}
// IsUserAuthorized is the resolver for the isUserAuthorized field.
func (r *reportResolver) IsUserAuthorized(ctx context.Context, obj *types.Report) (bool, error) {
trustService := r.TrustService(ctx, obj.ID.TenantID())