Skip to content

Commit 5552aaf

Browse files
authored
v0.8.29: slack search, landing optimizations, search optimizations, sso multiple providers
2 parents 6fc5a37 + e508098 commit 5552aaf

256 files changed

Lines changed: 118274 additions & 1492 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-build.yml

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ jobs:
3030
--health-interval 5s
3131
--health-timeout 5s
3232
--health-retries 10
33+
postgres-legacy:
34+
image: postgres:16-alpine
35+
env:
36+
POSTGRES_USER: postgres
37+
POSTGRES_PASSWORD: postgres
38+
POSTGRES_DB: sim_billing_test
39+
ports:
40+
- 5433:5432
41+
options: >-
42+
--health-cmd "pg_isready -U postgres -d sim_billing_test"
43+
--health-interval 5s
44+
--health-timeout 5s
45+
--health-retries 10
3346
env:
3447
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
3548
OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
@@ -83,6 +96,19 @@ jobs:
8396
ee/scim/lib/managed-membership.postgres.test.ts
8497
lib/auth/sso/application/admit-sso-user.postgres.test.ts
8598
99+
- name: Verify cumulative billing timeout recovery in PostgreSQL
100+
working-directory: apps/sim
101+
env:
102+
BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
103+
run: bunx vitest run lib/billing/core/usage-log.postgres.test.ts
104+
105+
- name: Verify cumulative billing timeout recovery on PostgreSQL 16
106+
if: matrix.provision == 'push'
107+
working-directory: apps/sim
108+
env:
109+
BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5433/sim_billing_test
110+
run: bunx vitest run lib/billing/core/usage-log.postgres.test.ts
111+
86112
- name: Verify SCIM and administration over real HTTP
87113
working-directory: apps/sim
88114
env:
@@ -153,14 +179,16 @@ jobs:
153179
lib/table/rows/secret-provenance.postgres.test.ts
154180
lib/memory/message-provenance.postgres.test.ts
155181
156-
- name: Verify Search progress and pagination in PostgreSQL
182+
- name: Verify Search progress, pagination, and outbox scheduling in PostgreSQL
157183
working-directory: apps/sim
158184
env:
159185
KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
160186
run: >-
161187
bunx vitest run --mode integration
162188
lib/knowledge/__integration__/search-source-progress.integration.ts
163189
lib/knowledge/__integration__/search-source-pagination.integration.ts
190+
lib/core/outbox/service.integration.ts
191+
lib/knowledge/__integration__/connector-upload.integration.ts
164192
165193
test-build:
166194
name: Lint and Test

apps/docs/content/docs/platform/enterprise/sso.mdx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
88
import { FAQ } from '@/components/ui/faq'
99
import { Image } from '@/components/ui/image'
1010

11-
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.
11+
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.
1212

1313
---
1414

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

39-
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.
39+
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.
40+
41+
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.
4042

4143
### 2. Choose a protocol
4244

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

100102
## Editing and advanced configuration
101103

102-
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.
104+
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.
103105

104106
**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.
105107

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

286288
1. User goes to `sim.ai` and clicks **Sign in with SSO**
287289
2. They enter their work email (e.g. `alice@company.com`)
288-
3. Sim redirects them to your identity provider
290+
3. Sim looks up the provider that serves `company.com` and redirects them to it
289291
4. After authenticating, they are returned to Sim
290292
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
291293
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
@@ -311,7 +313,11 @@ SSO provisioning creates internal organization members but does not grant worksp
311313
},
312314
{
313315
question: "What is the Domain field used for?",
314-
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."
316+
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."
317+
},
318+
{
319+
question: "Can we use more than one identity provider?",
320+
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."
315321
},
316322
{
317323
question: "Do I need to provide OIDC endpoints manually?",
@@ -343,7 +349,7 @@ SSO provisioning creates internal organization members but does not grant worksp
343349
},
344350
{
345351
question: "How do I update or replace an existing SSO configuration?",
346-
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."
352+
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."
347353
}
348354
]} />
349355

