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:
@@ -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 ?? ""}
|
||||
|
||||
Reference in New Issue
Block a user