Skip to content

Commit bb450e6

Browse files
samejrcursoragent
andauthored
feat(webapp): SSO & Directory Sync settings UI improvements (#4196)
## 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>
1 parent 71e4b00 commit bb450e6

10 files changed

Lines changed: 696 additions & 490 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Refreshed the SSO & Directory Sync settings page layout and copy

apps/webapp/app/components/LoginPageLayout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
5252
<LogoType className="w-36" />
5353
</a>
5454
</div>
55-
<div className="flex h-full max-w-sm items-center justify-center">{children}</div>
55+
<div className="flex h-full w-full max-w-xs items-center justify-center">
56+
<div className="w-full">{children}</div>
57+
</div>
5658
<Paragraph variant="small" className="text-center">
5759
Having login issues? <TextLink href="https://trigger.dev/contact">Email us</TextLink> or{" "}
5860
<TextLink href="https://trigger.dev/discord">ask us in Discord</TextLink>

apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export function OrganizationSettingsSideMenu({
149149
)}
150150
{isSsoUsingPlugin && (
151151
<SideMenuItem
152-
name="Identity & Access"
152+
name="SSO & Directory Sync"
153153
icon={PadlockIcon}
154154
activeIconColor="text-text-bright"
155155
inactiveIconColor="text-text-dimmed"

apps/webapp/app/components/primitives/Select.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ export interface SelectProps<TValue extends string | string[], TItem> extends Om
110110
allowItemShortcuts?: boolean;
111111
clearSearchOnSelection?: boolean;
112112
dropdownIcon?: boolean | React.ReactNode;
113+
popoverClassName?: string;
114+
placement?: Ariakit.SelectProviderProps<TValue>["placement"];
113115
}
114116

115117
export function Select<TValue extends string | string[], TItem>({
@@ -135,6 +137,8 @@ export function Select<TValue extends string | string[], TItem>({
135137
disabled,
136138
clearSearchOnSelection = true,
137139
dropdownIcon,
140+
popoverClassName,
141+
placement,
138142
...props
139143
}: SelectProps<TValue, TItem>) {
140144
const [searchValue, setSearchValue] = useState("");
@@ -191,6 +195,7 @@ export function Select<TValue extends string | string[], TItem>({
191195
open={open}
192196
setOpen={setOpen}
193197
virtualFocus={searchable}
198+
placement={placement}
194199
value={value}
195200
setValue={(v) => {
196201
if (clearSearchOnSelection) {
@@ -215,7 +220,7 @@ export function Select<TValue extends string | string[], TItem>({
215220
dropdownIcon={dropdownIcon}
216221
{...props}
217222
/>
218-
<SelectPopover>
223+
<SelectPopover className={popoverClassName}>
219224
{!searchable && showHeading && heading && <SelectHeading render={<>{heading}</>} />}
220225
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}
221226

@@ -437,7 +442,7 @@ export function SelectList(props: SelectListProps) {
437442
<Component
438443
{...props}
439444
className={cn(
440-
"overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom",
445+
"overflow-y-auto overflow-x-hidden overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom",
441446
props.className
442447
)}
443448
/>
@@ -449,6 +454,9 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
449454
checkIcon?: React.ReactNode;
450455
checkPosition?: "left" | "right";
451456
shortcut?: ShortcutDefinition;
457+
// Allow the item to grow to multiple lines and wrap its content instead of
458+
// being locked to a single truncated line. Use for options with a subtitle.
459+
wrap?: boolean;
452460
}
453461

454462
const selectItemClasses =
@@ -461,6 +469,7 @@ export function SelectItem({
461469
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
462470
checkPosition = "right",
463471
shortcut,
472+
wrap = false,
464473
...props
465474
}: SelectItemProps) {
466475
const combobox = Ariakit.useComboboxContext();
@@ -508,13 +517,16 @@ export function SelectItem({
508517
>
509518
<div
510519
className={cn(
511-
"flex h-8 w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
520+
"flex w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
521+
wrap ? "min-h-8" : "h-8",
512522
checkPosition === "left" ? "gap-2" : "gap-1"
513523
)}
514524
>
515525
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
516526
{icon}
517-
<div className="grow truncate">{props.children || props.value}</div>
527+
<div className={cn("grow", wrap ? "min-w-0 break-words py-1.5" : "truncate")}>
528+
{props.children || props.value}
529+
</div>
518530
{checkPosition === "right" && checkIcon}
519531
{shortcut && (
520532
<ShortcutKey
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { type ReactNode } from "react";
2+
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
3+
import { cn } from "~/utils/cn";
4+
import { Header2, Header3 } from "./Headers";
5+
import { Paragraph } from "./Paragraph";
6+
7+
// A composable layout system for settings pages: a centered container holds
8+
// sections; each section has a header (title/description/action over a divide)
9+
// followed by rows. A row lays out a title + description on the left and an
10+
// action (button/switch/select/status) on the right, separated by divides and
11+
// spacing rather than bordered boxes.
12+
//
13+
// Everything that renders text accepts `ReactNode`, and every piece takes a
14+
// `className` so callers can restyle without forking. For layouts the built-in
15+
// props don't cover, pass `children` to a row/block for full control.
16+
17+
const rowSize = {
18+
sm: "py-3",
19+
md: "py-4",
20+
} as const;
21+
22+
type RowSize = keyof typeof rowSize;
23+
24+
/** Page-level wrapper that centers content and sets the settings column width. */
25+
export function SettingsContainer({
26+
children,
27+
className,
28+
}: {
29+
children: ReactNode;
30+
className?: string;
31+
}) {
32+
return (
33+
<MainHorizontallyCenteredContainer
34+
className={cn("max-w-[37.5rem] overflow-visible", className)}
35+
>
36+
{children}
37+
</MainHorizontallyCenteredContainer>
38+
);
39+
}
40+
41+
/** A group of related rows. Adds vertical spacing between sibling sections. */
42+
export function SettingsSection({
43+
children,
44+
className,
45+
}: {
46+
children: ReactNode;
47+
className?: string;
48+
}) {
49+
return (
50+
<section className={cn("w-full [&:not(:first-child)]:mt-12", className)}>{children}</section>
51+
);
52+
}
53+
54+
/**
55+
* Section (or sub-section) heading with an optional description and a
56+
* right-aligned action, sitting above a bottom divide. Use `as="h3"` for a
57+
* heading nested inside a section.
58+
*/
59+
export function SettingsHeader({
60+
title,
61+
description,
62+
action,
63+
as = "h2",
64+
className,
65+
}: {
66+
title: ReactNode;
67+
description?: ReactNode;
68+
action?: ReactNode;
69+
as?: "h2" | "h3";
70+
className?: string;
71+
}) {
72+
const Heading = as === "h3" ? Header3 : Header2;
73+
return (
74+
<div
75+
className={cn(
76+
"flex w-full items-end justify-between gap-8 border-b border-grid-dimmed",
77+
// An h2 section header gets its top spacing from SettingsSection's margin,
78+
// so it only needs bottom padding. An h3 sits mid-section among rows, so it
79+
// takes the full row rhythm (py-4) to separate from the divide above it.
80+
as === "h3" ? "py-4" : "pb-3",
81+
className
82+
)}
83+
>
84+
<div className="space-y-1">
85+
<Heading>{title}</Heading>
86+
{description ? <Paragraph variant="small">{description}</Paragraph> : null}
87+
</div>
88+
{action ? <div className="flex flex-none items-center">{action}</div> : null}
89+
</div>
90+
);
91+
}
92+
93+
/** Title typography for a row. Renders a `<label>` when `htmlFor` is set. */
94+
export function SettingsRowTitle({
95+
children,
96+
htmlFor,
97+
className,
98+
}: {
99+
children: ReactNode;
100+
htmlFor?: string;
101+
className?: string;
102+
}) {
103+
const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className);
104+
return htmlFor ? (
105+
<label htmlFor={htmlFor} className={classes}>
106+
{children}
107+
</label>
108+
) : (
109+
<span className={classes}>{children}</span>
110+
);
111+
}
112+
113+
/** Description/subtitle typography for a row. */
114+
export function SettingsRowDescription({
115+
children,
116+
className,
117+
}: {
118+
children: ReactNode;
119+
className?: string;
120+
}) {
121+
return (
122+
<Paragraph variant="small" className={className}>
123+
{children}
124+
</Paragraph>
125+
);
126+
}
127+
128+
/**
129+
* A single settings row: title + description on the left, action on the right.
130+
*
131+
* Pass `title`/`description` for the common case, or `children` to supply
132+
* custom left-hand content (the built-in title group is skipped when `children`
133+
* is provided). `action` renders on the right in both cases.
134+
*/
135+
export function SettingsRow({
136+
title,
137+
description,
138+
action,
139+
htmlFor,
140+
children,
141+
className,
142+
titleClassName,
143+
size = "md",
144+
align = "center",
145+
bordered = true,
146+
}: {
147+
title?: ReactNode;
148+
description?: ReactNode;
149+
action?: ReactNode;
150+
htmlFor?: string;
151+
children?: ReactNode;
152+
className?: string;
153+
titleClassName?: string;
154+
size?: RowSize;
155+
align?: "center" | "start";
156+
bordered?: boolean;
157+
}) {
158+
return (
159+
<div
160+
className={cn(
161+
"flex w-full justify-between gap-8",
162+
align === "center" ? "items-center" : "items-start",
163+
rowSize[size],
164+
bordered && "border-b border-grid-dimmed",
165+
className
166+
)}
167+
>
168+
{children ?? (
169+
<div className="flex-1 space-y-1">
170+
{title ? (
171+
<SettingsRowTitle htmlFor={htmlFor} className={titleClassName}>
172+
{title}
173+
</SettingsRowTitle>
174+
) : null}
175+
{description ? <SettingsRowDescription>{description}</SettingsRowDescription> : null}
176+
</div>
177+
)}
178+
{action ? <div className="flex flex-none items-center">{action}</div> : null}
179+
</div>
180+
);
181+
}
182+
183+
/**
184+
* Full-width row for arbitrary content (callouts, empty states, custom blocks)
185+
* that shouldn't be split into a title/action layout.
186+
*/
187+
export function SettingsBlock({
188+
children,
189+
className,
190+
size = "md",
191+
bordered = true,
192+
}: {
193+
children: ReactNode;
194+
className?: string;
195+
size?: RowSize;
196+
bordered?: boolean;
197+
}) {
198+
return (
199+
<div
200+
className={cn("w-full", rowSize[size], bordered && "border-b border-grid-dimmed", className)}
201+
>
202+
{children}
203+
</div>
204+
);
205+
}
206+
207+
/** Right-aligned action bar, typically for a section's Save button. */
208+
export function SettingsActions({
209+
children,
210+
className,
211+
}: {
212+
children: ReactNode;
213+
className?: string;
214+
}) {
215+
return <div className={cn("flex justify-end gap-2 py-4", className)}>{children}</div>;
216+
}

0 commit comments

Comments
 (0)