diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 7c76325b6f..4ad4a85564 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { type ReactNode, useEffect, useState } from 'react' import { GithubIcon, GoogleIcon } from '@/components/icons' import { Button } from '@/components/ui/button' import { client } from '@/lib/auth-client' @@ -11,6 +11,7 @@ interface SocialLoginButtonsProps { googleAvailable: boolean callbackURL?: string isProduction: boolean + children?: ReactNode } export function SocialLoginButtons({ @@ -18,6 +19,7 @@ export function SocialLoginButtons({ googleAvailable, callbackURL = '/workspace', isProduction, + children, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) const [isGoogleLoading, setIsGoogleLoading] = useState(false) @@ -103,7 +105,7 @@ export function SocialLoginButtons({ const hasAnyOAuthProvider = githubAvailable || googleAvailable - if (!hasAnyOAuthProvider) { + if (!hasAnyOAuthProvider && !children) { return null } @@ -111,6 +113,7 @@ export function SocialLoginButtons({
{googleAvailable && googleButton} {githubAvailable && githubButton} + {children}
) } diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx new file mode 100644 index 0000000000..80275da62f --- /dev/null +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { env, isTruthy } from '@/lib/env' +import { cn } from '@/lib/utils' + +interface SSOLoginButtonProps { + callbackURL?: string + className?: string + // Visual variant for button styling and placement contexts + // - 'primary' matches the main auth action button style + // - 'outline' matches social provider buttons + variant?: 'primary' | 'outline' + // Optional class used when variant is primary to match brand/gradient + primaryClassName?: string +} + +export function SSOLoginButton({ + callbackURL, + className, + variant = 'outline', + primaryClassName, +}: SSOLoginButtonProps) { + const router = useRouter() + + if (!isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)) { + return null + } + + const handleSSOClick = () => { + const ssoUrl = `/sso${callbackURL ? `?callbackUrl=${encodeURIComponent(callbackURL)}` : ''}` + router.push(ssoUrl) + } + + const primaryBtnClasses = cn( + primaryClassName || 'auth-button-gradient', + 'flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200' + ) + + const outlineBtnClasses = cn('w-full rounded-[10px] shadow-sm hover:bg-gray-50') + + return ( + + ) +} diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index d30b03b634..45877f4e8f 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -16,9 +16,11 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client } from '@/lib/auth-client' import { quickValidateEmail } from '@/lib/email/validation' +import { env, isFalsy, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' +import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { inter } from '@/app/fonts/inter' import { soehne } from '@/app/fonts/soehne/soehne' @@ -365,6 +367,14 @@ export default function LoginPage({ } } + const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED) + const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) + const hasSocial = githubAvailable || googleAvailable + const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial + const showTopSSO = hasOnlySSO + const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) + const showDivider = (emailEnabled || showTopSSO) && showBottomSection + return ( <>
@@ -376,96 +386,111 @@ export default function LoginPage({

-
-
-
-
- -
- 0 && - 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' - )} - /> - {showEmailValidationError && emailErrors.length > 0 && ( -
- {emailErrors.map((error, index) => ( -

{error}

- ))} + {/* SSO Login Button (primary top-only when it is the only method) */} + {showTopSSO && ( +
+ +
+ )} + + {/* Email/Password Form - show unless explicitly disabled */} + {!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && ( + +
+
+
+
- )} -
-
-
- - -
-
0 && + 'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', + showEmailValidationError && + emailErrors.length > 0 && 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' )} /> - + {showEmailValidationError && emailErrors.length > 0 && ( +
+ {emailErrors.map((error, index) => ( +

{error}

+ ))} +
+ )}
- {showValidationError && passwordErrors.length > 0 && ( -
- {passwordErrors.map((error, index) => ( -

{error}

- ))} +
+
+ +
- )} +
+ 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + +
+ {showValidationError && passwordErrors.length > 0 && ( +
+ {passwordErrors.map((error, index) => ( +

{error}

+ ))} +
+ )} +
-
- - + + + )} - {(githubAvailable || googleAvailable) && ( + {/* Divider - show when we have multiple auth methods */} + {showDivider && (
@@ -476,22 +501,37 @@ export default function LoginPage({
)} - + {showBottomSection && ( +
+ + {ssoEnabled && !hasOnlySSO && ( + + )} + +
+ )} -
- Don't have an account? - - Sign up - -
+ {/* Only show signup link if email/password signup is enabled */} + {!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && ( +
+ Don't have an account? + + Sign up + +
+ )}
-
-
-
-
- -
- 0 && - 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' - )} - /> - {showNameValidationError && nameErrors.length > 0 && ( -
- {nameErrors.map((error, index) => ( -

{error}

- ))} + {/* SSO Login Button (primary top-only when it is the only method) */} + {(() => { + const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED) + const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) + const hasSocial = githubAvailable || googleAvailable + const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial + return hasOnlySSO + })() && ( +
+ +
+ )} + + {/* Email/Password Form - show unless explicitly disabled */} + {!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && ( + +
+
+
+
- )} -
-
-
- -
- 0)) && - 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + {showNameValidationError && nameErrors.length > 0 && ( +
+ {nameErrors.map((error, index) => ( +

{error}

+ ))} +
)} - /> - {showEmailValidationError && emailErrors.length > 0 && ( -
- {emailErrors.map((error, index) => ( -

{error}

- ))} -
- )} - {emailError && !showEmailValidationError && ( -
-

{emailError}

-
- )} -
-
-
-
-
+
+
+ +
0 && + 'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', + (emailError || (showEmailValidationError && emailErrors.length > 0)) && 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' )} /> - + {showEmailValidationError && emailErrors.length > 0 && ( +
+ {emailErrors.map((error, index) => ( +

{error}

+ ))} +
+ )} + {emailError && !showEmailValidationError && ( +
+

{emailError}

+
+ )}
- {showValidationError && passwordErrors.length > 0 && ( -
- {passwordErrors.map((error, index) => ( -

{error}

- ))} +
+
+
- )} +
+ 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + +
+ {showValidationError && passwordErrors.length > 0 && ( +
+ {passwordErrors.map((error, index) => ( +

{error}

+ ))} +
+ )} +
-
- - + + + )} - {(githubAvailable || googleAvailable) && ( + {/* Divider - show when we have multiple auth methods */} + {(() => { + const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED) + const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) + const hasSocial = githubAvailable || googleAvailable + const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial + const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) + const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection + return showDivider + })() && (
@@ -503,12 +534,36 @@ function SignupFormContent({
)} - + {(() => { + const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED) + const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) + const hasSocial = githubAvailable || googleAvailable + const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial + const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) + return showBottomSection + })() && ( +
+ + {isTruthy(env.NEXT_PUBLIC_SSO_ENABLED) && ( + + )} + +
+ )}
Already have an account? diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx new file mode 100644 index 0000000000..8437a5c04a --- /dev/null +++ b/apps/sim/app/(auth)/sso/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from 'next/navigation' +import { env, isTruthy } from '@/lib/env' +import SSOForm from './sso-form' + +// Force dynamic rendering to avoid prerender errors with search params +export const dynamic = 'force-dynamic' + +export default async function SSOPage() { + // Redirect if SSO is not enabled + if (!isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)) { + redirect('/login') + } + + return +} diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/app/(auth)/sso/sso-form.tsx new file mode 100644 index 0000000000..29784afa9f --- /dev/null +++ b/apps/sim/app/(auth)/sso/sso-form.tsx @@ -0,0 +1,293 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { client } from '@/lib/auth-client' +import { quickValidateEmail } from '@/lib/email/validation' +import { env, isFalsy } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { cn } from '@/lib/utils' +import { inter } from '@/app/fonts/inter' +import { soehne } from '@/app/fonts/soehne/soehne' + +const logger = createLogger('SSOForm') + +const validateEmailField = (emailValue: string): string[] => { + const errors: string[] = [] + + if (!emailValue || !emailValue.trim()) { + errors.push('Email is required.') + return errors + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + errors.push(validation.reason || 'Please enter a valid email address.') + } + + return errors +} + +const validateCallbackUrl = (url: string): boolean => { + try { + if (url.startsWith('/')) { + return true + } + + const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '' + if (url.startsWith(currentOrigin)) { + return true + } + + return false + } catch (error) { + logger.error('Error validating callback URL:', { error, url }) + return false + } +} + +export default function SSOForm() { + const router = useRouter() + const searchParams = useSearchParams() + const [isLoading, setIsLoading] = useState(false) + const [email, setEmail] = useState('') + const [emailErrors, setEmailErrors] = useState([]) + const [showEmailValidationError, setShowEmailValidationError] = useState(false) + const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [callbackUrl, setCallbackUrl] = useState('/workspace') + + useEffect(() => { + if (searchParams) { + const callback = searchParams.get('callbackUrl') + if (callback) { + if (validateCallbackUrl(callback)) { + setCallbackUrl(callback) + } else { + logger.warn('Invalid callback URL detected and blocked:', { url: callback }) + } + } + + // Check for SSO error from redirect + const error = searchParams.get('error') + if (error) { + const errorMessages: Record = { + account_not_found: + 'No account found. Please contact your administrator to set up SSO access.', + sso_failed: 'SSO authentication failed. Please try again.', + invalid_provider: 'SSO provider not configured correctly.', + } + setEmailErrors([errorMessages[error] || 'SSO authentication failed. Please try again.']) + setShowEmailValidationError(true) + } + } + + const checkCustomBrand = () => { + const computedStyle = getComputedStyle(document.documentElement) + const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() + + if (brandAccent && brandAccent !== '#6f3dfa') { + setButtonClass('auth-button-custom') + } else { + setButtonClass('auth-button-gradient') + } + } + + checkCustomBrand() + + window.addEventListener('resize', checkCustomBrand) + const observer = new MutationObserver(checkCustomBrand) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['style', 'class'], + }) + + return () => { + window.removeEventListener('resize', checkCustomBrand) + observer.disconnect() + } + }, [searchParams]) + + const handleEmailChange = (e: React.ChangeEvent) => { + const newEmail = e.target.value + setEmail(newEmail) + + const errors = validateEmailField(newEmail) + setEmailErrors(errors) + setShowEmailValidationError(false) + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault() + setIsLoading(true) + + const formData = new FormData(e.currentTarget) + const emailRaw = formData.get('email') as string + const emailValue = emailRaw.trim().toLowerCase() + + const emailValidationErrors = validateEmailField(emailValue) + setEmailErrors(emailValidationErrors) + setShowEmailValidationError(emailValidationErrors.length > 0) + + if (emailValidationErrors.length > 0) { + setIsLoading(false) + return + } + + try { + const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' + + await client.signIn.sso({ + email: emailValue, + callbackURL: safeCallbackUrl, + errorCallbackURL: `/sso?error=sso_failed&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, + }) + } catch (err) { + logger.error('SSO sign-in failed', { error: err, email: emailValue }) + + let errorMessage = 'SSO sign-in failed. Please try again.' + if (err instanceof Error) { + if (err.message.includes('NO_PROVIDER_FOUND')) { + errorMessage = 'SSO provider not found. Please check your configuration.' + } else if (err.message.includes('INVALID_EMAIL_DOMAIN')) { + errorMessage = 'Email domain not configured for SSO. Please contact your administrator.' + } else if (err.message.includes('network')) { + errorMessage = 'Network error. Please check your connection and try again.' + } else if (err.message.includes('rate limit')) { + errorMessage = 'Too many requests. Please wait a moment before trying again.' + } else if (err.message.includes('SSO_DISABLED')) { + errorMessage = 'SSO authentication is disabled. Please use another sign-in method.' + } else { + errorMessage = err.message + } + } + + setEmailErrors([errorMessage]) + setShowEmailValidationError(true) + setIsLoading(false) + } + } + + return ( + <> +
+

