feat(webapp): SSO & Directory Sync settings UI improvements (#4196)
📚 Publish docs / publish (push) Has been cancelled
📚 Publish docs / publish (push) Has been cancelled
## Summary UI/layout/copy pass over the org **SSO & Directory Sync** settings page (formerly "Identity & Access"). No logic, gates, flags, or data flow changed — server-side auth (`manage:sso`), Enterprise entitlement, action validation, and data loading are all untouched. - Renamed the nav item, page title, and meta from "Identity & Access" to "SSO & Directory Sync". - Added a reusable `SettingsLayout` component system (container, section, header, row, block, actions) modeled on `/account/security`, and refactored the SSO page onto it (section titles, dividers, left title/subtitle + right action rows). - Tightened all UI copy: concise, active voice, consistent labels, no em-dashes. - `Select` primitive: additive `wrap`, `popoverClassName`, and `placement` props (all default to prior behavior) so role options show a bright title with a wrapping description, right-aligned popover, and no horizontal overflow. - Removed the external-link arrow icon from buttons that open a modal; kept it only on genuinely external actions (Contact us, Open in new tab). - Polished the admin portal link dialog: smaller description, tighter spacing, `ClipboardField` with a permanent copy button, removed the redundant Copy link button, and a provider-aware Open label (e.g. "Open in WorkOS") derived from the link host with a safe fallback. ### SSO page UI <img width="3568" height="2550" alt="CleanShot 2026-07-08 at 18 52 11@2x" src="https://github.com/user-attachments/assets/009d2437-7552-4ff0-a457-64744a9fcd88" /> ### Login with SSO and normal email test (local) https://github.com/user-attachments/assets/b33a4ce9-c1fa-45c9-bd3c-077cb6fc9473 ## Test plan - [ ] Non-Enterprise org: SSO page shows the upsell state - [ ] Enterprise org, non-Owner without `manage:sso`: 403 - [ ] Enterprise Owner: verify domains, configure SSO, connect directory, JIT/default/group role selects, and enforcement toggle all work - [ ] Role select popovers: bright title + wrapping description, right-aligned, no horizontal scroll - [ ] Admin portal dialog: copy button works, "Open in WorkOS" opens the portal in a new tab --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Refreshed the SSO & Directory Sync settings page layout and copy
|
||||
@@ -52,7 +52,9 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
<LogoType className="w-36" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex h-full max-w-sm items-center justify-center">{children}</div>
|
||||
<div className="flex h-full w-full max-w-xs items-center justify-center">
|
||||
<div className="w-full">{children}</div>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-center">
|
||||
Having login issues? <TextLink href="https://trigger.dev/contact">Email us</TextLink> or{" "}
|
||||
<TextLink href="https://trigger.dev/discord">ask us in Discord</TextLink>
|
||||
|
||||
@@ -149,7 +149,7 @@ export function OrganizationSettingsSideMenu({
|
||||
)}
|
||||
{isSsoUsingPlugin && (
|
||||
<SideMenuItem
|
||||
name="Identity & Access"
|
||||
name="SSO & Directory Sync"
|
||||
icon={PadlockIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface SelectProps<TValue extends string | string[], TItem> extends Om
|
||||
allowItemShortcuts?: boolean;
|
||||
clearSearchOnSelection?: boolean;
|
||||
dropdownIcon?: boolean | React.ReactNode;
|
||||
popoverClassName?: string;
|
||||
placement?: Ariakit.SelectProviderProps<TValue>["placement"];
|
||||
}
|
||||
|
||||
export function Select<TValue extends string | string[], TItem>({
|
||||
@@ -135,6 +137,8 @@ export function Select<TValue extends string | string[], TItem>({
|
||||
disabled,
|
||||
clearSearchOnSelection = true,
|
||||
dropdownIcon,
|
||||
popoverClassName,
|
||||
placement,
|
||||
...props
|
||||
}: SelectProps<TValue, TItem>) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
@@ -191,6 +195,7 @@ export function Select<TValue extends string | string[], TItem>({
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
virtualFocus={searchable}
|
||||
placement={placement}
|
||||
value={value}
|
||||
setValue={(v) => {
|
||||
if (clearSearchOnSelection) {
|
||||
@@ -215,7 +220,7 @@ export function Select<TValue extends string | string[], TItem>({
|
||||
dropdownIcon={dropdownIcon}
|
||||
{...props}
|
||||
/>
|
||||
<SelectPopover>
|
||||
<SelectPopover className={popoverClassName}>
|
||||
{!searchable && showHeading && heading && <SelectHeading render={<>{heading}</>} />}
|
||||
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}
|
||||
|
||||
@@ -437,7 +442,7 @@ export function SelectList(props: SelectListProps) {
|
||||
<Component
|
||||
{...props}
|
||||
className={cn(
|
||||
"overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom",
|
||||
"overflow-y-auto overflow-x-hidden overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
@@ -449,6 +454,9 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
|
||||
checkIcon?: React.ReactNode;
|
||||
checkPosition?: "left" | "right";
|
||||
shortcut?: ShortcutDefinition;
|
||||
// Allow the item to grow to multiple lines and wrap its content instead of
|
||||
// being locked to a single truncated line. Use for options with a subtitle.
|
||||
wrap?: boolean;
|
||||
}
|
||||
|
||||
const selectItemClasses =
|
||||
@@ -461,6 +469,7 @@ export function SelectItem({
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
|
||||
checkPosition = "right",
|
||||
shortcut,
|
||||
wrap = false,
|
||||
...props
|
||||
}: SelectItemProps) {
|
||||
const combobox = Ariakit.useComboboxContext();
|
||||
@@ -508,13 +517,16 @@ export function SelectItem({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
|
||||
"flex w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
|
||||
wrap ? "min-h-8" : "h-8",
|
||||
checkPosition === "left" ? "gap-2" : "gap-1"
|
||||
)}
|
||||
>
|
||||
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
|
||||
{icon}
|
||||
<div className="grow truncate">{props.children || props.value}</div>
|
||||
<div className={cn("grow", wrap ? "min-w-0 break-words py-1.5" : "truncate")}>
|
||||
{props.children || props.value}
|
||||
</div>
|
||||
{checkPosition === "right" && checkIcon}
|
||||
{shortcut && (
|
||||
<ShortcutKey
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header2, Header3 } from "./Headers";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
// A composable layout system for settings pages: a centered container holds
|
||||
// sections; each section has a header (title/description/action over a divide)
|
||||
// followed by rows. A row lays out a title + description on the left and an
|
||||
// action (button/switch/select/status) on the right, separated by divides and
|
||||
// spacing rather than bordered boxes.
|
||||
//
|
||||
// Everything that renders text accepts `ReactNode`, and every piece takes a
|
||||
// `className` so callers can restyle without forking. For layouts the built-in
|
||||
// props don't cover, pass `children` to a row/block for full control.
|
||||
|
||||
const rowSize = {
|
||||
sm: "py-3",
|
||||
md: "py-4",
|
||||
} as const;
|
||||
|
||||
type RowSize = keyof typeof rowSize;
|
||||
|
||||
/** Page-level wrapper that centers content and sets the settings column width. */
|
||||
export function SettingsContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<MainHorizontallyCenteredContainer
|
||||
className={cn("max-w-[37.5rem] overflow-visible", className)}
|
||||
>
|
||||
{children}
|
||||
</MainHorizontallyCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** A group of related rows. Adds vertical spacing between sibling sections. */
|
||||
export function SettingsSection({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("w-full [&:not(:first-child)]:mt-12", className)}>{children}</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section (or sub-section) heading with an optional description and a
|
||||
* right-aligned action, sitting above a bottom divide. Use `as="h3"` for a
|
||||
* heading nested inside a section.
|
||||
*/
|
||||
export function SettingsHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
as = "h2",
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
as?: "h2" | "h3";
|
||||
className?: string;
|
||||
}) {
|
||||
const Heading = as === "h3" ? Header3 : Header2;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-end justify-between gap-8 border-b border-grid-dimmed",
|
||||
// An h2 section header gets its top spacing from SettingsSection's margin,
|
||||
// so it only needs bottom padding. An h3 sits mid-section among rows, so it
|
||||
// takes the full row rhythm (py-4) to separate from the divide above it.
|
||||
as === "h3" ? "py-4" : "pb-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Heading>{title}</Heading>
|
||||
{description ? <Paragraph variant="small">{description}</Paragraph> : null}
|
||||
</div>
|
||||
{action ? <div className="flex flex-none items-center">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Title typography for a row. Renders a `<label>` when `htmlFor` is set. */
|
||||
export function SettingsRowTitle({
|
||||
children,
|
||||
htmlFor,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
htmlFor?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className);
|
||||
return htmlFor ? (
|
||||
<label htmlFor={htmlFor} className={classes}>
|
||||
{children}
|
||||
</label>
|
||||
) : (
|
||||
<span className={classes}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Description/subtitle typography for a row. */
|
||||
export function SettingsRowDescription({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paragraph variant="small" className={className}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single settings row: title + description on the left, action on the right.
|
||||
*
|
||||
* Pass `title`/`description` for the common case, or `children` to supply
|
||||
* custom left-hand content (the built-in title group is skipped when `children`
|
||||
* is provided). `action` renders on the right in both cases.
|
||||
*/
|
||||
export function SettingsRow({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
titleClassName,
|
||||
size = "md",
|
||||
align = "center",
|
||||
bordered = true,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
htmlFor?: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
size?: RowSize;
|
||||
align?: "center" | "start";
|
||||
bordered?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full justify-between gap-8",
|
||||
align === "center" ? "items-center" : "items-start",
|
||||
rowSize[size],
|
||||
bordered && "border-b border-grid-dimmed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children ?? (
|
||||
<div className="flex-1 space-y-1">
|
||||
{title ? (
|
||||
<SettingsRowTitle htmlFor={htmlFor} className={titleClassName}>
|
||||
{title}
|
||||
</SettingsRowTitle>
|
||||
) : null}
|
||||
{description ? <SettingsRowDescription>{description}</SettingsRowDescription> : null}
|
||||
</div>
|
||||
)}
|
||||
{action ? <div className="flex flex-none items-center">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-width row for arbitrary content (callouts, empty states, custom blocks)
|
||||
* that shouldn't be split into a title/action layout.
|
||||
*/
|
||||
export function SettingsBlock({
|
||||
children,
|
||||
className,
|
||||
size = "md",
|
||||
bordered = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
size?: RowSize;
|
||||
bordered?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full", rowSize[size], bordered && "border-b border-grid-dimmed", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-aligned action bar, typically for a section's Save button. */
|
||||
export function SettingsActions({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("flex justify-end gap-2 py-4", className)}>{children}</div>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,7 +172,7 @@ export default function LoginPage() {
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: emailSchema });
|
||||
},
|
||||
shouldValidate: "onBlur",
|
||||
shouldValidate: "onSubmit",
|
||||
shouldRevalidate: "onInput",
|
||||
});
|
||||
|
||||
|
||||
@@ -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: "/",
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<Reason, { heading: string; body: string }> = {
|
||||
},
|
||||
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() {
|
||||
<LinkButton
|
||||
to="https://trigger.dev/contact"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed group-hover:text-text-bright transition"
|
||||
data-action="ask about sso"
|
||||
>
|
||||
Ask about SSO
|
||||
|
||||
Reference in New Issue
Block a user