diff --git a/.server-changes/accounts-webhook-passthrough.md b/.server-changes/accounts-webhook-passthrough.md
new file mode 100644
index 00000000000..56f239469b6
--- /dev/null
+++ b/.server-changes/accounts-webhook-passthrough.md
@@ -0,0 +1,8 @@
+---
+area: webapp
+type: feature
+---
+
+Add `POST /webhooks/v1/accounts`: a thin passthrough that verifies inbound
+webhooks via the SSO plugin and enqueues them on a dedicated worker. No-op
+(404) when no plugin is installed.
diff --git a/.server-changes/sso-plugin-plumbing.md b/.server-changes/sso-plugin-plumbing.md
new file mode 100644
index 00000000000..1a5c8d631dd
--- /dev/null
+++ b/.server-changes/sso-plugin-plumbing.md
@@ -0,0 +1,8 @@
+---
+area: webapp
+type: feature
+---
+
+Wire the SSO plugin loader (`@trigger.dev/sso`) into the webapp: SSO auth
+method, `hasSso` flag, `SsoStrategy`, and contributor fallback env vars.
+No-op (`no_sso`) without the plugin.
diff --git a/.server-changes/sso-session-expired-logout-ux.md b/.server-changes/sso-session-expired-logout-ux.md
new file mode 100644
index 00000000000..9ef6d4df130
--- /dev/null
+++ b/.server-changes/sso-session-expired-logout-ux.md
@@ -0,0 +1,8 @@
+---
+area: webapp
+type: improvement
+---
+
+When an SSO session is revalidated and the IdP reports it invalid, the user is now sent to the login page with a "Your SSO session expired. Please sign in again." notice instead of seeing a raw `sso_session_invalidated` 401.
+
+Navigations redirect through `/logout` (clearing the cookie) to `/login?reason=session_expired`. Programmatic fetches (Remix fetchers, Electric, etc.) get a 401 carrying an `x-sso-session-invalidated` marker header that a client-side fetch guard turns into the same logout redirect. EventSource streams, which can't read response headers, probe a new lightweight `/resources/session-check` endpoint on stream error to trigger the redirect.
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
index c25a433d7f9..4f1fcd606e3 100644
--- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
+++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
@@ -1,10 +1,9 @@
-import { ArrowLeftIcon } from "@heroicons/react/24/solid";
+import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid";
import { BellIcon } from "~/assets/icons/BellIcon";
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
import { UsageIcon } from "~/assets/icons/UsageIcon";
import { RolesIcon } from "~/assets/icons/RolesIcon";
-import { ShieldLockIcon } from "~/assets/icons/ShieldLockIcon";
import { SlackIcon } from "~/assets/icons/SlackIcon";
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
@@ -17,6 +16,7 @@ import {
organizationRolesPath,
organizationSettingsPath,
organizationSlackIntegrationPath,
+ organizationSsoPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
@@ -48,10 +48,12 @@ export function OrganizationSettingsSideMenu({
organization,
buildInfo,
isUsingPlugin,
+ isSsoUsingPlugin,
}: {
organization: MatchedOrganization;
buildInfo: BuildInfo;
isUsingPlugin: boolean;
+ isSsoUsingPlugin: boolean;
}) {
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
@@ -128,7 +130,7 @@ export function OrganizationSettingsSideMenu({
{featureFlags.hasPrivateConnections && (
)}
+ {isManagedCloud && isSsoUsingPlugin && (
+ Enterprise
+ )
+ }
+ />
+ )}
{
eventSource.removeEventListener(event ?? "message", handler);
+ eventSource.removeEventListener("error", errorHandler);
eventSource.close();
};
}, [url, event, init, disabled]);
diff --git a/apps/webapp/app/models/orgMember.server.ts b/apps/webapp/app/models/orgMember.server.ts
new file mode 100644
index 00000000000..4ec6930d2f8
--- /dev/null
+++ b/apps/webapp/app/models/orgMember.server.ts
@@ -0,0 +1,80 @@
+import { Prisma, prisma } from "~/db.server";
+import { logger } from "~/services/logger.server";
+import { rbac } from "~/services/rbac.server";
+
+export type EnsureOrgMemberParams = {
+ userId: string;
+ organizationId: string;
+ // null = use the seeded MEMBER role from the existing enum. A non-null
+ // value is an RBAC role id; when an RBAC plugin is installed it gets
+ // attached after the OrgMember row is created.
+ roleId: string | null;
+ source: "sso_jit" | "invite" | "manual";
+};
+
+export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string };
+
+// Idempotent OrgMember upsert. If the (userId, organizationId) row
+// already exists this is a no-op (returns `{ created: false }`); we do
+// NOT touch the existing role to avoid demoting a user that JIT happens
+// to fire for again.
+//
+// Seat-limit enforcement lives at the call sites — every existing
+// OrgMember insert in the codebase does its own seat check before
+// calling in. This helper deliberately does none (SSO JIT and
+// invite-accept are exempt by policy).
+export async function ensureOrgMember(
+ params: EnsureOrgMemberParams
+): Promise {
+ const { userId, organizationId, roleId, source } = params;
+
+ const existing = await prisma.orgMember.findFirst({
+ where: { userId, organizationId },
+ select: { id: true },
+ });
+ if (existing) {
+ return { created: false, orgMemberId: existing.id };
+ }
+
+ // Two concurrent JIT/invite flows can both miss the findFirst above and
+ // race to create the same (userId, organizationId) row; the unique
+ // constraint makes one lose with P2002. Treat that as the idempotent
+ // "already a member" case rather than letting it break sign-in.
+ let member: { id: string };
+ try {
+ member = await prisma.orgMember.create({
+ data: {
+ userId,
+ organizationId,
+ role: "MEMBER",
+ },
+ select: { id: true },
+ });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
+ const existingAfterConflict = await prisma.orgMember.findFirst({
+ where: { userId, organizationId },
+ select: { id: true },
+ });
+ if (existingAfterConflict) {
+ return { created: false, orgMemberId: existingAfterConflict.id };
+ }
+ }
+ throw error;
+ }
+
+ if (roleId !== null) {
+ const result = await rbac.setUserRole({ userId, organizationId, roleId });
+ if (!result.ok) {
+ logger.warn("ensureOrgMember.setUserRole failed", {
+ source,
+ userId,
+ organizationId,
+ roleId,
+ error: result.error,
+ });
+ }
+ }
+
+ return { created: true, orgMemberId: member.id };
+}
diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts
index c48221c4b61..983668ce43e 100644
--- a/apps/webapp/app/models/user.server.ts
+++ b/apps/webapp/app/models/user.server.ts
@@ -30,7 +30,18 @@ type FindOrCreateGoogle = {
authenticationExtraParams: Record;
};
-type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
+type FindOrCreateSso = {
+ authenticationMethod: "SSO";
+ email: User["email"];
+ firstName: string | null;
+ lastName: string | null;
+};
+
+type FindOrCreateUser =
+ | FindOrCreateMagicLink
+ | FindOrCreateGithub
+ | FindOrCreateGoogle
+ | FindOrCreateSso;
type LoggedInUser = {
user: User;
@@ -48,6 +59,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise {
+ // Validate the canonical value we actually look up and persist below —
+ // validating raw `email` would let case/whitespace variants slip past
+ // (or misapply) the allow-list policy.
+ const normalised = email.toLowerCase().trim();
+ assertEmailAllowed(normalised);
+
+ const existingUser = await prisma.user.findFirst({ where: { email: normalised } });
+
+ const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || null;
+
+ const user = await prisma.user.upsert({
+ where: { email: normalised },
+ update: {
+ // Existing magic-link / OAuth users keep their original
+ // authenticationMethod; we only refresh name/displayName when the
+ // user has nothing set yet so we don't clobber a customised display
+ // name on every SSO login.
+ ...(existingUser?.name ? {} : { name: fullName }),
+ ...(existingUser?.displayName ? {} : { displayName: fullName }),
+ },
+ create: {
+ email: normalised,
+ name: fullName,
+ displayName: fullName,
+ authenticationMethod: "SSO",
+ },
+ });
+
+ return { user, isNewUser: !existingUser };
+}
+
export type UserWithDashboardPreferences = User & {
dashboardPreferences: DashboardPreferences;
};
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx
new file mode 100644
index 00000000000..fa27c86c6fb
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx
@@ -0,0 +1,798 @@
+import {
+ ArrowTopRightOnSquareIcon,
+ CheckCircleIcon,
+ ClockIcon,
+ ExclamationCircleIcon,
+ LockClosedIcon,
+} from "@heroicons/react/20/solid";
+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 { z } from "zod";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import {
+ MainHorizontallyCenteredContainer,
+ PageBody,
+ PageContainer,
+} from "~/components/layout/AppLayout";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { Callout } from "~/components/primitives/Callout";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+} from "~/components/primitives/Dialog";
+import { Header2 } from "~/components/primitives/Headers";
+import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { Select, SelectItem } from "~/components/primitives/Select";
+import { Switch } from "~/components/primitives/Switch";
+import { $replica } from "~/db.server";
+import { featuresForRequest } from "~/features.server";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { rbac } from "~/services/rbac.server";
+import { ssoController } from "~/services/sso.server";
+import { getCurrentPlan } from "~/services/platform.v3.server";
+import type { Role } from "@trigger.dev/plugins";
+import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
+import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
+import { v3BillingPath } from "~/utils/pathBuilder";
+
+export const meta: MetaFunction = () => [{ title: "SSO settings | Trigger.dev" }];
+
+const Params = z.object({ organizationSlug: z.string() });
+
+async function resolveOrg(slug: string) {
+ return $replica.organization.findFirst({
+ where: { slug },
+ select: { id: true, title: true },
+ });
+}
+
+function planAllowsSso(plan: unknown): boolean {
+ if (!plan || typeof plan !== "object") return false;
+ const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } })
+ .v3Subscription;
+ return subscription?.plan?.code === "enterprise";
+}
+
+// The render-level upsell (planAllowsSso on the client) is cosmetic —
+// any org member could still POST the actions directly. Mutations that
+// provision real IdP-side resources are gated here, server-side.
+async function requireSsoEntitlement(orgId: string): Promise {
+ const plan = await getCurrentPlan(orgId);
+ if (!planAllowsSso(plan)) {
+ throw new Response("SSO requires an Enterprise plan", { status: 403 });
+ }
+}
+
+export const loader = dashboardLoader(
+ {
+ params: Params,
+ context: async (params) => {
+ const org = await resolveOrg(params.organizationSlug);
+ return org ? { organizationId: org.id, orgTitle: org.title } : {};
+ },
+ authorization: { action: "manage", resource: { type: "sso" } },
+ },
+ async ({ context, request }) => {
+ const { isManagedCloud } = featuresForRequest(request);
+ // Gate on managed cloud AND the SSO plugin actually being loaded
+ // (SSO_ENABLED off → OSS fallback → isUsingPlugin false). Without
+ // this the page renders for every managed-cloud org even when SSO
+ // is disabled for the deployment.
+ if (!isManagedCloud || !(await ssoController.isUsingPlugin())) {
+ throw new Response("Not Found", { status: 404 });
+ }
+
+ const orgId = context.organizationId;
+ if (!orgId) {
+ throw new Response("Not Found", { status: 404 });
+ }
+
+ // The page is reachable on every paid + free plan; when the org
+ // isn't on Enterprise we render the upsell state instead of the
+ // SSO UI. Plan-tier enforcement lives in the React render so the
+ // sidebar entry and the page itself stay aligned.
+ const [statusResult, allRoles, assignableIds] = await Promise.all([
+ ssoController.getStatus(orgId),
+ rbac.allRoles(orgId),
+ rbac.getAssignableRoleIds(orgId),
+ ]);
+ const status = statusResult.isOk()
+ ? statusResult.value
+ : {
+ hasIdpOrg: false,
+ enforced: false,
+ jitProvisioningEnabled: false,
+ jitDefaultRoleId: null,
+ idpOrgId: null,
+ primaryConnectionId: null,
+ domains: [] as Array<{
+ domain: string;
+ verified: boolean;
+ state: "pending" | "verified" | "failed";
+ verificationFailedReason: string | null;
+ }>,
+ connections: [] as Array<{
+ id: string;
+ name: string | null;
+ connectionType: string;
+ state: "active" | "inactive";
+ }>,
+ };
+
+ // JIT can't promote new users to Owner — that role is reserved for
+ // the founding member and explicit transfers. Plan-gated roles are
+ // filtered out via the assignable set so the UI doesn't offer
+ // something the org can't actually use.
+ const assignable = new Set(assignableIds);
+ const jitRoles = allRoles.filter(
+ (r) => r.name !== "Owner" && assignable.has(r.id)
+ );
+
+ return typedjson({ status, orgTitle: context.orgTitle, jitRoles });
+ }
+);
+
+const NULL_ROLE_VALUE = "__none__";
+const DEFAULT_JIT_ROLE_NAME = "Developer";
+
+// Don't use `z.coerce.boolean()` — it goes through JS `Boolean()`,
+// which treats the string "false" as truthy (any non-empty string).
+const boolish = z
+ .union([z.literal("true"), z.literal("false")])
+ .transform((v) => v === "true");
+
+const ActionSchema = z.discriminatedUnion("action", [
+ z.object({
+ action: z.literal("save_config"),
+ enforced: boolish,
+ jitEnabled: boolish,
+ jitRoleId: z.string(),
+ }),
+ z.object({
+ action: z.literal("portal_link"),
+ intent: z.enum(["sso", "domain_verification"]),
+ }),
+]);
+
+export const action = dashboardAction(
+ {
+ params: Params,
+ context: async (params) => {
+ const org = await resolveOrg(params.organizationSlug);
+ return org ? { organizationId: org.id } : {};
+ },
+ authorization: { action: "manage", resource: { type: "sso" } },
+ },
+ async ({ request, context, user, params }) => {
+ const orgId = context.organizationId;
+ if (!orgId) {
+ throw new Response("Not Found", { status: 404 });
+ }
+
+ const { isManagedCloud } = featuresForRequest(request);
+ if (!isManagedCloud) {
+ throw new Response("Not Found", { status: 404 });
+ }
+ await requireSsoEntitlement(orgId);
+
+ const formData = await request.formData();
+ const parsed = ActionSchema.safeParse({
+ action: formData.get("action"),
+ enforced: formData.get("enforced") ?? undefined,
+ jitEnabled: formData.get("jitEnabled") ?? undefined,
+ jitRoleId: formData.get("jitRoleId") ?? undefined,
+ intent: formData.get("intent") ?? undefined,
+ });
+ if (!parsed.success) {
+ return new Response("Bad Request", { status: 400 });
+ }
+
+ switch (parsed.data.action) {
+ case "save_config": {
+ const jitRoleId =
+ parsed.data.jitRoleId === NULL_ROLE_VALUE ? null : parsed.data.jitRoleId;
+ // Issue all three writes in parallel — they touch the same
+ // OrgSsoConfig row but only update disjoint columns, so there
+ // is no contention. A failure on any leaves the others applied;
+ // surface the first error string back to the form.
+ const [enforced, jit, jitRole] = await Promise.all([
+ ssoController.setEnforced({
+ organizationId: orgId,
+ enforced: parsed.data.enforced,
+ }),
+ ssoController.setJitProvisioningEnabled({
+ organizationId: orgId,
+ enabled: parsed.data.jitEnabled,
+ }),
+ ssoController.setJitDefaultRole({ organizationId: orgId, roleId: jitRoleId }),
+ ]);
+ const failed = [enforced, jit, jitRole].find((r) => r.isErr());
+ if (failed && failed.isErr()) {
+ return new Response(`Error: ${failed.error}`, { status: 400 });
+ }
+ return redirect(`/orgs/${params.organizationSlug}/settings/sso`);
+ }
+ case "portal_link": {
+ const url = new URL(request.url);
+ const returnUrl = `${url.protocol}//${url.host}/orgs/${params.organizationSlug}/settings/sso`;
+ const result = await ssoController.generatePortalLink({
+ organizationId: orgId,
+ userId: user.id,
+ intent: parsed.data.intent,
+ returnUrl,
+ });
+ if (result.isErr()) {
+ return Response.json({ ok: false, error: result.error }, { status: 400 });
+ }
+ return Response.json({ ok: true, url: result.value.url });
+ }
+ }
+ }
+);
+
+function defaultJitRoleId(
+ jitRoles: ReadonlyArray,
+ current: string | null
+): string {
+ // Persisted value wins, even when it points at something the picker
+ // can no longer offer — keeps the user's prior choice visible.
+ if (current) return current;
+ const dev = jitRoles.find((r) => r.name === DEFAULT_JIT_ROLE_NAME);
+ return dev?.id ?? NULL_ROLE_VALUE;
+}
+
+export default function Page() {
+ const { status, orgTitle, jitRoles } = useTypedLoaderData();
+ const organization = useOrganization();
+ const _plan = useCurrentPlan();
+
+ const isEntitled = planAllowsSso(_plan);
+ const activeConnections = status.connections.filter((c) => c.state === "active");
+ const hasActive = activeConnections.length > 0;
+
+ // Deferred-save: each field starts mirrored from `status`, edits stay
+ // local until Save commits all three to the action. The `key` trick
+ // below resets local state after a successful save (when `status`
+ // changes via revalidation following the redirect).
+ const initialJitRoleId = defaultJitRoleId(jitRoles, status.jitDefaultRoleId);
+ const [draftEnforced, setDraftEnforced] = useState(status.enforced);
+ const [draftJitEnabled, setDraftJitEnabled] = useState(status.jitProvisioningEnabled);
+ const [draftJitRoleId, setDraftJitRoleId] = useState(initialJitRoleId);
+
+ // Re-sync drafts when the loader returns fresh `status` (post-save
+ // redirect → revalidation). useEffect rather than a memo so we don't
+ // stomp in-flight edits during the same render.
+ useEffect(() => {
+ setDraftEnforced(status.enforced);
+ setDraftJitEnabled(status.jitProvisioningEnabled);
+ setDraftJitRoleId(defaultJitRoleId(jitRoles, status.jitDefaultRoleId));
+ // jitRoles only changes if the org changes; the role list itself is
+ // stable across saves on a given org.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [status.enforced, status.jitProvisioningEnabled, status.jitDefaultRoleId]);
+
+ const isDirty =
+ draftEnforced !== status.enforced ||
+ draftJitEnabled !== status.jitProvisioningEnabled ||
+ draftJitRoleId !== initialJitRoleId;
+
+ const [portalUrl, setPortalUrl] = useState(null);
+ const [enforceModalOpen, setEnforceModalOpen] = useState(false);
+ const portalFetcher = useFetcher<{ ok: boolean; url?: string; error?: string }>();
+ const saveFetcher = useFetcher();
+ const isSaving = saveFetcher.state !== "idle";
+
+ useEffect(() => {
+ if (portalFetcher.data?.ok && portalFetcher.data.url) {
+ setPortalUrl(portalFetcher.data.url);
+ }
+ }, [portalFetcher.data]);
+
+ const openPortal = (intent: "sso" | "domain_verification") => {
+ setPortalUrl(null);
+ portalFetcher.submit(
+ { action: "portal_link", intent },
+ { method: "POST" }
+ );
+ };
+
+ const submitSave = () => {
+ saveFetcher.submit(
+ {
+ action: "save_config",
+ enforced: draftEnforced ? "true" : "false",
+ jitEnabled: draftJitEnabled ? "true" : "false",
+ jitRoleId: draftJitRoleId,
+ },
+ { method: "POST" }
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ {!isEntitled ? (
+
+ ) : !status.hasIdpOrg ? (
+ openPortal("sso")} />
+ ) : !hasActive ? (
+ openPortal("sso")}
+ onOpenDomain={() => openPortal("domain_verification")}
+ />
+ ) : (
+ openPortal("sso")}
+ onToggleEnforced={(next) => {
+ // Going on→off is harmless; going off→on locks users out so
+ // we still require explicit confirmation. The modal updates
+ // the draft only; nothing is persisted until Save.
+ if (next && !status.enforced) {
+ setEnforceModalOpen(true);
+ } else {
+ setDraftEnforced(next);
+ }
+ }}
+ onToggleJit={(next) => setDraftJitEnabled(next)}
+ onChangeJitRole={(roleId) => setDraftJitRoleId(roleId ?? NULL_ROLE_VALUE)}
+ onSave={submitSave}
+ />
+ )}
+
+
+
+ setPortalUrl(null)} />
+
+ setEnforceModalOpen(false)}
+ onConfirm={() => {
+ setDraftEnforced(true);
+ setEnforceModalOpen(false);
+ }}
+ />
+
+ );
+}
+
+function EnterpriseUpsellState({ organizationSlug }: { organizationSlug: string }) {
+ return (
+
+
+
+ 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.
+ 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). The first
+ click opens the admin portal in a 5-minute single-use link.
+
+
+
+ {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 === 0 && verifiedDomains.length > 0 && (
+
+ {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.`}
+
+ )}
+ {failedDomains.length === 0 && verifiedDomains.length === 0 && (
+
+ Not yet configured. Continue in the admin portal to verify a domain and set up your
+ identity provider connection.
+
+ )}
+
+ {domains.length > 0 && (
+
+
+ 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.
+
+