+ Sign in with SSO +

+

+ Enter your work email to continue +

+
+ +
+
+
+
+ +
+ 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + {showEmailValidationError && emailErrors.length > 0 && ( +
+ {emailErrors.map((error, index) => ( +

{error}

+ ))} +
+ )} +
+
+ + +
+ + {/* Only show divider and email signin button if email/password is enabled */} + {!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && ( + <> +
+
+
+
+
+ Or +
+
+ +
+ + + +
+ + )} + + {/* Only show signup link if email/password signup is enabled */} + {!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && ( +
+ Don't have an account? + + Sign up + +
+ )} + +
+ By signing in, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +
+ + ) +} diff --git a/apps/sim/app/api/auth/sso/providers/route.ts b/apps/sim/app/api/auth/sso/providers/route.ts new file mode 100644 index 0000000000..05a9e3d6ef --- /dev/null +++ b/apps/sim/app/api/auth/sso/providers/route.ts @@ -0,0 +1,65 @@ +import { db, ssoProvider } from '@sim/db' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('SSO-Providers') + +export async function GET(req: NextRequest) { + try { + const session = await auth.api.getSession({ headers: req.headers }) + + let providers + if (session?.user?.id) { + const results = await db + .select({ + id: ssoProvider.id, + providerId: ssoProvider.providerId, + domain: ssoProvider.domain, + issuer: ssoProvider.issuer, + oidcConfig: ssoProvider.oidcConfig, + samlConfig: ssoProvider.samlConfig, + userId: ssoProvider.userId, + organizationId: ssoProvider.organizationId, + }) + .from(ssoProvider) + .where(eq(ssoProvider.userId, session.user.id)) + + providers = results.map((provider) => ({ + ...provider, + providerType: + provider.oidcConfig && provider.samlConfig + ? 'oidc' + : provider.oidcConfig + ? 'oidc' + : provider.samlConfig + ? 'saml' + : ('oidc' as 'oidc' | 'saml'), + })) + } else { + // Unauthenticated users can only see basic info (domain only) + // This is needed for SSO login flow to check if a domain has SSO enabled + const results = await db + .select({ + domain: ssoProvider.domain, + }) + .from(ssoProvider) + + providers = results.map((provider) => ({ + domain: provider.domain, + })) + } + + logger.info('Fetched SSO providers', { + userId: session?.user?.id, + authenticated: !!session?.user?.id, + providerCount: providers.length, + }) + + return NextResponse.json({ providers }) + } catch (error) { + logger.error('Failed to fetch SSO providers', { error }) + return NextResponse.json({ error: 'Failed to fetch SSO providers' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts new file mode 100644 index 0000000000..37f05c1caf --- /dev/null +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -0,0 +1,255 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('SSO-Register') + +export async function POST(request: NextRequest) { + try { + if (!env.SSO_ENABLED) { + return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) + } + + const body = await request.json() + const { + providerId, + issuer, + domain, + providerType = 'oidc', + // OIDC specific fields + clientId, + clientSecret, + scopes = ['openid', 'profile', 'email'], + pkce = true, + // SAML specific fields + entryPoint, + cert, + callbackUrl, + audience, + wantAssertionsSigned, + signatureAlgorithm, + digestAlgorithm, + identifierFormat, + idpMetadata, + // Mapping configuration + mapping = { + id: 'sub', + email: 'email', + name: 'name', + image: 'picture', + }, + } = body + + if (!providerId || !issuer || !domain) { + return NextResponse.json( + { error: 'Missing required fields: providerId, issuer, domain' }, + { status: 400 } + ) + } + + if (providerType === 'oidc') { + if (!clientId || !clientSecret) { + return NextResponse.json( + { error: 'Missing required OIDC fields: clientId, clientSecret' }, + { status: 400 } + ) + } + } else if (providerType === 'saml') { + if (!entryPoint || !cert) { + return NextResponse.json( + { error: 'Missing required SAML fields: entryPoint, cert' }, + { status: 400 } + ) + } + } + + const headers: Record = {} + request.headers.forEach((value, key) => { + headers[key] = value + }) + + const providerConfig: any = { + providerId, + issuer, + domain, + mapping, + } + + if (providerType === 'oidc') { + const oidcConfig: any = { + clientId, + clientSecret, + scopes: + typeof scopes === 'string' + ? scopes + .split(',') + .map((s: string) => s.trim()) + .filter((s: string) => s !== 'offline_access') + : (scopes || ['openid', 'profile', 'email']).filter( + (s: string) => s !== 'offline_access' + ), + pkce: pkce ?? true, + } + + // Add manual endpoints for providers that might need them + // Common patterns for OIDC providers that don't support discovery properly + if ( + issuer.includes('okta.com') || + issuer.includes('auth0.com') || + issuer.includes('identityserver') + ) { + const baseUrl = issuer.includes('/oauth2/default') + ? issuer.replace('/oauth2/default', '') + : issuer.replace('/oauth', '').replace('/v2.0', '').replace('/oauth2', '') + + // Okta-style endpoints + if (issuer.includes('okta.com')) { + oidcConfig.authorizationEndpoint = `${baseUrl}/oauth2/default/v1/authorize` + oidcConfig.tokenEndpoint = `${baseUrl}/oauth2/default/v1/token` + oidcConfig.userInfoEndpoint = `${baseUrl}/oauth2/default/v1/userinfo` + oidcConfig.jwksEndpoint = `${baseUrl}/oauth2/default/v1/keys` + } + // Auth0-style endpoints + else if (issuer.includes('auth0.com')) { + oidcConfig.authorizationEndpoint = `${baseUrl}/authorize` + oidcConfig.tokenEndpoint = `${baseUrl}/oauth/token` + oidcConfig.userInfoEndpoint = `${baseUrl}/userinfo` + oidcConfig.jwksEndpoint = `${baseUrl}/.well-known/jwks.json` + } + // Generic OIDC endpoints (IdentityServer, etc.) + else { + oidcConfig.authorizationEndpoint = `${baseUrl}/connect/authorize` + oidcConfig.tokenEndpoint = `${baseUrl}/connect/token` + oidcConfig.userInfoEndpoint = `${baseUrl}/connect/userinfo` + oidcConfig.jwksEndpoint = `${baseUrl}/.well-known/jwks` + } + + logger.info('Using manual OIDC endpoints for provider', { + providerId, + provider: issuer.includes('okta.com') + ? 'Okta' + : issuer.includes('auth0.com') + ? 'Auth0' + : 'Generic', + authEndpoint: oidcConfig.authorizationEndpoint, + }) + } + + providerConfig.oidcConfig = oidcConfig + } else if (providerType === 'saml') { + const computedCallbackUrl = + callbackUrl || `${issuer.replace('/metadata', '')}/callback/${providerId}` + + const escapeXml = (str: string) => + str.replace(/[<>&"']/g, (c) => { + switch (c) { + case '<': + return '<' + case '>': + return '>' + case '&': + return '&' + case '"': + return '"' + case "'": + return ''' + default: + return c + } + }) + + const spMetadataXml = ` + + + + +` + + const samlConfig: any = { + entryPoint, + cert, + callbackUrl: computedCallbackUrl, + spMetadata: { + metadata: spMetadataXml, + }, + mapping, + } + + if (audience) samlConfig.audience = audience + if (wantAssertionsSigned !== undefined) samlConfig.wantAssertionsSigned = wantAssertionsSigned + if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm + if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm + if (identifierFormat) samlConfig.identifierFormat = identifierFormat + if (idpMetadata) { + samlConfig.idpMetadata = { + metadata: idpMetadata, + } + } + + providerConfig.samlConfig = samlConfig + providerConfig.mapping = undefined + } + + logger.info('Calling Better Auth registerSSOProvider with config:', { + providerId: providerConfig.providerId, + domain: providerConfig.domain, + hasOidcConfig: !!providerConfig.oidcConfig, + hasSamlConfig: !!providerConfig.samlConfig, + samlConfigKeys: providerConfig.samlConfig ? Object.keys(providerConfig.samlConfig) : [], + fullConfig: JSON.stringify( + { + ...providerConfig, + oidcConfig: providerConfig.oidcConfig + ? { + ...providerConfig.oidcConfig, + clientSecret: '[REDACTED]', + } + : undefined, + samlConfig: providerConfig.samlConfig + ? { + ...providerConfig.samlConfig, + cert: '[REDACTED]', + } + : undefined, + }, + null, + 2 + ), + }) + + const registration = await auth.api.registerSSOProvider({ + body: providerConfig, + headers, + }) + + logger.info('SSO provider registered successfully', { + providerId, + providerType, + domain, + }) + + return NextResponse.json({ + success: true, + providerId: registration.providerId, + providerType, + message: `${providerType.toUpperCase()} provider registered successfully`, + }) + } catch (error) { + logger.error('Failed to register SSO provider', { + error, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + errorDetails: JSON.stringify(error), + }) + + return NextResponse.json( + { + error: 'Failed to register SSO provider', + details: error instanceof Error ? error.message : 'Unknown error', + fullError: JSON.stringify(error), + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/theme-provider.tsx b/apps/sim/app/theme-provider.tsx index a247c696e6..9221c452ee 100644 --- a/apps/sim/app/theme-provider.tsx +++ b/apps/sim/app/theme-provider.tsx @@ -13,6 +13,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) { pathname === '/homepage' || pathname.startsWith('/login') || pathname.startsWith('/signup') || + pathname.startsWith('/sso') || pathname.startsWith('/terms') || pathname.startsWith('/privacy') || pathname.startsWith('/invite') || diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-url.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-url.tsx index 7a2106926b..f0320dee29 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-url.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-url.tsx @@ -1,4 +1,4 @@ -import { CheckCheck, Copy, Info } from 'lucide-react' +import { Check, Copy, Info } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -66,7 +66,7 @@ export function WebhookUrlField({ disabled={isLoadingToken} > {copied === 'url' ? ( - + ) : ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx index b9612be299..2e46883162 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx @@ -367,11 +367,12 @@ export function EnvironmentVariables({ onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)} onPaste={(e) => handlePaste(e, originalIndex)} placeholder='API_KEY' + name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`} autoComplete='off' - autoCorrect='off' autoCapitalize='off' spellCheck='false' - name={`env-var-key-${envVar.id || originalIndex}-${Math.random()}`} + readOnly + onFocus={(e) => e.target.removeAttribute('readOnly')} className={`h-9 rounded-[8px] border-none px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isConflict ? 'border border-red-500 bg-[#F6D2D2] outline-none ring-0 disabled:bg-[#F6D2D2] disabled:opacity-100 dark:bg-[#442929] disabled:dark:bg-[#442929]' : 'bg-muted'}`} /> updateEnvVar(originalIndex, 'value', e.target.value)} type={focusedValueIndex === originalIndex ? 'text' : 'password'} - onFocus={(e) => handleValueFocus(originalIndex, e)} + onFocus={(e) => { + if (!isConflict) { + e.target.removeAttribute('readOnly') + handleValueFocus(originalIndex, e) + } + }} onClick={handleValueClick} onBlur={() => setFocusedValueIndex(null)} onPaste={(e) => handlePaste(e, originalIndex)} placeholder={isConflict ? 'Workspace override active' : 'Enter value'} disabled={isConflict} aria-disabled={isConflict} - className={`allow-scroll h-9 rounded-[8px] border-none px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isConflict ? 'cursor-not-allowed border border-red-500 bg-[#F6D2D2] outline-none ring-0 disabled:bg-[#F6D2D2] disabled:opacity-100 dark:bg-[#442929] disabled:dark:bg-[#442929]' : 'bg-muted'}`} - autoComplete='off' - autoCorrect='off' + name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`} + autoComplete='new-password' autoCapitalize='off' spellCheck='false' - name={`env-var-value-${envVar.id || originalIndex}-${Math.random()}`} + readOnly={isConflict} + className={`allow-scroll h-9 rounded-[8px] border-none px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isConflict ? 'cursor-not-allowed border border-red-500 bg-[#F6D2D2] outline-none ring-0 disabled:bg-[#F6D2D2] disabled:opacity-100 dark:bg-[#442929] disabled:dark:bg-[#442929]' : 'bg-muted'}`} />
@@ -442,6 +448,8 @@ export function EnvironmentVariables({ return (
+ {/* Hidden dummy input to prevent autofill */} + {/* Fixed Header */}
{/* Search Input */} @@ -454,6 +462,12 @@ export function EnvironmentVariables({ placeholder='Search variables...' value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} + name='env_search_field' + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + readOnly + onFocus={(e) => e.target.removeAttribute('readOnly')} className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' />
@@ -495,11 +509,21 @@ export function EnvironmentVariables({ setPendingKeyValue(e.target.value) }} onBlur={() => handleWorkspaceKeyRename(key, value)} + name={`workspace_env_key_${key}_${Math.random()}`} + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + readOnly + onFocus={(e) => e.target.removeAttribute('readOnly')} className='h-9 rounded-[8px] border-none bg-muted px-3 text-sm' />
@@ -540,11 +564,21 @@ export function EnvironmentVariables({ setPendingKeyValue(e.target.value) }} onBlur={() => handleWorkspaceKeyRename(key, value)} + name={`workspace_env_key_filtered_${key}_${Math.random()}`} + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + readOnly + onFocus={(e) => e.target.removeAttribute('readOnly')} className='h-9 rounded-[8px] border-none bg-muted px-3 text-sm' />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts index 139d1af73a..1bd2b3d26f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts @@ -7,5 +7,6 @@ export { General } from './general/general' export { MCP } from './mcp/mcp' export { Privacy } from './privacy/privacy' export { SettingsNavigation } from './settings-navigation/settings-navigation' +export { SSO } from './sso/sso' export { Subscription } from './subscription/subscription' export { TeamManagement } from './team-management/team-management' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx index ebeb2ddec2..749458520a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx @@ -1,9 +1,11 @@ +import { useEffect, useState } from 'react' import { Bot, CreditCard, FileCode, Home, Key, + LogIn, Server, Settings, Shield, @@ -11,9 +13,11 @@ import { Users, Waypoints, } from 'lucide-react' +import { useSession } from '@/lib/auth-client' import { getEnv, isTruthy } from '@/lib/env' import { isHosted } from '@/lib/environment' import { cn } from '@/lib/utils' +import { useOrganizationStore } from '@/stores/organization' const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) @@ -28,6 +32,7 @@ interface SettingsNavigationProps { | 'apikeys' | 'subscription' | 'team' + | 'sso' | 'privacy' | 'copilot' | 'mcp' @@ -44,6 +49,7 @@ type NavigationItem = { | 'apikeys' | 'subscription' | 'team' + | 'sso' | 'copilot' | 'privacy' | 'mcp' @@ -51,6 +57,8 @@ type NavigationItem = { icon: React.ComponentType<{ className?: string }> hideWhenBillingDisabled?: boolean requiresTeam?: boolean + requiresEnterprise?: boolean + requiresOwner?: boolean } const allNavigationItems: NavigationItem[] = [ @@ -107,6 +115,14 @@ const allNavigationItems: NavigationItem[] = [ hideWhenBillingDisabled: true, requiresTeam: true, }, + { + id: 'sso', + label: 'Single Sign-On', + icon: LogIn, + requiresTeam: true, + requiresEnterprise: true, + requiresOwner: true, + }, ] export function SettingsNavigation({ @@ -114,6 +130,36 @@ export function SettingsNavigation({ onSectionChange, hasOrganization, }: SettingsNavigationProps) { + const { data: session } = useSession() + const { hasEnterprisePlan, getUserRole } = useOrganizationStore() + const userEmail = session?.user?.email + const userId = session?.user?.id + const userRole = getUserRole(userEmail) + const isOwner = userRole === 'owner' + const isAdmin = userRole === 'admin' + const canManageSSO = isOwner || isAdmin + + const [isSSOProviderOwner, setIsSSOProviderOwner] = useState(null) + + useEffect(() => { + if (!isHosted && userId) { + fetch('/api/auth/sso/providers') + .then((res) => { + if (!res.ok) throw new Error('Failed to fetch providers') + return res.json() + }) + .then((data) => { + const ownsProvider = data.providers?.some((p: any) => p.userId === userId) || false + setIsSSOProviderOwner(ownsProvider) + }) + .catch(() => { + setIsSSOProviderOwner(false) + }) + } else if (isHosted) { + setIsSSOProviderOwner(null) + } + }, [userId, isHosted]) + const navigationItems = allNavigationItems.filter((item) => { if (item.id === 'copilot' && !isHosted) { return false @@ -122,11 +168,25 @@ export function SettingsNavigation({ return false } - // Hide team tab if user doesn't have an active organization if (item.requiresTeam && !hasOrganization) { return false } + if (item.requiresEnterprise && !hasEnterprisePlan) { + return false + } + + if (item.id === 'sso') { + if (isHosted) { + return hasOrganization && hasEnterprisePlan && canManageSSO + } + return isSSOProviderOwner === true + } + + if (item.requiresOwner && !isOwner) { + return false + } + return true }) @@ -169,7 +229,7 @@ export function SettingsNavigation({ ))}
- {/* Homepage link - Only show in hosted environments */} + {/* Homepage link */} {isHosted && (
+
+
+ +
+
+
+ Issuer URL +

+ {provider.issuer} +

+
+
+ Provider ID +

{provider.providerId}

+
+
+ +
+ + Callback URL + +
+ (e.target as HTMLInputElement).select()} + /> + +
+
+
+
+ ))} +
+ ) : ( + // SSO Configuration Form + <> + {hasProviders && ( +
+ +
+ )} +
+ {/* Hidden dummy input to prevent autofill */} + + {/* Provider Type Selection */} +
+ +
+ + +
+

+ {formData.providerType === 'oidc' + ? 'OpenID Connect (Okta, Azure AD, Auth0, etc.)' + : 'Security Assertion Markup Language (ADFS, Shibboleth, etc.)'} +

+
+ +
+ + + {showErrors && errors.providerId.length > 0 && ( +
+

{errors.providerId.join(' ')}

+
+ )} +

+ Select a pre-configured provider ID from the trusted providers list +

+
+ +
+ + e.target.removeAttribute('readOnly')} + onChange={(e) => handleInputChange('issuerUrl', e.target.value)} + className={cn( + 'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', + showErrors && + errors.issuerUrl.length > 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + {showErrors && errors.issuerUrl.length > 0 && ( +
+

{errors.issuerUrl.join(' ')}

+
+ )} +

+

+ +
+ + e.target.removeAttribute('readOnly')} + onChange={(e) => handleInputChange('domain', e.target.value)} + className={cn( + 'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', + showErrors && + errors.domain.length > 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + {showErrors && errors.domain.length > 0 && ( +
+

{errors.domain.join(' ')}

+
+ )} +
+ + {/* Provider-specific fields */} + {formData.providerType === 'oidc' ? ( + <> +
+ + e.target.removeAttribute('readOnly')} + onChange={(e) => handleInputChange('clientId', e.target.value)} + className={cn( + 'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', + showErrors && + errors.clientId.length > 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + {showErrors && errors.clientId.length > 0 && ( +
+

{errors.clientId.join(' ')}

+
+ )} +
+ +
+ +
+ { + e.target.removeAttribute('readOnly') + setShowClientSecret(true) + }} + onBlurCapture={() => setShowClientSecret(false)} + onChange={(e) => handleInputChange('clientSecret', e.target.value)} + className={cn( + 'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', + showErrors && + errors.clientSecret.length > 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + +
+ {showErrors && errors.clientSecret.length > 0 && ( +
+

{errors.clientSecret.join(' ')}

+
+ )} +
+ +
+ + handleInputChange('scopes', e.target.value)} + className={cn( + 'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', + showErrors && + errors.scopes.length > 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + {showErrors && errors.scopes.length > 0 && ( +
+

{errors.scopes.join(' ')}

+
+ )} +

+ Comma-separated list of OIDC scopes to request +

+
+ + ) : ( + <> +
+ + handleInputChange('entryPoint', e.target.value)} + className={cn( + 'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', + showErrors && + errors.entryPoint.length > 0 && + 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' + )} + /> + {showErrors && errors.entryPoint.length > 0 && ( +
+

{errors.entryPoint.join(' ')}

+
+ )} +

+

+ +
+ +