Skip to content

Commit bf1c178

Browse files
feat(sso): add support for login with SAML/SSO (#1489)
* feat(sso): added login with SAML/SSO * restore env * fixed login styling * upgrade deps, update UI * more styling improvements * reran migrations, tested with script * improvement(auth): created SSO page * improvement(auth): remove email option for SSO if not enabled * cleanup * cleaned up, added documentation for SSO/SAML config + tested registering either one with script and UI form * cleanup * ack PR comments * move sso known providers to consts --------- Co-authored-by: waleed <waleed> Co-authored-by: Emir Karabeg <[email protected]>
1 parent 6a66466 commit bf1c178

File tree

28 files changed

+10088
-344
lines changed

28 files changed

+10088
-344
lines changed

apps/sim/app/(auth)/components/social-login-buttons.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useState } from 'react'
3+
import { type ReactNode, useEffect, useState } from 'react'
44
import { GithubIcon, GoogleIcon } from '@/components/icons'
55
import { Button } from '@/components/ui/button'
66
import { client } from '@/lib/auth-client'
@@ -11,13 +11,15 @@ interface SocialLoginButtonsProps {
1111
googleAvailable: boolean
1212
callbackURL?: string
1313
isProduction: boolean
14+
children?: ReactNode
1415
}
1516

