feat(webapp): Identity & Access setup flow improvements (#4082)

- auto-reloading for setting up identity & access
- ui/ux improvements around the process of setting up connections for
the identity & access
This commit is contained in:
Oskar Otwinowski
2026-06-30 18:46:15 +02:00
committed by GitHub
parent c2e2480a74
commit 93755cbdbd
2 changed files with 113 additions and 69 deletions
@@ -149,7 +149,7 @@ export function OrganizationSettingsSideMenu({
)}
{isSsoUsingPlugin && (
<SideMenuItem
name="SSO"
name="Identity & Access"
icon={PadlockIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
@@ -8,7 +8,7 @@ import {
import { type MetaFunction } from "@remix-run/react";
import { redirect, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import { useFetcher } from "@remix-run/react";
import { useFetcher, useRevalidator } from "@remix-run/react";
import { z } from "zod";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import {
@@ -41,7 +41,7 @@ import { throwPermissionDenied } from "~/utils/permissionDenied";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { v3BillingPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => [{ title: "SSO settings | Trigger.dev" }];
export const meta: MetaFunction = () => [{ title: "Identity & Access | Trigger.dev" }];
const Params = z.object({ organizationSlug: z.string() });
@@ -286,10 +286,12 @@ export default function Page() {
draftJitRoleId !== initialJitRoleId;
const [portalUrl, setPortalUrl] = useState<string | null>(null);
const [portalIntent, setPortalIntent] = useState<"sso" | "domain_verification" | null>(null);
const [enforceModalOpen, setEnforceModalOpen] = useState(false);
const portalFetcher = useFetcher<{ ok: boolean; url?: string; error?: string }>();
const saveFetcher = useFetcher();
const isSaving = saveFetcher.state !== "idle";
const revalidator = useRevalidator();
useEffect(() => {
if (portalFetcher.data?.ok && portalFetcher.data.url) {
@@ -297,8 +299,33 @@ export default function Page() {
}
}, [portalFetcher.data]);
// Poll for fresh domain/connection state only while setup is incomplete:
// the user is finishing steps in the admin portal (another tab) and we
// want the page to reflect them without a manual reload. This covers both
// pre-active states (no IdP org yet, and IdP org but no active connection).
// Once there's an active connection we stop — the ActiveConnectionState form
// holds local draft edits that a revalidation must not stomp. The upsell
// state is excluded by `isEntitled`.
const shouldPoll = isEntitled && !hasActive;
useEffect(() => {
if (!shouldPoll) return;
const id = setInterval(() => {
if (
revalidator.state !== "idle" ||
portalFetcher.state !== "idle" ||
saveFetcher.state !== "idle" ||
(typeof document !== "undefined" && document.visibilityState === "hidden")
) {
return;
}
revalidator.revalidate();
}, 5000);
return () => clearInterval(id);
}, [shouldPoll, revalidator, portalFetcher.state, saveFetcher.state]);
const openPortal = (intent: "sso" | "domain_verification") => {
setPortalUrl(null);
setPortalIntent(intent);
portalFetcher.submit({ action: "portal_link", intent }, { method: "POST" });
};
@@ -317,14 +344,14 @@ export default function Page() {
return (
<PageContainer>
<NavBar>
<PageTitle title="SSO" />
<PageTitle title="Identity & Access" />
</NavBar>
<PageBody scrollable={true}>
<MainHorizontallyCenteredContainer className="max-w-3xl space-y-6">
{!isEntitled ? (
<EnterpriseUpsellState organizationSlug={organization.slug} />
) : !status.hasIdpOrg ? (
<NoIdpOrgState onOpenPortal={() => openPortal("sso")} />
<NoIdpOrgState onOpenPortal={() => openPortal("domain_verification")} />
) : !hasActive ? (
<NoActiveConnectionState
domains={status.domains}
@@ -342,7 +369,7 @@ export default function Page() {
draftJitRoleId={draftJitRoleId}
isDirty={isDirty}
isSaving={isSaving}
onTogglePortal={() => openPortal("sso")}
onOpenPortal={openPortal}
onToggleEnforced={(next) => {
// Going on→off is harmless; going off→on locks users out so
// we still require explicit confirmation. The modal updates
@@ -361,7 +388,7 @@ export default function Page() {
</MainHorizontallyCenteredContainer>
</PageBody>
<PortalLinkDialog url={portalUrl} onClose={() => setPortalUrl(null)} />
<PortalLinkDialog url={portalUrl} intent={portalIntent} onClose={() => setPortalUrl(null)} />
<EnforceConfirmDialog
open={enforceModalOpen}
@@ -411,11 +438,14 @@ function NoIdpOrgState({ onOpenPortal }: { onOpenPortal: () => void }) {
<Header2>Configure SSO for your organization</Header2>
<Paragraph variant="base">
Single sign-on lets your IT admins manage who can access Trigger.dev through your identity
provider (Okta, Azure AD, Google Workspace, OneLogin, and more). The first click opens the
admin portal in a 5-minute single-use link.
provider (Okta, Azure AD, Google Workspace, OneLogin, and more).
</Paragraph>
<Button variant="primary/small" onClick={onOpenPortal} LeadingIcon={LockClosedIcon}>
Open admin portal
<Button
variant="tertiary/small"
onClick={onOpenPortal}
LeadingIcon={ArrowTopRightOnSquareIcon}
>
Start the process
</Button>
</div>
);
@@ -439,51 +469,49 @@ function NoActiveConnectionState({
}) {
const verifiedDomains = domains.filter((d) => d.state === "verified");
const failedDomains = domains.filter((d) => d.state === "failed");
const pendingDomains = domains.filter((d) => d.state === "pending");
const hasUnresolved = failedDomains.length > 0 || pendingDomains.length > 0;
const hasVerifiedDomain = verifiedDomains.length > 0;
return (
<div className="space-y-4">
{failedDomains.length > 0 && (
<Callout variant="error">
{failedDomains.length === 1
? `Domain verification failed for ${failedDomains[0].domain}. Re-check the DNS records in the admin portal and re-run verification.`
: `${failedDomains.length} domains failed verification. Re-check the DNS records in the admin portal and re-run verification.`}
</Callout>
)}
{failedDomains.length === 0 && verifiedDomains.length > 0 && (
<Callout variant="success">
{verifiedDomains.length === 1
? `Domain verified: ${verifiedDomains[0].domain}. Continue in the admin portal to finish setting up your identity provider connection.`
: `${verifiedDomains.length} domains verified. Continue in the admin portal to finish setting up your identity provider connection.`}
</Callout>
)}
{failedDomains.length === 0 && verifiedDomains.length === 0 && (
<Callout variant="warning">
Not yet configured. Continue in the admin portal to verify a domain and set up your
identity provider connection.
</Callout>
)}
{domains.length > 0 && (
<div className="space-y-2">
<Header2>Domains</Header2>
<DomainList domains={domains} />
</div>
)}
<div className="flex flex-wrap gap-2">
<Button variant="primary/small" onClick={onOpenSso}>
Configure SSO
</Button>
<Button variant="tertiary/small" onClick={onOpenDomain}>
{failedDomains.length > 0
? "Re-verify a domain"
: hasUnresolved
? "Continue verifying a domain"
: "Verify another domain"}
<div className="space-y-6">
<div className="space-y-2">
<Header2>Domains</Header2>
<Paragraph variant="small" className="text-text-dimmed">
Verify the email domains your team signs in with. Once a domain is verified you can
connect your identity provider.
</Paragraph>
{failedDomains.length > 0 && (
<Callout variant="error">
{failedDomains.length === 1
? `Domain verification failed for ${failedDomains[0].domain}. Re-check the DNS records in the admin portal and re-run verification.`
: `${failedDomains.length} domains failed verification. Re-check the DNS records in the admin portal and re-run verification.`}
</Callout>
)}
{domains.length > 0 && <DomainList domains={domains} />}
<Button
variant="tertiary/small"
onClick={onOpenDomain}
LeadingIcon={ArrowTopRightOnSquareIcon}
>
{domains.length > 0 ? "Verify another domain" : "Verify domain"}
</Button>
</div>
{hasVerifiedDomain && (
<div className="space-y-2">
<Header2>SSO</Header2>
<Paragraph variant="small" className="text-text-dimmed">
Connect your identity provider to finish setting up single sign-on for your verified
domains.
</Paragraph>
<Button
variant="tertiary/small"
onClick={onOpenSso}
LeadingIcon={ArrowTopRightOnSquareIcon}
>
Configure SSO
</Button>
</div>
)}
</div>
);
}
@@ -551,7 +579,7 @@ function ActiveConnectionState({
draftJitRoleId,
isDirty,
isSaving,
onTogglePortal,
onOpenPortal,
onToggleEnforced,
onToggleJit,
onChangeJitRole,
@@ -571,7 +599,7 @@ function ActiveConnectionState({
draftJitRoleId: string;
isDirty: boolean;
isSaving: boolean;
onTogglePortal: () => void;
onOpenPortal: (intent: "sso" | "domain_verification") => void;
onToggleEnforced: (next: boolean) => void;
onToggleJit: (next: boolean) => void;
onChangeJitRole: (roleId: string | null) => void;
@@ -594,6 +622,13 @@ function ActiveConnectionState({
</Paragraph>
</div>
))}
<Button
variant="tertiary/small"
onClick={() => onOpenPortal("sso")}
LeadingIcon={ArrowTopRightOnSquareIcon}
>
Manage SSO connection
</Button>
</div>
<div className="space-y-2">
@@ -605,6 +640,13 @@ function ActiveConnectionState({
) : (
<DomainList domains={status.domains} />
)}
<Button
variant="tertiary/small"
onClick={() => onOpenPortal("domain_verification")}
LeadingIcon={ArrowTopRightOnSquareIcon}
>
{status.domains.length > 0 ? "Verify another domain" : "Verify domain"}
</Button>
</div>
<div className="space-y-3">
@@ -667,18 +709,7 @@ function ActiveConnectionState({
}
</Select>
</div>
<div className="flex items-center justify-between pt-1">
<LinkButton
to="#"
variant="tertiary/small"
LeadingIcon={ArrowTopRightOnSquareIcon}
onClick={(e) => {
e.preventDefault();
onTogglePortal();
}}
>
Open admin portal
</LinkButton>
<div className="flex justify-end pt-1">
<Button variant="primary/small" disabled={!isDirty || isSaving} onClick={onSave}>
{isSaving ? "Saving…" : "Save"}
</Button>
@@ -688,14 +719,27 @@ function ActiveConnectionState({
);
}
function PortalLinkDialog({ url, onClose }: { url: string | null; onClose: () => void }) {
function PortalLinkDialog({
url,
intent,
onClose,
}: {
url: string | null;
intent: "sso" | "domain_verification" | null;
onClose: () => void;
}) {
const purpose =
intent === "domain_verification"
? "This single-use link opens domain verification. Send it to whoever manages your DNS or identity provider so they can confirm your organization owns its email domains."
: intent === "sso"
? "This single-use link opens identity-provider setup. Send it to whoever manages your identity provider so they can connect it to Trigger.dev."
: "This single-use link opens your organization's SSO setup.";
return (
<Dialog open={url !== null} onOpenChange={(open) => (open ? undefined : onClose())}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Admin portal link</DialogHeader>
<DialogDescription>
This link is active for 5 minutes copy it and share it with your IT contact via whatever
channel you prefer.
{purpose} The link expires 5 minutes after you open this dialog.
</DialogDescription>
<div className="mt-4 break-all rounded-md border border-grid-bright bg-charcoal-800 p-3 font-mono text-xs">
{url ?? ""}