apps/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"fumadocs-mdx": "14.3.2",
2929
"fumadocs-openapi": "10.8.1",
3030
"fumadocs-ui": "16.8.5",
31-
"next": "16.3.1",
31+
"next": "16.3.4",
3232
"next-themes": "^0.4.6",
3333
"react": "19.2.4",
3434
"react-dom": "19.2.4",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/** @vitest-environment jsdom */
2+
import { createHash } from 'node:crypto'
3+
import { readFile } from 'node:fs/promises'
4+
import path from 'node:path'
5+
import { renderToStaticMarkup } from 'react-dom/server'
6+
import sharp from 'sharp'
7+
import { describe, expect, it, vi } from 'vitest'
8+
import { Cta } from '@/app/(landing)/components/cta/cta'
9+
import { FOOTER_ARTWORK } from '@/app/(landing)/components/cta/footer-artwork.generated'
10+
11+
vi.mock('@/app/(landing)/components/hero-cta', () => ({ HeroCta: () => null }))
12+
13+
describe('Footer artwork delivery', () => {
14+
it('renders responsive static sources with lazy, decorative fallbacks and no optimizer requests', () => {
15+
const host = document.createElement('div')
16+
host.innerHTML = renderToStaticMarkup(<Cta />)
17+
expect(host.innerHTML).not.toContain('/_next/image')
18+
expect(host.querySelectorAll('picture')).toHaveLength(2)
19+
expect(host.querySelector('picture')?.className).toContain('dark:hidden')
20+
expect(host.querySelectorAll('picture')[1].className).toContain('dark:block')
21+
for (const picture of host.querySelectorAll('picture')) {
22+
expect(picture.closest('[aria-hidden="true"]')).not.toBeNull()
23+
expect([...picture.querySelectorAll('source')].map((source) => source.type)).toEqual([
24+
'image/avif',
25+
'image/webp',
26+
])
27+
const image = picture.querySelector('img')!
28+
expect(image.getAttribute('loading')).toBe('lazy')
29+
expect(image.alt).toBe('')
30+
expect(image.src).toContain('/landing/footer-artwork/')
31+
expect(image.getAttribute('style')).toContain('data:image/svg+xml')
32+
}
33+
})
34+
35+
it('ships every advertised size with the matching dimensions and immutable content hash', async () => {
36+
for (const artwork of Object.values(FOOTER_ARTWORK)) {
37+
for (const srcSet of [artwork.avifSrcSet, artwork.webpSrcSet]) {
38+
for (const entry of srcSet.split(', ')) {
39+
const [src, descriptor] = entry.split(' ')
40+
const width = Number.parseInt(descriptor, 10)
41+
const buffer = await readFile(path.join(process.cwd(), 'public', src))
42+
const metadata = await sharp(buffer).metadata()
43+
expect(metadata.width).toBe(width)
44+
expect(metadata.height).toBe((width * 9) / 16)
45+
expect(src).toContain(createHash('sha256').update(buffer).digest('hex').slice(0, 12))
46+
}
47+
}
48+
}
49+
})
50+
})

apps/sim/app/(landing)/components/cta/cta.tsx

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
import { cn } from '@sim/emcn'
22
import Image from 'next/image'
33
import styles from '@/app/(landing)/components/cta/cta.module.css'
4+
import { FOOTER_ARTWORK } from '@/app/(landing)/components/cta/footer-artwork.generated'
45
import { HeroCta } from '@/app/(landing)/components/hero-cta'
56
import {
67
HOME_TYPE,
78
LANDING_CONTENT_WIDTH,
89
LANDING_GUTTER,
910
} from '@/app/(landing)/components/landing-layout'
10-
import ctaDark from '@/public/landing/cta-san-francisco-painted-dark.webp'
11-
import ctaLight from '@/public/landing/cta-san-francisco-painted-light.webp'
1211

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