1617
export function SocialLoginButtons({
1718
githubAvailable,
1819
googleAvailable,
1920
callbackURL = '/workspace',
2021
isProduction,
22+
children,
2123
}: SocialLoginButtonsProps) {
2224
const [isGithubLoading, setIsGithubLoading] = useState(false)
2325
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
@@ -103,14 +105,15 @@ export function SocialLoginButtons({
103105

104106
const hasAnyOAuthProvider = githubAvailable || googleAvailable
105107

106-
if (!hasAnyOAuthProvider) {
108+
if (!hasAnyOAuthProvider && !children) {
107109
return null
108110
}
109111

110112
return (
111113
<div className={`${inter.className} grid gap-3 font-light`}>
112114
{googleAvailable && googleButton}
113115
{githubAvailable && githubButton}
116+
{children}
114117
</div>
115118
)
116119
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use client'
2+
3+
import { useRouter } from 'next/navigation'
4+
import { Button } from '@/components/ui/button'
5+
import { env, isTruthy } from '@/lib/env'
6+
import { cn } from '@/lib/utils'
7+
8+
interface SSOLoginButtonProps {
9+
callbackURL?: string
10+
className?: string
11+
// Visual variant for button styling and placement contexts
12+
// - 'primary' matches the main auth action button style
13+
// - 'outline' matches social provider buttons
14+
variant?: 'primary' | 'outline'
15+
// Optional class used when variant is primary to match brand/gradient
16+
primaryClassName?: string
17+
}
18+
19+
export function SSOLoginButton({
20+
callbackURL,
21+
className,
22+
variant = 'outline',
23+
primaryClassName,
24+
}: SSOLoginButtonProps) {
25+
const router = useRouter()
26+
27+
if (!isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)) {
28+
return null
29+
}
30+
31+
const handleSSOClick = () => {
32+
const ssoUrl = `/sso${callbackURL ? `?callbackUrl=${encodeURIComponent(callbackURL)}` : ''}`
33+
router.push(ssoUrl)
34+
}
35+
36+
const primaryBtnClasses = cn(
37+
primaryClassName || 'auth-button-gradient',
38+
'flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200'
39+
)
40+
41+
const outlineBtnClasses = cn('w-full rounded-[10px] shadow-sm hover:bg-gray-50')
42+
43+
return (
44+
<Button
45+
type='button'
46+
onClick={handleSSOClick}
47+
variant={variant === 'outline' ? 'outline' : undefined}
48+
className={cn(variant === 'outline' ? outlineBtnClasses : primaryBtnClasses, className)}
49+
>
50+
Sign in with SSO
51+
</Button>
52+
)
53+
}

apps/sim/app/(auth)/login/login-form.tsx

Lines changed: 131 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import { Input } from '@/components/ui/input'
1616
import { Label } from '@/components/ui/label'
1717
import { client } from '@/lib/auth-client'
1818
import { quickValidateEmail } from '@/lib/email/validation'
19+
import { env, isFalsy, isTruthy } from '@/lib/env'
1920
import { createLogger } from '@/lib/logs/console/logger'
2021
import { cn } from '@/lib/utils'
2122
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
23+
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
2224
import { inter } from '@/app/fonts/inter'
2325
import { soehne } from '@/app/fonts/soehne/soehne'
2426

@@ -365,6 +367,14 @@ export default function LoginPage({
365367
}
366368
}
367369

370+
const ssoEnabled = isTruthy(env.NEXT_PUBLIC_SSO_ENABLED)
371+
const emailEnabled = !isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED)
372+
const hasSocial = githubAvailable || googleAvailable
373+
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
374+
const showTopSSO = hasOnlySSO
375+
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
376+
const showDivider = (emailEnabled || showTopSSO) && showBottomSection
377+
368378
return (
369379
<>
370380
<div className='space-y-1 text-center'>
@@ -376,96 +386,111 @@ export default function LoginPage({
376386
</p>
377387
</div>
378388

379-
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
380-
<div className='space-y-6'>
381-
<div className='space-y-2'>
382-
<div className='flex items-center justify-between'>
383-
<Label htmlFor='email'>Email</Label>
384-
</div>
385-
<Input
386-
id='email'
387-
name='email'
388-
placeholder='Enter your email'
389-
required
390-
autoCapitalize='none'
391-
autoComplete='email'
392-
autoCorrect='off'
393-
value={email}
394-
onChange={handleEmailChange}
395-
className={cn(
396-
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
397-
showEmailValidationError &&
398-
emailErrors.length > 0 &&
399-
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
400-
)}
401-
/>
402-
{showEmailValidationError && emailErrors.length > 0 && (
403-
<div className='mt-1 space-y-1 text-red-400 text-xs'>
404-
{emailErrors.map((error, index) => (
405-
<p key={index}>{error}</p>
406-
))}
389+
{/* SSO Login Button (primary top-only when it is the only method) */}
390+
{showTopSSO && (
391+
<div className={`${inter.className} mt-8`}>
392+
<SSOLoginButton
393+
callbackURL={callbackUrl}
394+
variant='primary'
395+
primaryClassName={buttonClass}
396+
/>
397+
</div>
398+
)}
399+
400+
{/* Email/Password Form - show unless explicitly disabled */}
401+
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
402+
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
403+
<div className='space-y-6'>
404+
<div className='space-y-2'>
405+
<div className='flex items-center justify-between'>
406+
<Label htmlFor='email'>Email</Label>
407407
</div>
408-
)}
409-
</div>
410-
<div className='space-y-2'>
411-
<div className='flex items-center justify-between'>
412-
<Label htmlFor='password'>Password</Label>
413-
<button
414-
type='button'
415-
onClick={() => setForgotPasswordOpen(true)}
416-
className='font-medium text-muted-foreground text-xs transition hover:text-foreground'
417-
>
418-
Forgot password?
419-
</button>
420-
</div>
421-
<div className='relative'>
422408
<Input
423-
id='password'
424-
name='password'
409+
id='email'
410+
name='email'
411+
placeholder='Enter your email'
425412
required
426-
type={showPassword ? 'text' : 'password'}
427413
autoCapitalize='none'
428-
autoComplete='current-password'
414+
autoComplete='email'
429415
autoCorrect='off'
430-
placeholder='Enter your password'
431-
value={password}
432-
onChange={handlePasswordChange}
416+
value={email}
417+
onChange={handleEmailChange}
433418
className={cn(
434-
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
435-
showValidationError &&
436-
passwordErrors.length > 0 &&
419+
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
420+
showEmailValidationError &&
421+
emailErrors.length > 0 &&
437422
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
438423
)}
439424
/>
440-
<button
441-
type='button'
442-
onClick={() => setShowPassword(!showPassword)}
443-
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
444-
aria-label={showPassword ? 'Hide password' : 'Show password'}
445-
>
446-
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
447-
</button>
425+
{showEmailValidationError && emailErrors.length > 0 && (
426+
<div className='mt-1 space-y-1 text-red-400 text-xs'>
427+
{emailErrors.map((error, index) => (
428+
<p key={index}>{error}</p>
429+
))}
430+
</div>
431+
)}
448432
</div>
449-
{showValidationError && passwordErrors.length > 0 && (
450-
<div className='mt-1 space-y-1 text-red-400 text-xs'>
451-
{passwordErrors.map((error, index) => (
452-
<p key={index}>{error}</p>
453-
))}
433+
<div className='space-y-2'>
434+
<div className='flex items-center justify-between'>
435+
<Label htmlFor='password'>Password</Label>
436+
<button
437+
type='button'
438+
onClick={() => setForgotPasswordOpen(true)}
439+
className='font-medium text-muted-foreground text-xs transition hover:text-foreground'
440+
>
441+
Forgot password?
442+
</button>
454443
</div>
455-
)}
444+
<div className='relative'>
445+
<Input
446+
id='password'
447+
name='password'
448+
required
449+
type={showPassword ? 'text' : 'password'}
450+
autoCapitalize='none'
451+
autoComplete='current-password'
452+
autoCorrect='off'
453+
placeholder='Enter your password'
454+
value={password}
455+
onChange={handlePasswordChange}
456+
className={cn(
457+
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
458+
showValidationError &&
459+
passwordErrors.length > 0 &&
460+
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
461+
)}
462+
/>
463+
<button
464+
type='button'
465+
onClick={() => setShowPassword(!showPassword)}
466+
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
467+
aria-label={showPassword ? 'Hide password' : 'Show password'}
468+
>
469+
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
470+
</button>
471+
</div>
472+
{showValidationError && passwordErrors.length > 0 && (
473+
<div className='mt-1 space-y-1 text-red-400 text-xs'>
474+
{passwordErrors.map((error, index) => (
475+
<p key={index}>{error}</p>
476+
))}
477+
</div>
478+
)}
479+
</div>
456480
</div>
457-
</div>
458481

459-
<Button
460-
type='submit'
461-
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
462-
disabled={isLoading}
463-
>
464-
{isLoading ? 'Signing in...' : 'Sign in'}
465-
</Button>
466-
</form>
482+
<Button
483+
type='submit'
484+
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
485+
disabled={isLoading}
486+
>
487+
{isLoading ? 'Signing in...' : 'Sign in'}
488+
</Button>
489+
</form>
490+
)}
467491

468-
{(githubAvailable || googleAvailable) && (
492+
{/* Divider - show when we have multiple auth methods */}
493+
{showDivider && (
469494
<div className={`${inter.className} relative my-6 font-light`}>
470495
<div className='absolute inset-0 flex items-center'>
471496
<div className='auth-divider w-full border-t' />
@@ -476,22 +501,37 @@ export default function LoginPage({
476501
</div>
477502
)}
478503

479-
<SocialLoginButtons
480-
googleAvailable={googleAvailable}
481-
githubAvailable={githubAvailable}
482-
isProduction={isProduction}
483-
callbackURL={callbackUrl}
484-
/>
504+
{showBottomSection && (
505+
<div className={cn(inter.className, !emailEnabled ? 'mt-8' : undefined)}>
506+
<SocialLoginButtons
507+
googleAvailable={googleAvailable}
508+
githubAvailable={githubAvailable}
509+
isProduction={isProduction}
510+
callbackURL={callbackUrl}
511+
>
512+
{ssoEnabled && !hasOnlySSO && (
513+
<SSOLoginButton
514+
callbackURL={callbackUrl}
515+
variant='outline'
516+
primaryClassName={buttonClass}
517+
/>
518+
)}
519+
</SocialLoginButtons>
520+
</div>
521+
)}
485522

486-
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
487-
<span className='font-normal'>Don't have an account? </span>
488-
<Link
489-
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
490-
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
491-
>
492-
Sign up
493-
</Link>
494-
</div>
523+
{/* Only show signup link if email/password signup is enabled */}
524+
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
525+
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
526+
<span className='font-normal'>Don't have an account? </span>
527+
<Link
528+
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
529+
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
530+
>
531+
Sign up
532+
</Link>
533+
</div>
534+
)}
495535

496536
<div
497537
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}

0 commit comments

Comments
 (0)