Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 10
postgres-legacy:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sim_billing_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U postgres -d sim_billing_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
Expand Down Expand Up @@ -83,6 +96,19 @@ jobs:
ee/scim/lib/managed-membership.postgres.test.ts
lib/auth/sso/application/admit-sso-user.postgres.test.ts

- name: Verify cumulative billing timeout recovery in PostgreSQL
working-directory: apps/sim
env:
BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
run: bunx vitest run lib/billing/core/usage-log.postgres.test.ts

- name: Verify cumulative billing timeout recovery on PostgreSQL 16
if: matrix.provision == 'push'
working-directory: apps/sim
env:
BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5433/sim_billing_test
run: bunx vitest run lib/billing/core/usage-log.postgres.test.ts

- name: Verify SCIM and administration over real HTTP
working-directory: apps/sim
env:
Expand Down Expand Up @@ -153,14 +179,16 @@ jobs:
lib/table/rows/secret-provenance.postgres.test.ts
lib/memory/message-provenance.postgres.test.ts

- name: Verify Search progress and pagination in PostgreSQL
- name: Verify Search progress, pagination, and outbox scheduling in PostgreSQL
working-directory: apps/sim
env:
KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
run: >-
bunx vitest run --mode integration
lib/knowledge/__integration__/search-source-progress.integration.ts
lib/knowledge/__integration__/search-source-pagination.integration.ts
lib/core/outbox/service.integration.ts
lib/knowledge/__integration__/connector-upload.integration.ts

test-build:
name: Lint and Test
Expand Down
18 changes: 12 additions & 6 deletions apps/docs/content/docs/platform/enterprise/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { FAQ } from '@/components/ui/faq'
import { Image } from '@/components/ui/image'

Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0.
Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0, and an organization can use more than one identity provider at a time, one per verified domain.

---

Expand Down Expand Up @@ -36,7 +36,9 @@ Go to **Settings → Organization → Single sign-on**. The page has three tabs:
| **Domains** | DNS verification shared by SSO and SCIM |
| **Provisioning** | SCIM connection, tokens, rules, group mappings, and activity |

Use **Domains** to verify ownership, then return to **Sign-in** to configure your provider. Switching tabs preserves an unsaved sign-in draft while you stay on this page; use **Save** or **Update** to commit it. The selected tab is included in the URL, so it can be bookmarked or shared. On self-hosted deployments, Provisioning appears when SCIM is enabled.
Use **Domains** to verify ownership, then return to **Sign-in** to configure your provider. Switching tabs preserves an unsaved sign-in draft while you stay on this page; use **Save** or **Update** to commit it. The selected tab and provider are included in the URL, so they can be bookmarked or shared. On self-hosted deployments, Provisioning appears when SCIM is enabled.

An organization can run several identity providers at once, each serving a different verified domain: Okta for `eng.acme.com` and Microsoft Entra ID for `acme.com`, for example. **Sign-in** lists them; select **Add identity provider** for another, or a row to view, edit, or delete one. Sim routes each sign-in by the email domain, so a domain routes to exactly one provider.

### 2. Choose a protocol

Expand Down Expand Up @@ -99,7 +101,7 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the

## Editing and advanced configuration

For a saved connection, open **Sign-in** and select **Edit**. The Provider ID remains fixed. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes.
For a saved connection, open **Sign-in**, select the provider, and select **Edit**. The Provider ID remains fixed. **Delete** removes that sign-in path only: accounts and memberships it admitted stay, and people at its domain sign in another way until a provider serves the domain again. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes.

**Advanced options** contains OIDC scopes and optional authorization, token, and JWKS endpoint overrides. For SAML, it contains Audience, Callback URL override, signed-assertion requirements, NameID format, and optional IdP metadata XML. **Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default.

Expand Down Expand Up @@ -285,7 +287,7 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu

