Skip to content

Commit 8089b56

Browse files
atrakhConvex, Inc.
authored andcommitted
dashboard: add ui for configuring sso (#41986)
--- > [!NOTE] > Adds a new SSO settings page with enable/disable and domain management, gated by a feature flag, plus minor design-system tweaks. > > - **Dashboard** > - **SSO Settings UI**: New `TeamSSO` component (`components/teamSettings/TeamSSO.tsx`) with enable/disable and domain configuration, including validation against verified email domains. > - **API Hooks**: Add `useGetSSO`, `useEnableSSO`, `useUpdateSSODomain`, `useDisableSSO` in `api/teams.ts` with corresponding mutate keys. > - **Navigation & Page**: Add `settings/sso` route (`pages/t/[team]/settings/sso.tsx`) and sidebar link in `TeamSettingsLayout` when enabled. > - **Feature Flag**: Introduce `singleSignOn` flag via `useLaunchDarkly` for conditional SSO UI. > - **Design System** > - **TextInput**: `error` prop accepts `React.ReactNode` (was `string`). > - **Checkbox**: Improve disabled styles (`disabled:bg-background-primary`, `disabled:cursor-not-allowed`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 490ec4984b042a68f54b40f16b6a61cbe85466fa. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> GitOrigin-RevId: 2ff63ad4e58d410f59037882c13a9c8200378f75
1 parent 1d7b623 commit 8089b56

File tree

8 files changed

+312
-6
lines changed

8 files changed

+312
-6
lines changed

npm-packages/@convex-dev/design-system/src/Checkbox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function Checkbox({
3131
tabIndex={0}
3232
type="checkbox"
3333
className={classNames(
34-
"size-3.5 form-checkbox enabled:cursor-pointer rounded-sm disabled:opacity-50 enabled:hover:text-content-link enabled:hover:outline enabled:hover:outline-content-primary",
34+
"size-3.5 form-checkbox enabled:cursor-pointer rounded-sm disabled:opacity-50 disabled:bg-background-primary disabled:cursor-not-allowed enabled:hover:text-content-link enabled:hover:outline enabled:hover:outline-content-primary",
3535
"focus:outline-0 focus:ring-0",
3636
"bg-background-secondary ring-offset-background-secondary checked:bg-util-accent text-util-accent",
3737
className,

npm-packages/@convex-dev/design-system/src/TextInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type InputProps = {
1919
iconTooltip?: string;
2020
/** The action on `Icon`. */
2121
action?: () => void;
22-
error?: string;
22+
error?: React.ReactNode;
2323
description?: React.ReactNode;
2424
id: string;
2525
type?: "text" | "search" | "email" | "time" | "password" | "number";

npm-packages/dashboard/src/api/teams.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,55 @@ export function useUnpauseTeam(teamId: number) {
131131
successToast: "Your team has been restored.",
132132
});
133133
}
134+
135+
export function useGetSSO(teamId: number | undefined) {
136+
const { data: ssoOrganization } = useBBQuery({
137+
path: "/teams/{team_id}/get_sso",
138+
pathParams: {
139+
team_id: teamId?.toString() || "",
140+
},
141+
});
142+
return ssoOrganization;
143+
}
144+
145+
export function useEnableSSO(teamId: number) {
146+
return useBBMutation({
147+
path: `/teams/{team_id}/enable_sso`,
148+
pathParams: {
149+
team_id: teamId.toString(),
150+
},
151+
mutateKey: "/teams/{team_id}/get_sso",
152+
mutatePathParams: {
153+
team_id: teamId.toString(),
154+
},
155+
successToast: "SSO has been enabled for your team.",
156+
});
157+
}
158+
159+
export function useUpdateSSODomain(teamId: number) {
160+
return useBBMutation({
161+
path: `/teams/{team_id}/update_sso_domain`,
162+
pathParams: {
163+
team_id: teamId.toString(),
164+
},
165+
mutateKey: "/teams/{team_id}/get_sso",
166+
mutatePathParams: {
167+
team_id: teamId.toString(),
168+
},
169+
successToast: "SSO domain has been updated.",
170+
});
171+
}
172+
173+
export function useDisableSSO(teamId: number) {
174+
return useBBMutation({
175+
path: `/teams/{team_id}/disable_sso`,
176+
pathParams: {
177+
team_id: teamId.toString(),
178+
},
179+
mutateKey: "/teams/{team_id}/get_sso",
180+
mutatePathParams: {
181+
team_id: teamId.toString(),
182+
},
183+
successToast: "SSO has been disabled for your team.",
184+
});
185+
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { Team } from "generatedApi";
2+
import { Sheet } from "@ui/Sheet";
3+
import { Callout } from "@ui/Callout";
4+
import { Checkbox } from "@ui/Checkbox";
5+
import { Spinner } from "@ui/Spinner";
6+
import { TextInput } from "@ui/TextInput";
7+
import { Button } from "@ui/Button";
8+
import { useIsCurrentMemberTeamAdmin } from "api/roles";
9+
import {
10+
useTeamEntitlements,
11+
useGetSSO,
12+
useEnableSSO,
13+
useUpdateSSODomain,
14+
useDisableSSO,
15+
} from "api/teams";
16+
import { useState, useMemo } from "react";
17+
import { Tooltip } from "@ui/Tooltip";
18+
import { useProfileEmails } from "api/profile";
19+
20+
export function TeamSSO({ team }: { team: Team }) {
21+
const hasAdminPermissions = useIsCurrentMemberTeamAdmin();
22+
const entitlements = useTeamEntitlements(team.id);
23+
const ssoOrganization = useGetSSO(team.id);
24+
const enableSSO = useEnableSSO(team.id);
25+
const updateSSODomain = useUpdateSSODomain(team.id);
26+
const disableSSO = useDisableSSO(team.id);
27+
const profileEmails = useProfileEmails();
28+
29+
const [isSubmitting, setIsSubmitting] = useState(false);
30+
const [showDomainForm, setShowDomainForm] = useState(false);
31+
const [domain, setDomain] = useState("");
32+
const [domainError, setDomainError] = useState<React.ReactNode>(null);
33+
34+
const ssoEnabled = entitlements?.ssoEnabled ?? false;
35+
const isSSOConfigured = !!ssoOrganization;
36+
const currentDomain = ssoOrganization?.domains?.[0]?.domain;
37+
38+
// Extract verified domains from team member emails
39+
const verifiedDomains = useMemo(() => {
40+
if (!profileEmails) return new Set<string>();
41+
const domains = new Set<string>();
42+
profileEmails.forEach(({ email, isVerified }) => {
43+
const emailDomain = email.split("@")[1];
44+
if (emailDomain && isVerified) {
45+
domains.add(emailDomain.toLowerCase());
46+
}
47+
});
48+
return domains;
49+
}, [profileEmails]);
50+
51+
const handleDomainFormSubmit = async (e: React.FormEvent) => {
52+
e.preventDefault();
53+
const trimmedDomain = domain.trim().toLowerCase();
54+
55+
if (!trimmedDomain) {
56+
setDomainError("Domain is required");
57+
return;
58+
}
59+
60+
// Validate that domain matches a verified email domain
61+
if (!verifiedDomains.has(trimmedDomain)) {
62+
setDomainError(
63+
<div>
64+
The domain "{trimmedDomain}" does not match any verified email
65+
addresses on your account.{" "}
66+
<a
67+
href="/profile"
68+
target="_blank"
69+
rel="noopener noreferrer"
70+
className="text-content-link underline"
71+
>
72+
Add a verified email
73+
</a>{" "}
74+
with this domain before setting up SSO.
75+
</div>,
76+
);
77+
return;
78+
}
79+
80+
setDomainError(null);
81+
setIsSubmitting(true);
82+
try {
83+
if (isSSOConfigured) {
84+
await updateSSODomain({ domain: trimmedDomain });
85+
} else {
86+
await enableSSO({ domain: trimmedDomain });
87+
}
88+
setShowDomainForm(false);
89+
setDomain("");
90+
} finally {
91+
setIsSubmitting(false);
92+
}
93+
};
94+
95+
return (
96+
<>
97+
<h2>Single Sign-On (SSO)</h2>
98+
99+
{!ssoEnabled && (
100+
<Callout variant="upsell">
101+
SSO is not available on your plan. Upgrade your plan to use SSO.
102+
</Callout>
103+
)}
104+
105+
<Sheet>
106+
<h3 className="mb-2">Configuration</h3>
107+
<p className="mb-4 text-xs text-content-secondary">
108+
Configure Single Sign-On (SSO) for your team to enable secure
109+
authentication through your identity provider.
110+
</p>
111+
112+
<Tooltip
113+
tip={
114+
!hasAdminPermissions
115+
? "You do not have permission to change SSO settings."
116+
: !ssoEnabled
117+
? "SSO is not available on your plan."
118+
: undefined
119+
}
120+
>
121+
<label className="flex items-center gap-2 text-sm">
122+
<Checkbox
123+
checked={isSSOConfigured || showDomainForm}
124+
disabled={isSubmitting || !hasAdminPermissions || !ssoEnabled}
125+
onChange={async () => {
126+
if (isSSOConfigured) {
127+
setIsSubmitting(true);
128+
try {
129+
await disableSSO();
130+
setShowDomainForm(false);
131+
setDomain("");
132+
} finally {
133+
setIsSubmitting(false);
134+
}
135+
} else if (showDomainForm) {
136+
setShowDomainForm(false);
137+
setDomain("");
138+
} else {
139+
setShowDomainForm(true);
140+
setDomain("");
141+
}
142+
}}
143+
/>
144+
Enable SSO
145+
{isSubmitting && (
146+
<div>
147+
<Spinner />
148+
</div>
149+
)}
150+
</label>
151+
</Tooltip>
152+
153+
{(showDomainForm || isSSOConfigured) && (
154+
<div className="mt-6 space-y-4">
155+
{isSSOConfigured && !showDomainForm && (
156+
<div className="flex flex-col gap-2">
157+
{currentDomain && (
158+
<span>
159+
Current domain:{" "}
160+
<span className="font-semibold">{currentDomain}</span>
161+
</span>
162+
)}
163+
<Button
164+
variant="neutral"
165+
className="w-fit"
166+
size="sm"
167+
onClick={() => {
168+
setShowDomainForm(true);
169+
setDomain(currentDomain || "");
170+
}}
171+
disabled={isSubmitting || !hasAdminPermissions}
172+
>
173+
{currentDomain ? "Change SSO Domain" : "Set SSO Domain"}
174+
</Button>
175+
</div>
176+
)}
177+
178+
{showDomainForm && (
179+
<form
180+
className="max-w-[30rem] space-y-4"
181+
onSubmit={handleDomainFormSubmit}
182+
>
183+
<TextInput
184+
autoFocus
185+
id="sso-domain"
186+
label="Domain"
187+
value={domain}
188+
onChange={(e) => {
189+
setDomain(e.target.value);
190+
setDomainError(null);
191+
}}
192+
placeholder={currentDomain || "example.com"}
193+
disabled={isSubmitting}
194+
description="Enter the domain your team's members will use to login with SSO."
195+
error={domainError}
196+
/>
197+
<div className="flex gap-2">
198+
<Button
199+
variant="neutral"
200+
onClick={() => {
201+
setShowDomainForm(false);
202+
setDomain("");
203+
setDomainError(null);
204+
}}
205+
disabled={isSubmitting}
206+
>
207+
Cancel
208+
</Button>
209+
<Button
210+
type="submit"
211+
variant="primary"
212+
disabled={!domain.trim() || isSubmitting}
213+
>
214+
Save
215+
</Button>
216+
</div>
217+
</form>
218+
)}
219+
</div>
220+
)}
221+
</Sheet>
222+
</>
223+
);
224+
}

npm-packages/dashboard/src/generatedApi.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2229,8 +2229,13 @@ export interface components {
22292229
};
22302230
/** @enum {string} */
22312231
Role: "admin" | "developer";
2232+
SSOOrganizationDomain: {
2233+
domain: string;
2234+
id: string;
2235+
};
22322236
SSOOrganizationResponse: {
22332237
createdAt: string;
2238+
domains: components["schemas"]["SSOOrganizationDomain"][];
22342239
id: string;
22352240
name: string;
22362241
updatedAt: string;
@@ -2538,6 +2543,7 @@ export type RenameAccessTokenArgs = components['schemas']['RenameAccessTokenArgs
25382543
export type RequestDestination = components['schemas']['RequestDestination'];
25392544
export type RestoreFromCloudBackupArgs = components['schemas']['RestoreFromCloudBackupArgs'];
25402545
export type Role = components['schemas']['Role'];
2546+
export type SsoOrganizationDomain = components['schemas']['SSOOrganizationDomain'];
25412547
export type SsoOrganizationResponse = components['schemas']['SSOOrganizationResponse'];
25422548
export type SerializedAccessToken = components['schemas']['SerializedAccessToken'];
25432549
export type SetSpendingLimitArgs = components['schemas']['SetSpendingLimitArgs'];

npm-packages/dashboard/src/hooks/useLaunchDarkly.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import kebabCase from "lodash/kebabCase";
44
const flagDefaults: {
55
commandPalette: boolean;
66
commandPaletteDeleteProjects: boolean;
7+
singleSignOn: boolean;
78
} = {
89
commandPalette: false,
910
commandPaletteDeleteProjects: false,
11+
singleSignOn: false,
1012
};
1113

1214
function kebabCaseKeys(object: typeof flagDefaults) {

npm-packages/dashboard/src/layouts/TeamSettingsLayout.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Head from "next/head";
77
import React from "react";
88
import { Team } from "generatedApi";
99
import { SidebarLink } from "@common/elements/Sidebar";
10+
import { useLaunchDarkly } from "hooks/useLaunchDarkly";
1011

1112
export function TeamSettingsLayout({
1213
page: selectedPage,
@@ -21,15 +22,17 @@ export function TeamSettingsLayout({
2122
| "audit-log"
2223
| "referrals"
2324
| "access-tokens"
24-
| "applications";
25+
| "applications"
26+
| "sso";
2527
Component: React.FunctionComponent<{ team: Team }>;
2628
title: string;
2729
}) {
2830
const selectedTeam = useCurrentTeam();
2931

30-
const auditLogsEnabled = useTeamEntitlements(
31-
selectedTeam?.id,
32-
)?.auditLogsEnabled;
32+
const entitlements = useTeamEntitlements(selectedTeam?.id);
33+
const auditLogsEnabled = entitlements?.auditLogsEnabled;
34+
35+
const { singleSignOn } = useLaunchDarkly();
3336

3437
const pages = [
3538
"general",
@@ -88,6 +91,14 @@ export function TeamSettingsLayout({
8891
>
8992
Audit Log
9093
</SidebarLink>
94+
{singleSignOn && (
95+
<SidebarLink
96+
isActive={selectedPage === "sso"}
97+
href={`/t/${selectedTeam?.slug}/settings/sso`}
98+
>
99+
Single Sign-On
100+
</SidebarLink>
101+
)}
91102
</aside>
92103
<div className="scrollbar w-full overflow-y-auto">
93104
<div className="flex max-w-[65rem] flex-col gap-6 p-6">
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { TeamSSO } from "components/teamSettings/TeamSSO";
2+
import { TeamSettingsLayout } from "layouts/TeamSettingsLayout";
3+
import { withAuthenticatedPage } from "lib/withAuthenticatedPage";
4+
5+
export { getServerSideProps } from "lib/ssr";
6+
7+
function SSOPage() {
8+
return <TeamSettingsLayout page="sso" Component={TeamSSO} title="SSO" />;
9+
}
10+
11+
export default withAuthenticatedPage(SSOPage);

0 commit comments

Comments
 (0)