1616
/**
1717
* Painted pre-footer CTA for every marketing page, mounted once by LandingShell.
1818
* Theme classes select the matching lazy-loaded painting without client state.
19+
* Pre-encoded sources avoid request-time image optimization at every resolution.
1920
* An embedded preview fills the scene while the full-resolution image loads.
2021
* The sky mask keeps the copy clear and the lower edge fades into the footer.
2122
*/
@@ -52,26 +53,29 @@ export function Cta() {
5253
aria-hidden='true'
5354
className='-mt-[clamp(96px,12.5vw,240px)] max-sm:-mt-6 pointer-events-none relative aspect-video w-full max-sm:aspect-[16/10]'
5455
>
55-
<Image
56-
src={ctaLight}
57-
alt=''
58-
fill
59-
placeholder='blur'
60-
fetchPriority='high'
61-
quality={90}
62-
sizes='(max-width: 639px) 112vw, 100vw'
63-
className={cn('object-cover object-center dark:hidden', styles.plate)}
64-
/>
65-
<Image
66-
src={ctaDark}
67-
alt=''
68-
fill
69-
placeholder='blur'
70-
fetchPriority='high'
71-
quality={90}
72-
sizes='(max-width: 639px) 112vw, 100vw'
73-
className={cn('hidden object-cover object-center dark:block', styles.plate)}
74-
/>
56+
{Object.entries(FOOTER_ARTWORK).map(([theme, artwork]) => (
57+
<picture
58+
key={theme}
59+
className={cn(
60+
'absolute inset-0',
61+
theme === 'light' ? 'block dark:hidden' : 'hidden dark:block'
62+
)}
63+
>
64+
<source type='image/avif' srcSet={artwork.avifSrcSet} sizes={ARTWORK_SIZES} />
65+
<source type='image/webp' srcSet={artwork.webpSrcSet} sizes={ARTWORK_SIZES} />
66+
<Image
67+
src={artwork.src}
68+
alt=''
69+
fill
70+
unoptimized
71+
placeholder='blur'
72+
blurDataURL={artwork.blurDataURL}
73+
fetchPriority='high'
74+
sizes={ARTWORK_SIZES}
75+
className={cn('object-cover object-center', styles.plate)}
76+
/>
77+
</picture>
78+
))}
7579
</div>
7680
</section>
7781
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/** Generated by scripts/generate-footer-artwork.ts. */
2+
export const FOOTER_ARTWORK = {
3+
light: {
4+
avifSrcSet:
5+
'/landing/footer-artwork/light-1200-f29e84fa39b5.avif 1200w, /landing/footer-artwork/light-1920-352cf7c570ad.avif 1920w, /landing/footer-artwork/light-3840-e889bb4bc039.avif 3840w',
6+
webpSrcSet:
7+
'/landing/footer-artwork/light-1200-8764ab359e07.webp 1200w, /landing/footer-artwork/light-1920-c9ec24660024.webp 1920w, /landing/footer-artwork/light-3840-6a6c3eab4b02.webp 3840w',
8+
src: '/landing/footer-artwork/light-3840-6a6c3eab4b02.webp',
9+
blurDataURL:
10+
'data:image/webp;base64,UklGRjYAAABXRUJQVlA4ICoAAACwAQCdASoIAAQAAkA4JaQAAxaZftQAAP75igYWrDK4ciyu9bneK5BAAAA=',
11+
},
12+
dark: {
13+
avifSrcSet:
14+
'/landing/footer-artwork/dark-1200-a1f7345fe7f6.avif 1200w, /landing/footer-artwork/dark-1920-638fa2f584cb.avif 1920w, /landing/footer-artwork/dark-3840-c0bf8a67335d.avif 3840w',
15+
webpSrcSet:
16+
'/landing/footer-artwork/dark-1200-117c8741a843.webp 1200w, /landing/footer-artwork/dark-1920-2c586b41810a.webp 1920w, /landing/footer-artwork/dark-3840-3140a296c362.webp 3840w',
17+
src: '/landing/footer-artwork/dark-3840-3140a296c362.webp',
18+
blurDataURL:
19+
'data:image/webp;base64,UklGRiwAAABXRUJQVlA4ICAAAABwAQCdASoIAAQAAkA4JaRtgAAIQAD+9MlFFCUE/BGgAA==',
20+
},
21+
} as const

apps/sim/app/(landing)/components/featured-customer/featured-customer-card.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,16 @@ interface FeaturedCustomerCardProps {
3434
story: FeaturedCustomerStory
3535
active: boolean
3636
emphasized: boolean
37+
preload: boolean
3738
}
3839

3940
/** Shared media and editorial caption treatment for each featured-customer slide. */
40-
export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCustomerCardProps) {
41+
export function FeaturedCustomerCard({
42+
story,
43+
active,
44+
emphasized,
45+
preload,
46+
}: FeaturedCustomerCardProps) {
4147
const videoRef = useRef<HTMLVideoElement>(null)
4248
const reducedMotion = usePrefersReducedMotion()
4349
const isFilm = story.media.kind === 'video'
@@ -51,10 +57,9 @@ export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCust
5157
}
5258

5359
/**
54-
* The film streams only while the slide is on screen in a visible tab:
55-
* `play()` defeats `preload='none'`, so calling it at mount would pull
56-
* the whole file for a section well below the fold. Autoplay may still be
57-
* blocked, in which case the poster remains the fallback.
60+
* Preload prepares neighboring films when the carousel reaches the viewport.
61+
* Playback is restricted to the active, visible slide in a visible tab.
62+
* If autoplay is blocked, the poster remains the fallback.
5863
*/
5964
const canObserve = typeof IntersectionObserver !== 'undefined'
6065
let inView = !canObserve
@@ -103,7 +108,7 @@ export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCust
103108
loop
104109
muted
105110
playsInline
106-
preload='none'
111+
preload={preload && !reducedMotion ? 'auto' : 'none'}
107112
src={story.media.src}
108113
tabIndex={-1}
109114
className='pointer-events-none absolute inset-0 size-full rounded-[inherit] object-cover motion-reduce:hidden'

apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ beforeEach(() => {
4343
afterEach(() => {
4444
vi.useRealTimers()
4545
vi.restoreAllMocks()
46+
vi.unstubAllGlobals()
4647
document.body.replaceChildren()
4748
})
4849

@@ -87,7 +88,7 @@ describe('FeaturedCustomer', () => {
8788
)
8889
const expVideo = expRealty.querySelector('video') as HTMLVideoElement
8990
expect(expVideo.getAttribute('src')).toBe('/landing/customer-stories/exp-house-color-loop.mp4')
90-
expect(expVideo.getAttribute('preload')).toBe('none')
91+
expect(expVideo.getAttribute('preload')).toBe('auto')
9192
expect(expVideo.muted).toBe(true)
9293
expect(vi.mocked(video.play).mock.contexts).not.toContain(expVideo)
9394
expect(
@@ -178,6 +179,55 @@ describe('FeaturedCustomer', () => {
178179
})
179180
})
180181

182+
it('prepares neighboring videos only once the carousel is visible without starting playback', () => {
183+
const observers: Array<{
184+
target: Element
185+
intersect: (isIntersecting: boolean) => void
186+
}> = []
187+
vi.stubGlobal(
188+
'IntersectionObserver',
189+
class {
190+
constructor(private readonly callback: IntersectionObserverCallback) {}
191+
192+
observe(target: Element) {
193+
observers.push({
194+
target,
195+
intersect: (isIntersecting) =>
196+
this.callback(
197+
[{ target, isIntersecting } as IntersectionObserverEntry],
198+
{} as IntersectionObserver
199+
),
200+
})
201+
}
202+
203+
disconnect() {}
204+
}
205+
)
206+
const host = document.createElement('div')
207+
document.body.appendChild(host)
208+
const root = createRoot(host)
209+
act(() => root.render(<FeaturedCustomer />))
210+
211+
const videos = [...host.querySelectorAll('video')]
212+
expect(videos.map((video) => video.preload)).toEqual(['none', 'none'])
213+
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled()
214+
215+
const regionObserver = observers.find(({ target }) => target.tagName === 'DIV')!
216+
act(() => regionObserver.intersect(true))
217+
expect(videos.map((video) => video.preload)).toEqual(['auto', 'auto'])
218+
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled()
219+
220+
const playbackObserver = observers.find(({ target }) => target === videos[0])!
221+
act(() => playbackObserver.intersect(true))
222+
expect(vi.mocked(HTMLMediaElement.prototype.play).mock.contexts).toEqual([videos[0]])
223+
224+
motionPreference.reduced = true
225+
act(() => root.render(<FeaturedCustomer />))
226+
expect(videos.map((video) => video.preload)).toEqual(['none', 'none'])
227+
228+
act(() => root.unmount())
229+
})
230+
181231
it('keeps video paused with reduced motion and responds to preference changes', () => {
182232
motionPreference.reduced = true
183233
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

0 commit comments

Comments
 (0)