Skip to content

Commit 69b68a6

Browse files
committed
Merge branch 'wip/fix-server' into improvement/org-view
2 parents 72a8b96 + 9634d2e commit 69b68a6

16 files changed

Lines changed: 473 additions & 164 deletions

‎apps/sim/app/o/[organizationId]/home/home-redirect.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
2-
import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
2+
import type { OrganizationSurfaceGate } from '@/lib/organizations/surface'
33

44
type OrganizationRedirectContext = Pick<
5-
OrganizationSurfaceContext,
5+
OrganizationSurfaceGate,
66
'mothershipAvailable' | 'canBuild' | 'searchAccess'
77
>
88

‎apps/sim/app/o/[organizationId]/home/page.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1111
const mocks = vi.hoisted(() => ({ context: vi.fn(), prefetchHome: vi.fn() }))
1212

1313
vi.mock('next/navigation', () => nextNavigationMock)
14-
vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceContext: mocks.context }))
14+
vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceGate: mocks.context }))
1515
vi.mock('@/lib/mothership/chat/lifecycle', () => mothershipChatLifecycleMock)
1616
vi.mock('@/platform/lib/data/prefetch', () => ({}))
1717
vi.mock('@/platform/lib/data/prefetch-member-inventory', () => ({

‎apps/sim/app/o/[organizationId]/integrations/hydrated-integrations.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Suspense } from 'react'
22
import type { SessionPrincipal } from '@sim/auth/principal'
3-
import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
3+
import type { OrganizationSurfaceGate } from '@/lib/organizations/surface'
44
import { Hydrated } from '@/app/o/[organizationId]/_redesign/hydrated'
55
import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host'
66
import {
@@ -11,7 +11,7 @@ import {
1111
import { prefetchIntegrations } from '@/platform/lib/data/prefetch-member-inventory'
1212

1313
interface HydratedIntegrationsProps extends Omit<IntegrationsRouteProps, 'organizationId'> {
14-
surface: OrganizationSurfaceContext
14+
surface: OrganizationSurfaceGate
1515
principal: SessionPrincipal
1616
}
1717

‎apps/sim/app/o/[organizationId]/integrations/page.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/ne
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

55
const mocks = vi.hoisted(() => ({ context: vi.fn() }))
6-
vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceContext: mocks.context }))
6+
vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceGate: mocks.context }))
77
vi.mock('@/app/o/[organizationId]/integrations/hydrated-integrations', () => ({
88
HydratedIntegrations: () => null,
99
}))

‎apps/sim/app/o/[organizationId]/links/organization-sim-link-provider.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
'use client'
22

33
import type { ReactNode } from 'react'
4+
import dynamic from 'next/dynamic'
45
import { RedesignSurface } from '@/app/o/[organizationId]/_redesign/redesign-host'
56
import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
6-
import { LinkEditor } from '@/platform/app/(shell)/links/_components/links-page/link-editor'
7+
8+
/** Every organization route mounts this provider; only a new-link shortcut needs the form. */
9+
const LinkEditor = dynamic(() =>
10+
import('@/platform/app/(shell)/links/_components/links-page/link-editor').then(
11+
(module) => module.LinkEditor
12+
)
13+
)
714