1. User goes to `sim.ai` and clicks **Sign in with SSO**
2. They enter their work email (e.g. `alice@company.com`)
3. Sim redirects them to your identity provider
3. Sim looks up the provider that serves `company.com` and redirects them to it
4. After authenticating, they are returned to Sim
5. If **First sign-in** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
Expand All @@ -311,7 +313,11 @@ SSO provisioning creates internal organization members but does not grant worksp
},
{
question: "What is the Domain field used for?",
answer: "The domain (e.g. company.com) is how Sim routes users to the right identity provider. When a user enters their email on the SSO sign-in page, Sim matches their email domain to a registered SSO provider and redirects them there."
answer: "The domain (e.g. company.com) is how Sim routes users to the right identity provider. When a user enters their email on the SSO sign-in page, Sim matches their email domain to the provider that serves it and redirects them there. Each verified domain routes to one provider, and an organization can serve different domains with different providers."
},
{
question: "Can we use more than one identity provider?",
answer: "Yes. Add one provider per verified domain: for example Okta for eng.acme.com and Microsoft Entra ID for acme.com. Sign-in routes by email domain, so a single domain cannot be split across two providers. SCIM provisioning stays organization-wide and works alongside any number of providers."
},
{
question: "Do I need to provide OIDC endpoints manually?",
Expand Down Expand Up @@ -343,7 +349,7 @@ SSO provisioning creates internal organization members but does not grant worksp
},
{
question: "How do I update or replace an existing SSO configuration?",
answer: "Open Settings → Organization → Single sign-on → Sign-in and select Edit. Change the fields and select Update. The Provider ID cannot be changed; replacing it requires deleting the provider and creating a new one."
answer: "Open Settings → Organization → Single sign-on → Sign-in, select the provider, and select Edit. Change the fields and select Update. The Provider ID cannot be changed; replacing it means deleting the provider and adding a new one."
}
]} />

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"fumadocs-mdx": "14.3.2",
"fumadocs-openapi": "10.8.1",
"fumadocs-ui": "16.8.5",
"next": "16.3.1",
"next": "16.3.4",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
Expand Down
50 changes: 50 additions & 0 deletions apps/sim/app/(landing)/components/cta/cta.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** @vitest-environment jsdom */
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { renderToStaticMarkup } from 'react-dom/server'
import sharp from 'sharp'
import { describe, expect, it, vi } from 'vitest'
import { Cta } from '@/app/(landing)/components/cta/cta'
import { FOOTER_ARTWORK } from '@/app/(landing)/components/cta/footer-artwork.generated'

vi.mock('@/app/(landing)/components/hero-cta', () => ({ HeroCta: () => null }))

describe('Footer artwork delivery', () => {
it('renders responsive static sources with lazy, decorative fallbacks and no optimizer requests', () => {
const host = document.createElement('div')
host.innerHTML = renderToStaticMarkup(<Cta />)
expect(host.innerHTML).not.toContain('/_next/image')
expect(host.querySelectorAll('picture')).toHaveLength(2)
expect(host.querySelector('picture')?.className).toContain('dark:hidden')
expect(host.querySelectorAll('picture')[1].className).toContain('dark:block')
for (const picture of host.querySelectorAll('picture')) {
expect(picture.closest('[aria-hidden="true"]')).not.toBeNull()
expect([...picture.querySelectorAll('source')].map((source) => source.type)).toEqual([
'image/avif',
'image/webp',
])
const image = picture.querySelector('img')!
expect(image.getAttribute('loading')).toBe('lazy')
expect(image.alt).toBe('')
expect(image.src).toContain('/landing/footer-artwork/')
expect(image.getAttribute('style')).toContain('data:image/svg+xml')
}
})

it('ships every advertised size with the matching dimensions and immutable content hash', async () => {
for (const artwork of Object.values(FOOTER_ARTWORK)) {
for (const srcSet of [artwork.avifSrcSet, artwork.webpSrcSet]) {
for (const entry of srcSet.split(', ')) {
const [src, descriptor] = entry.split(' ')
const width = Number.parseInt(descriptor, 10)
const buffer = await readFile(path.join(process.cwd(), 'public', src))
const metadata = await sharp(buffer).metadata()
expect(metadata.width).toBe(width)
expect(metadata.height).toBe((width * 9) / 16)
expect(src).toContain(createHash('sha256').update(buffer).digest('hex').slice(0, 12))
}
}
}
})
})
48 changes: 26 additions & 22 deletions apps/sim/app/(landing)/components/cta/cta.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { cn } from '@sim/emcn'
import Image from 'next/image'
import styles from '@/app/(landing)/components/cta/cta.module.css'
import { FOOTER_ARTWORK } from '@/app/(landing)/components/cta/footer-artwork.generated'
import { HeroCta } from '@/app/(landing)/components/hero-cta'
import {
HOME_TYPE,
LANDING_CONTENT_WIDTH,
LANDING_GUTTER,
} from '@/app/(landing)/components/landing-layout'
import ctaDark from '@/public/landing/cta-san-francisco-painted-dark.webp'
import ctaLight from '@/public/landing/cta-san-francisco-painted-light.webp'

