-
-
- SSO is available on the Enterprise plan
-
-
- Single sign-on (SAML / OIDC) lets your IT admins manage who can access Trigger.dev through
- your identity provider — Okta, Azure AD, Google Workspace, OneLogin, and more. Upgrade your
- organization to Enterprise to configure it.
-
-
- - Self-service domain verification and connection setup via the admin portal.
- - Just-in-time user provisioning for your verified domains.
- - Per-domain enforcement so contractors keep using existing sign-in methods.
-
-
-
- Talk to sales
-
-
- Contact us
-
+
+
+ SSO & Directory Sync
+ Enterprise
+
+ }
+ description="Single sign-on (SAML/OIDC) and Directory Sync (SCIM) let your IT team manage access to Trigger.dev from your identity provider, such as Okta, Azure AD, or Google Workspace."
+ />
+
+
+ - Verify domains and connect your identity provider from the admin portal.
+ - Just-in-time provisioning for your verified domains.
+ - Enforce SSO by domain, so contractors keep their existing sign-in.
+ - Sync users and map directory groups to roles with SCIM.
+
+
+ Contact us}
+ />
+
-
+
);
}
function NoIdpOrgState({ onOpenPortal }: { onOpenPortal: () => void }) {
return (
-
-
Configure SSO for your organization
-
- 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).
-
-
-
+
+
+
+ Start setup
+
+ }
+ />
+
);
}
@@ -621,49 +594,48 @@ function NoActiveConnectionState({
const hasVerifiedDomain = verifiedDomains.length > 0;
return (
-
-
-
Domains
-
- Verify the email domains your team signs in with. Once a domain is verified you can
- connect your identity provider.
-
+ <>
+
+
+ {domains.length > 0 ? "Add domain" : "Verify domain"}
+
+ }
+ />
{failedDomains.length > 0 && (
-
- {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.`}
-
+
+
+ {failedDomains.length === 1
+ ? `Verification failed for ${failedDomains[0].domain}. Check the DNS records in the admin portal and try again.`
+ : `${failedDomains.length} domains failed verification. Check the DNS records in the admin portal and try again.`}
+
+
)}
{domains.length > 0 && }
-
-
+
{hasVerifiedDomain && (
-
-
SSO
-
- Connect your identity provider to finish setting up single sign-on for your verified
- domains.
-
-
-
+
+
+
+ Configure SSO
+
+ }
+ />
+
)}
- {/* Directory Sync is independent of SSO — once a domain is verified an org
- can connect a directory without ever configuring SSO. */}
+ {/* Directory Sync is independent of SSO (needs only a verified domain). */}
{hasVerifiedDomain && hasSso ? (
) : null}
-
+ >
);
}
function DomainList({ domains }: { domains: ReadonlyArray
}) {
return (
-
+ >
);
}
@@ -708,21 +684,18 @@ function domainVisual(state: DomainRow["state"]) {
switch (state) {
case "verified":
return {
- row: "border-emerald-500/30 bg-emerald-500/5",
- label: "text-emerald-400",
+ label: "text-success",
icon: ,
};
case "failed":
return {
- row: "border-rose-500/30 bg-rose-500/5",
- label: "text-rose-400",
+ label: "text-error",
icon: ,
};
case "pending":
default:
return {
- row: "border-amber-500/20 bg-amber-500/5",
- label: "text-amber-400",
+ label: "text-warning",
icon: ,
};
}
@@ -769,110 +742,92 @@ function ActiveConnectionState({
onSave: () => void;
}) {
return (
-
-
-
Verified domains
+ <>
+
+ onOpenPortal("domain_verification")}>
+ {status.domains.length > 0 ? "Add domain" : "Verify domain"}
+
+ }
+ />
{status.domains.length === 0 ? (
-
- No domains verified yet.
-
+
+ No domains verified yet.
+
) : (
)}
-
-
-
-
-
{orgTitle} – SSO connection
+
+
+
+ onOpenPortal("sso")}>
+ Manage connection
+
+ }
+ />
{activeConnections.map((conn) => (
-
-
- {conn.name ?? conn.connectionType}
-
-
- Type: {conn.connectionType}
-
-
+ title={conn.name ?? conn.connectionType}
+ description={`Type: ${conn.connectionType}`}
+ action={}
+ />
))}
-
-
-
-
-
-
- Require SSO for matching domains
-
-
- When on, users whose email matches a verified domain must use SSO to sign in.
-
-
-
-
-
-
-
- JIT provisioning
-
-
- Auto-create memberships for first-time SSO sign-ins from your verified domains.
-
-
-
-
-
-
-
- Default role for JIT provisioned users
-
-
- Role assigned to new users created via JIT provisioning. Owner is reserved and cannot
- be granted automatically.
-
-
-
-
-
+
+ }
+ />
+
+ }
+ />
+
+ value={draftJitRoleId}
+ setValue={(v) => onChangeJitRole(v)}
+ items={[...jitRoles]}
+ variant="secondary/small"
+ dropdownIcon
+ popoverClassName="max-w-xs"
+ placement="bottom-end"
+ text={(v) => jitRoles.find((r) => r.id === v)?.name ?? "Select a role"}
+ >
+ {(items) =>
+ items.map((role) => (
+
+ ))
+ }
+
+ }
+ />
+
-
-
+
+
{hasSso ? (
onOpenPortal("dsync")}
/>
) : null}
-
+ >
+ );
+}
+
+function StatusIndicator({ label, active = true }: { label: string; active?: boolean }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+// Option content for the role dropdowns: a bright title with a wrapping,
+// dimmed description beneath it. `wrap` lets long descriptions flow onto
+// multiple lines instead of running off the popover edge.
+function RoleSelectItem({
+ id,
+ name,
+ description,
+}: {
+ id: string;
+ name: string;
+ description?: string;
+}) {
+ return (
+
+
+ {name}
+ {description ? {description} : null}
+
+
);
}
@@ -897,11 +888,8 @@ function DirectorySyncSection({
const fetcher = useFetcher();
const isSaving = fetcher.state !== "idle";
- // Deferred save: edits stay local until Save commits them all together
- // (mirrors the SSO Configuration form). `useOverrideDraft` keeps the fields
- // safe under whole-page polling — untouched fields adopt fresh server
- // values, edited ones are preserved. Role values keep the NULL_ROLE_VALUE
- // sentinel in the draft; the action converts it to null on write.
+ // Deferred save; `useOverrideDraft` keeps fields poll-safe. Role drafts hold
+ // the NULL_ROLE_VALUE sentinel; the action converts it to null.
const externalDraft = useOverrideDraft(directorySync.allowExternalDomainSync);
const manualDraft = useOverrideDraft(directorySync.allowManualMembership);
const defaultRoleDraft = useOverrideDraft(
@@ -914,10 +902,8 @@ function DirectorySyncSection({
const draftDefaultRole = defaultRoleDraft.value;
const setDraftDefaultRole = defaultRoleDraft.set;
- // Group mappings vary in count, so instead of one draft per group we keep a
- // sparse map of only the groups the user has edited (overrides). Rendering
- // falls back to the server value, so new groups arriving via polling show up
- // immediately, and an override is dropped once the server catches up to it.
+ // Sparse override map of only edited groups; rendering falls back to the
+ // server value so polled-in groups appear and matched overrides drop.
const [draftGroupRoles, setDraftGroupRoles] = useState>({});
useEffect(() => {
setDraftGroupRoles((current) => {
@@ -925,8 +911,7 @@ function DirectorySyncSection({
for (const g of directorySync.groups) {
const override = current[g.groupId];
if (override === undefined) continue;
- // Keep only overrides that still diverge from the server (drops
- // saved/externally-matched edits and edits for removed groups).
+ // Keep only overrides still diverging from the server.
if (override !== (g.mappedRoleId ?? NULL_ROLE_VALUE)) next[g.groupId] = override;
}
const currentKeys = Object.keys(current);
@@ -945,7 +930,7 @@ function DirectorySyncSection({
externalDraft.dirty || manualDraft.dirty || defaultRoleDraft.dirty || groupRolesDirty;
const submitSave = () => {
- // Send only the group mappings that actually changed.
+ // Send only changed mappings.
const changedGroups = directorySync.groups
.filter((g) => {
const override = draftGroupRoles[g.groupId];
@@ -965,143 +950,121 @@ function DirectorySyncSection({
};
return (
-
-
Directory Sync
-
- Sync users and groups from your identity provider (SCIM). Members in mapped groups are
- provisioned automatically, their role follows the group mapping, and removing a user from
- your directory removes their access here.
-
+
+
+ {directorySync.directories.length === 0 ? "Connect a directory" : "Manage directory"}
+
+ }
+ />
{directorySync.directories.length === 0 ? (
-
+
+
+ No directory connected. Once connected, members of mapped groups are provisioned
+ automatically.
+
+
) : (
<>
{directorySync.directories.map((dir) => (
-
-
-
- {dir.name ?? dir.type}
-
-
- {dir.type} · {dir.state === "active" ? "Active" : "Inactive"} ·{" "}
- {directorySync.userCount} {directorySync.userCount === 1 ? "user" : "users"}
-
-
-
- ))}
-
-
-
-
-
- Sync users outside verified domains
-
-
- By default only directory users whose email domain is verified for this org are
- provisioned. Turn on to also provision users on other domains (e.g. contractors).
-
-
-
-
-
-
-
-
- Allow manual membership management
-
-
- On by default. Turn off to let Directory Sync manage membership exclusively — while
- a directory is active, inviting, removing, and leaving are disabled in the
- dashboard.
-
-
-
+ }
/>
-
+ ))}
-
-
-
- Default role for users without a mapped group
-
-
- Directory users who belong to no mapped group are provisioned at this role
- (Developer by default). Choose "No access" to leave them unprovisioned until they
- join a mapped group.
-
-
-
-
-
-
-
- Group → role mapping
-
- {directorySync.groups.length === 0 ? (
-
- No directory groups synced yet. Groups appear here once your directory syncs them.
+
+ }
+ />
+
+
+ }
+ />
+
+
+ value={draftDefaultRole}
+ setValue={(v) => setDraftDefaultRole(v)}
+ items={[{ id: NULL_ROLE_VALUE, name: "No access", description: "" }, ...jitRoles]}
+ variant="secondary/small"
+ dropdownIcon
+ popoverClassName="max-w-xs"
+ placement="bottom-end"
+ text={(v) =>
+ v === NULL_ROLE_VALUE
+ ? "No access"
+ : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role")
+ }
+ >
+ {(items) =>
+ items.map((role) => (
+
+ ))
+ }
+
+ }
+ />
+
+
+ {directorySync.groups.length === 0 ? (
+
+
+ No groups synced yet. Groups appear here after your directory syncs.
- ) : (
- directorySync.groups.map((group) => {
- const value =
- draftGroupRoles[group.groupId] ?? group.mappedRoleId ?? NULL_ROLE_VALUE;
- return (
-
-
- {group.name}
-
+
+ ) : (
+ directorySync.groups.map((group) => {
+ const value = draftGroupRoles[group.groupId] ?? group.mappedRoleId ?? NULL_ROLE_VALUE;
+ return (
+
value={value}
setValue={(v) =>
@@ -1111,8 +1074,10 @@ function DirectorySyncSection({
{ id: NULL_ROLE_VALUE, name: "No access", description: "" },
...jitRoles,
]}
- variant="tertiary/small"
+ variant="secondary/small"
dropdownIcon
+ popoverClassName="max-w-xs"
+ placement="bottom-end"
text={(v) =>
v === NULL_ROLE_VALUE
? "No access"
@@ -1121,34 +1086,49 @@ function DirectorySyncSection({
>
{(items) =>
items.map((role) => (
-
-
- {role.name}
- {role.description ? (
- {role.description}
- ) : null}
-
-
+
))
}
-
- );
- })
- )}
-
+ }
+ />
+ );
+ })
+ )}
-
+
-
+
>
)}
-
+
);
}
+// The portal is hosted by our SSO vendor, so the friendly name is derived from
+// the link's host (e.g. setup.workos.com -> WorkOS). Falls back to the
+// capitalized root label, then to a generic label if the URL can't be parsed.
+const KNOWN_PORTAL_PROVIDERS: Record = { workos: "WorkOS" };
+
+function portalProviderName(url: string | null): string | null {
+ if (!url) return null;
+ try {
+ const labels = new URL(url).hostname.split(".").filter(Boolean);
+ const root = labels.length >= 2 ? labels[labels.length - 2] : labels[0];
+ if (!root) return null;
+ return KNOWN_PORTAL_PROVIDERS[root.toLowerCase()] ?? root[0].toUpperCase() + root.slice(1);
+ } catch {
+ return null;
+ }
+}
+
function PortalLinkDialog({
url,
intent,
@@ -1160,51 +1140,37 @@ function PortalLinkDialog({
}) {
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."
+ ? "Single-use link to verify your email domains. Share it with whoever manages your DNS."
: 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."
+ ? "Single-use link to connect your identity provider. Share it with whoever manages it."
: intent === "dsync"
- ? "This single-use link opens directory sync (SCIM) setup. Send it to whoever manages your identity provider so they can connect your directory to Trigger.dev."
- : "This single-use link opens your organization's SSO setup.";
+ ? "Single-use link to set up Directory Sync over SCIM. Share it with whoever manages your identity provider."
+ : "Single-use link to set up SSO.";
+ const providerName = portalProviderName(url);
return (
@@ -1227,16 +1193,14 @@ function EnforceConfirmDialog({
Enable SSO enforcement for {orgTitle}?
- Once enabled, users whose email domain matches your verified domains will be redirected to
- your identity provider to sign in. They will no longer be able to use magic link, GitHub,
- or Google via that domain.
+ Users with an email on a verified domain will be redirected to your identity provider to
+ sign in. They can no longer use magic link, GitHub, or Google for that domain.
- Users with non-matching emails (e.g. contractors with personal emails) will continue to
- use existing methods.
+ Users with other emails, such as contractors, keep their existing sign-in methods.
-
+
Cancel
diff --git a/apps/webapp/app/routes/login._index/route.tsx b/apps/webapp/app/routes/login._index/route.tsx
index 462e5345f2..c833de6ece 100644
--- a/apps/webapp/app/routes/login._index/route.tsx
+++ b/apps/webapp/app/routes/login._index/route.tsx
@@ -172,7 +172,7 @@ export default function LoginPage() {
onValidate({ formData }) {
return parseWithZod(formData, { schema: emailSchema });
},
- shouldValidate: "onBlur",
+ shouldValidate: "onSubmit",
shouldRevalidate: "onInput",
});
diff --git a/apps/webapp/app/routes/login.magic/magicLinkEmailCookie.server.ts b/apps/webapp/app/routes/login.magic/magicLinkEmailCookie.server.ts
new file mode 100644
index 0000000000..8f1738d2aa
--- /dev/null
+++ b/apps/webapp/app/routes/login.magic/magicLinkEmailCookie.server.ts
@@ -0,0 +1,15 @@
+import { createCookie } from "@remix-run/node";
+import { env } from "~/env.server";
+
+// Carries the submitted email to the confirmation screen in a short-lived,
+// httpOnly cookie rather than the URL, so the address never lands in access
+// logs, browser history, or error-tracker breadcrumbs. Lives in a .server
+// module: it calls createCookie at import time using server-only env, which
+// throws if it ever evaluates in the client bundle.
+export const magicLinkEmailCookie = createCookie("magiclink-email", {
+ maxAge: 60 * 10,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: env.NODE_ENV === "production",
+ path: "/",
+});
diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx
index d18b36ba18..0fc4526172 100644
--- a/apps/webapp/app/routes/login.magic/route.tsx
+++ b/apps/webapp/app/routes/login.magic/route.tsx
@@ -1,7 +1,6 @@
import { ArrowLeftIcon } from "@heroicons/react/20/solid";
import { InboxArrowDownIcon } from "@heroicons/react/24/solid";
import {
- createCookie,
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
@@ -35,17 +34,7 @@ import { ssoRedirectForEmail } from "~/services/ssoAutoDiscovery.server";
import { logger, tryCatch } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { extractClientIp } from "~/utils/extractClientIp.server";
-
-// The submitted email is carried to the confirmation screen in a short-lived,
-// httpOnly cookie rather than the URL, so the address never lands in access
-// logs, browser history, or error-tracker breadcrumbs.
-const magicLinkEmailCookie = createCookie("magiclink-email", {
- maxAge: 60 * 10,
- httpOnly: true,
- sameSite: "lax",
- secure: env.NODE_ENV === "production",
- path: "/",
-});
+import { magicLinkEmailCookie } from "./magicLinkEmailCookie.server";
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
diff --git a/apps/webapp/app/routes/login.sso/route.tsx b/apps/webapp/app/routes/login.sso/route.tsx
index 7c9415a0b3..1ed8549735 100644
--- a/apps/webapp/app/routes/login.sso/route.tsx
+++ b/apps/webapp/app/routes/login.sso/route.tsx
@@ -1,6 +1,6 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
-import { ArrowLeftIcon } from "@heroicons/react/20/solid";
+import { ArrowLeftIcon, ArrowUpRightIcon } from "@heroicons/react/20/solid";
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Form, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -37,7 +37,7 @@ const CONTENT: Record = {
},
domain_policy: {
heading: "SSO required",
- body: "Trigger.dev couldn't send a magic link because your organization requires single sign-on. Continue to your identity provider.",
+ body: "Your organization requires single sign-on. Continue with your identity provider.",
},
oauth_blocked: {
heading: "SSO required",
@@ -117,7 +117,7 @@ export default function LoginSsoPage() {
onValidate({ formData }) {
return parseWithZod(formData, { schema: ssoEmailSchema });
},
- shouldValidate: "onBlur",
+ shouldValidate: "onSubmit",
shouldRevalidate: "onInput",
});
@@ -179,6 +179,8 @@ export default function LoginSsoPage() {
Ask about SSO