Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function OrganizationSettingsSideMenu({
)}
{isSsoUsingPlugin && (
<SideMenuItem
name="SSO"
name="Identity & Access"
icon={PadlockIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() });

Expand Down Expand Up @@ -286,19 +286,46 @@ 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) {
setPortalUrl(portalFetcher.data.url);
}
}, [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" });
};

Expand All @@ -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}
Expand All @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -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>
);
Expand All @@ -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>
)}
<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>

{domains.length > 0 && (
{hasVerifiedDomain && (
<div className="space-y-2">
<Header2>Domains</Header2>
<DomainList domains={domains} />
<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 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"}
</Button>
</div>
</div>
);
}
Expand Down Expand Up @@ -551,7 +579,7 @@ function ActiveConnectionState({
draftJitRoleId,
isDirty,
isSaving,
onTogglePortal,
onOpenPortal,
onToggleEnforced,
onToggleJit,
onChangeJitRole,
Expand All @@ -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;
Expand All @@ -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">
Expand All @@ -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">
Expand Down Expand Up @@ -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>
Expand All @@ -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 ?? ""}
Expand Down