/** One shared closing statement, with a deliberate line break between sentences. */
const CTA_HEADLINE = ['Every agent your company runs.', 'All in one place.'] as const
const ARTWORK_SIZES = '(max-width: 639px) 112vw, 100vw'

/**
* Painted pre-footer CTA for every marketing page, mounted once by LandingShell.
* Theme classes select the matching lazy-loaded painting without client state.
* Pre-encoded sources avoid request-time image optimization at every resolution.
* An embedded preview fills the scene while the full-resolution image loads.
* The sky mask keeps the copy clear and the lower edge fades into the footer.
*/
Expand Down Expand Up @@ -52,26 +53,29 @@ export function Cta() {
aria-hidden='true'
className='-mt-[clamp(96px,12.5vw,240px)] max-sm:-mt-6 pointer-events-none relative aspect-video w-full max-sm:aspect-[16/10]'
>
<Image
src={ctaLight}
alt=''
fill
placeholder='blur'
fetchPriority='high'
quality={90}
sizes='(max-width: 639px) 112vw, 100vw'
className={cn('object-cover object-center dark:hidden', styles.plate)}
/>
<Image
src={ctaDark}
alt=''
fill
placeholder='blur'
fetchPriority='high'
quality={90}
sizes='(max-width: 639px) 112vw, 100vw'
className={cn('hidden object-cover object-center dark:block', styles.plate)}
/>
{Object.entries(FOOTER_ARTWORK).map(([theme, artwork]) => (
<picture
key={theme}
className={cn(
'absolute inset-0',
theme === 'light' ? 'block dark:hidden' : 'hidden dark:block'
)}
>
<source type='image/avif' srcSet={artwork.avifSrcSet} sizes={ARTWORK_SIZES} />
<source type='image/webp' srcSet={artwork.webpSrcSet} sizes={ARTWORK_SIZES} />
<Image
src={artwork.src}
alt=''
fill
unoptimized
placeholder='blur'
blurDataURL={artwork.blurDataURL}
fetchPriority='high'
sizes={ARTWORK_SIZES}
className={cn('object-cover object-center', styles.plate)}
/>
</picture>
))}
</div>
</section>
)
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/app/(landing)/components/cta/footer-artwork.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Generated by scripts/generate-footer-artwork.ts. */
export const FOOTER_ARTWORK = {
light: {
avifSrcSet:
'/landing/footer-artwork/light-1200-f29e84fa39b5.avif 1200w, /landing/footer-artwork/light-1920-352cf7c570ad.avif 1920w, /landing/footer-artwork/light-3840-e889bb4bc039.avif 3840w',
webpSrcSet:
'/landing/footer-artwork/light-1200-8764ab359e07.webp 1200w, /landing/footer-artwork/light-1920-c9ec24660024.webp 1920w, /landing/footer-artwork/light-3840-6a6c3eab4b02.webp 3840w',
src: '/landing/footer-artwork/light-3840-6a6c3eab4b02.webp',
blurDataURL:
'data:image/webp;base64,UklGRjYAAABXRUJQVlA4ICoAAACwAQCdASoIAAQAAkA4JaQAAxaZftQAAP75igYWrDK4ciyu9bneK5BAAAA=',
},
dark: {
avifSrcSet:
'/landing/footer-artwork/dark-1200-a1f7345fe7f6.avif 1200w, /landing/footer-artwork/dark-1920-638fa2f584cb.avif 1920w, /landing/footer-artwork/dark-3840-c0bf8a67335d.avif 3840w',
webpSrcSet:
'/landing/footer-artwork/dark-1200-117c8741a843.webp 1200w, /landing/footer-artwork/dark-1920-2c586b41810a.webp 1920w, /landing/footer-artwork/dark-3840-3140a296c362.webp 3840w',
src: '/landing/footer-artwork/dark-3840-3140a296c362.webp',
blurDataURL:
'data:image/webp;base64,UklGRiwAAABXRUJQVlA4ICAAAABwAQCdASoIAAQAAkA4JaRtgAAIQAD+9MlFFCUE/BGgAA==',
},
} as const
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,16 @@ interface FeaturedCustomerCardProps {
story: FeaturedCustomerStory
active: boolean
emphasized: boolean
preload: boolean
}