815
interface OrganizationSimLinkProviderProps {
916
organizationId: string | null

‎apps/sim/app/o/[organizationId]/page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { notFound, redirect } from 'next/navigation'
22
import { getSession } from '@/lib/auth'
33
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
4-
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
4+
import { getOrganizationSurfaceGate } from '@/lib/organizations/surface'
55
import { canBrowsePeople } from '@/lib/people/feature-flag'
66
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
77
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
@@ -18,7 +18,7 @@ export default async function OrganizationPage({
1818
if (!session?.user?.id) {
1919
redirect(buildAuthCrossLink('/login', { callbackUrl: routes.root, isInviteFlow: false }))
2020
}
21-
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
21+
const context = await getOrganizationSurfaceGate(organizationId, session.user.id)
2222
if (!context) notFound()
2323
if (!getOrganizationHomeRedirect(context, organizationId)) redirect(routes.home)
2424
if (context.searchAccess.memberScoped) redirect(routes.search)

‎apps/sim/app/o/[organizationId]/viewer.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { SessionPrincipal } from '@sim/auth/principal'
22
import { notFound, redirect } from 'next/navigation'
33
import { getSession } from '@/lib/auth'
4-
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
4+
import { getOrganizationSurfaceGate } from '@/lib/organizations/surface'
55
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
66

77
/**
@@ -13,7 +13,7 @@ export async function requireOrganizationViewer(organizationId: string, callback
1313
if (!session?.user?.id) {
1414
redirect(buildAuthCrossLink('/login', { callbackUrl, isInviteFlow: false }))
1515
}
16-
const surface = await getOrganizationSurfaceContext(organizationId, session.user.id)
16+
const surface = await getOrganizationSurfaceGate(organizationId, session.user.id)
1717
if (!surface) notFound()
1818
const principal: SessionPrincipal = {
1919
kind: 'session',
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/** @vitest-environment jsdom */
2+
import {
3+
apiClientRequestMock,
4+
apiClientRequestMockFns,
5+
} from '@sim/testing/mocks/api-client-request.mock'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { renderDataHook, unmountDataHooks } from '@/platform/lib/render-hook.test-support'
8+
9+
vi.mock('@/lib/api/client/request', () => apiClientRequestMock)
10+
11+
import { useOpenBillingPortal } from '@/hooks/queries/subscription'
12+
13+
const mockRequestJson = apiClientRequestMockFns.mockRequestJson
14+
const BODY = { context: 'user' } as const
15+
const PORTAL_URL = 'https://billing.example.test/session'
16+
17+
function deferredPortal() {
18+
let resolve: (value: { url: string }) => void = () => {}
19+
let reject: (error: Error) => void = () => {}
20+
mockRequestJson.mockReturnValueOnce(
21+
new Promise((res, rej) => {
22+
resolve = res
23+
reject = rej
24+
})
25+
)
26+
return { resolve, reject }
27+
}
28+
29+
async function settle() {
30+
await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalled())
31+
}
32+
33+
/**
34+
* Failure mode: the tab opened before the request belongs to the viewer's click, so it has to reach
35+
* the portal or close whatever the page does in the meantime, including leave.
36+
*/
37+
describe('useOpenBillingPortal after the page that asked has gone', () => {
38+
const portalTab = { location: { href: '' }, close: vi.fn() }
39+
const openTab = vi.fn()
40+
41+
beforeEach(() => {
42+
mockRequestJson.mockReset()
43+
portalTab.location.href = ''
44+
portalTab.close.mockReset()
45+
openTab.mockReset().mockReturnValue(portalTab)
46+
vi.stubGlobal('open', openTab)
47+
})
48+
49+
afterEach(async () => {
50+
await unmountDataHooks()
51+
})
52+
53+
it('still sends the opened tab to the portal', async () => {
54+
const portal = deferredPortal()
55+
const hook = await renderDataHook(() => useOpenBillingPortal())
56+
hook.result.current.open(BODY, vi.fn())
57+
await settle()
58+
await hook.unmount()
59+
60+
portal.resolve({ url: PORTAL_URL })
61+
62+
await vi.waitFor(() => expect(portalTab.location.href).toBe(PORTAL_URL))
63+
})
64+
65+
it('still closes the opened tab and reports a failed request', async () => {
66+
const portal = deferredPortal()
67+
const onError = vi.fn()
68+
const hook = await renderDataHook(() => useOpenBillingPortal())
69+
hook.result.current.open(BODY, onError)
70+
await settle()
71+
await hook.unmount()
72+
73+
portal.reject(new Error('no portal'))
74+
75+
await vi.waitFor(() => expect(portalTab.close).toHaveBeenCalledOnce())
76+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'no portal' }))
77+
})
78+
79+
it('leaves the current page alone when a blocked tab outlives the page that asked', async () => {
80+
const location = { href: 'https://app.example.test/elsewhere' }
81+
vi.stubGlobal('location', location)
82+
openTab.mockReturnValue(null)
83+
const portal = deferredPortal()
84+
const hook = await renderDataHook(() => useOpenBillingPortal())
85+
hook.result.current.open(BODY, vi.fn())
86+
await settle()
87+
await hook.unmount()
88+
89+
portal.resolve({ url: PORTAL_URL })
90+
await vi.waitFor(() => expect(hook.queryClient.isMutating()).toBe(0))
91+
92+
expect(location.href).toBe('https://app.example.test/elsewhere')
93+
})
94+
95+
it('takes the current tab when one is blocked and the page is still here', async () => {
96+
const location = { href: 'https://app.example.test/billing' }
97+
vi.stubGlobal('location', location)
98+
openTab.mockReturnValue(null)
99+
const portal = deferredPortal()
100+
const hook = await renderDataHook(() => useOpenBillingPortal())
101+
hook.result.current.open(BODY, vi.fn())
102+
await settle()
103+
104+
portal.resolve({ url: PORTAL_URL })
105+
106+
await vi.waitFor(() => expect(location.href).toBe(PORTAL_URL))
107+
})
108+
})

‎apps/sim/hooks/queries/subscription.ts‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,30 +204,42 @@ export function useUpdateUsageLimit() {
204204

205205
type OpenBillingPortalParams = ContractBodyInput<typeof createBillingPortalContract>
206206

207+
interface OpenBillingPortalVariables {
208+
body: OpenBillingPortalParams
209+
portalWindow: Window | null
210+
onError: (error: Error) => void
211+
}
212+
207213
/**
208214
* Opens the billing portal in a new tab. The tab opens before the request so a popup blocker still
209-
* treats it as the viewer's click; if one blocks it anyway, the portal takes the current tab. A failed
210-
* request closes the tab and reports through `onError`.
215+
* treats it as the viewer's click, and it reaches the portal or closes even if the page that asked
216+
* has gone, so it is never stranded blank. If a blocker stops the tab, the portal takes the current
217+
* tab, but only while that page is still mounted: a viewer who moved on is not pulled away.
211218
*/
212219
export function useOpenBillingPortal() {
213220
const portal = useMutation({
214-
mutationFn: (body: OpenBillingPortalParams) =>
221+
mutationFn: ({ body }: OpenBillingPortalVariables) =>
215222
requestJson(createBillingPortalContract, { body }),
223+
onSuccess: (data, { portalWindow }) => {
224+
if (portalWindow) portalWindow.location.href = data.url
225+
},
226+
onError: (error, { portalWindow, onError }) => {
227+
portalWindow?.close()
228+
onError(error)
229+
},
216230
})
217231
return {
218232
isPending: portal.isPending,
219233
open: (body: OpenBillingPortalParams, onError: (error: Error) => void) => {
220234
const portalWindow = window.open('', '_blank')
221-
portal.mutate(body, {
222-
onSuccess: (data) => {
223-
if (portalWindow) portalWindow.location.href = data.url
224-
else window.location.href = data.url
225-
},
226-
onError: (error) => {
227-
portalWindow?.close()
228-
onError(error)
229-
},
230-
})
235+
portal.mutate(
236+
{ body, portalWindow, onError },
237+
{
238+
onSuccess: (data) => {
239+
if (!portalWindow) window.location.href = data.url
240+
},
241+
}
242+
)
231243
},
232244
}
233245
}

0 commit comments

Comments
 (0)