/** Shared media and editorial caption treatment for each featured-customer slide. */
export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCustomerCardProps) {
export function FeaturedCustomerCard({
story,
active,
emphasized,
preload,
}: FeaturedCustomerCardProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const reducedMotion = usePrefersReducedMotion()
const isFilm = story.media.kind === 'video'
Expand All @@ -51,10 +57,9 @@ export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCust
}

/**
* The film streams only while the slide is on screen in a visible tab:
* `play()` defeats `preload='none'`, so calling it at mount would pull
* the whole file for a section well below the fold. Autoplay may still be
* blocked, in which case the poster remains the fallback.
* Preload prepares neighboring films when the carousel reaches the viewport.
* Playback is restricted to the active, visible slide in a visible tab.
* If autoplay is blocked, the poster remains the fallback.
*/
const canObserve = typeof IntersectionObserver !== 'undefined'
let inView = !canObserve
Expand Down Expand Up @@ -103,7 +108,7 @@ export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCust
loop
muted
playsInline
preload='none'
preload={preload && !reducedMotion ? 'auto' : 'none'}
src={story.media.src}
tabIndex={-1}
className='pointer-events-none absolute inset-0 size-full rounded-[inherit] object-cover motion-reduce:hidden'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
document.body.replaceChildren()
})

Expand Down Expand Up @@ -87,7 +88,7 @@ describe('FeaturedCustomer', () => {
)
const expVideo = expRealty.querySelector('video') as HTMLVideoElement
expect(expVideo.getAttribute('src')).toBe('/landing/customer-stories/exp-house-color-loop.mp4')
expect(expVideo.getAttribute('preload')).toBe('none')
expect(expVideo.getAttribute('preload')).toBe('auto')
expect(expVideo.muted).toBe(true)
expect(vi.mocked(video.play).mock.contexts).not.toContain(expVideo)
expect(
Expand Down Expand Up @@ -178,6 +179,55 @@ describe('FeaturedCustomer', () => {
})
})

it('prepares neighboring videos only once the carousel is visible without starting playback', () => {
const observers: Array<{
target: Element
intersect: (isIntersecting: boolean) => void
}> = []
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(private readonly callback: IntersectionObserverCallback) {}

observe(target: Element) {
observers.push({
target,
intersect: (isIntersecting) =>
this.callback(
[{ target, isIntersecting } as IntersectionObserverEntry],
{} as IntersectionObserver
),
})
}

disconnect() {}
}
)
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
act(() => root.render(<FeaturedCustomer />))

const videos = [...host.querySelectorAll('video')]
expect(videos.map((video) => video.preload)).toEqual(['none', 'none'])
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled()

const regionObserver = observers.find(({ target }) => target.tagName === 'DIV')!
act(() => regionObserver.intersect(true))
expect(videos.map((video) => video.preload)).toEqual(['auto', 'auto'])
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled()

const playbackObserver = observers.find(({ target }) => target === videos[0])!
act(() => playbackObserver.intersect(true))
expect(vi.mocked(HTMLMediaElement.prototype.play).mock.contexts).toEqual([videos[0]])

motionPreference.reduced = true
act(() => root.render(<FeaturedCustomer />))
expect(videos.map((video) => video.preload)).toEqual(['none', 'none'])

act(() => root.unmount())
})

it('keeps video paused with reduced motion and responds to preference changes', () => {
motionPreference.reduced = true
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
Expand Down
Loading
Loading