Skip to content

Commit e0d84d7

Browse files
committed
fix(desktop): forward blocked members through the workspaces API
1 parent 4760fc0 commit e0d84d7

8 files changed

Lines changed: 414 additions & 422 deletions

File tree

‎apps/sim/app/api/workspaces/route.test.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import {
1616
} from '@sim/testing/mocks/workspaces-policy.mock'
1717
import { beforeEach, describe, expect, it, vi } from 'vitest'
1818

19-
const { mockCreateWorkspace } = vi.hoisted(() => ({
19+
const { mockCreateWorkspace, mockResolveRolledOutOrganizationId } = vi.hoisted(() => ({
2020
mockCreateWorkspace: vi.fn(),
21+
mockResolveRolledOutOrganizationId: vi.fn(),
2122
}))
2223

2324
vi.mock('@/lib/auth/session-response', () => ({
@@ -32,6 +33,10 @@ vi.mock('@/lib/workspaces/list', () => ({
3233
listWorkspacesForViewer: vi.fn(),
3334
}))
3435

36+
vi.mock('@/lib/navigation/resolve-app-entry', () => ({
37+
resolveRolledOutOrganizationId: mockResolveRolledOutOrganizationId,
38+
}))
39+
3540
vi.mock('@/lib/posthog/server', () => posthogServerMock)
3641

3742
vi.mock('@sim/audit', () => auditMock)
@@ -99,3 +104,42 @@ describe('POST /api/workspaces capability refusal', () => {
99104
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
100105
})
101106
})
107+
108+
describe('GET /api/workspaces blocked viewer forwarding', () => {
109+
const blockedList = {
110+
workspaces: [],
111+
lastActiveWorkspaceId: null,
112+
pinnedWorkspaceIds: [],
113+
creationPolicy: { canCreate: false },
114+
}
115+
const listRequest = () =>
116+
createMockRequest('GET', undefined, {}, 'http://localhost/api/workspaces')
117+
118+
beforeEach(() => {
119+
mockGetSession.mockResolvedValue({
120+
user: { id: 'user-1', name: 'A', email: 'a@example.com' },
121+
})
122+
vi.mocked(listWorkspacesForViewer).mockResolvedValue(
123+
blockedList as unknown as Awaited<ReturnType<typeof listWorkspacesForViewer>>
124+
)
125+
})
126+
127+
it('names the organization that can serve a viewer with nothing to pick', async () => {
128+
mockResolveRolledOutOrganizationId.mockResolvedValue('org-1')
129+
const response = await GET(listRequest())
130+
expect((await response.json()).forwardOrganizationId).toBe('org-1')
131+
})
132+
133+
it('leaves the blocked picker in place when no organization can serve them', async () => {
134+
mockResolveRolledOutOrganizationId.mockResolvedValue(null)
135+
const response = await GET(listRequest())
136+
expect(await response.json()).not.toHaveProperty('forwardOrganizationId')
137+
})
138+
139+
it('still returns the list when the organization lookup fails', async () => {
140+
mockResolveRolledOutOrganizationId.mockRejectedValue(new Error('down'))
141+
const response = await GET(listRequest())
142+
expect(response.status).toBe(200)
143+
expect(await response.json()).not.toHaveProperty('forwardOrganizationId')
144+
})
145+
})

‎apps/sim/app/api/workspaces/route.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { type WorkspaceMode, workflow } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5-
import { getPostgresErrorCode } from '@sim/utils/errors'
5+
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
66
import { and, eq, isNull } from 'drizzle-orm'
77
import { type NextRequest, NextResponse } from 'next/server'
88
import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
@@ -11,6 +11,7 @@ import { parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
1212
import { getActiveOrganizationId } from '@/lib/auth/session-response'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { resolveRolledOutOrganizationId } from '@/lib/navigation/resolve-app-entry'
1415
import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response'
1516
import { captureServerEvent } from '@/lib/posthog/server'
1617
import { createWorkspace } from '@/lib/workspaces/create'
@@ -24,6 +25,20 @@ import {
2425

2526
const logger = createLogger('Workspaces')
2627

28+
/** A failed lookup leaves the viewer on the blocked picker rather than failing the list. */
29+
async function resolveForwardOrganizationId(session: {
30+
user: { id: string }
31+
}): Promise<string | undefined> {
32+
try {
33+
return (await resolveRolledOutOrganizationId(session)) ?? undefined
34+
} catch (error) {
35+
logger.warn('Could not resolve an organization to forward a blocked viewer to', {
36+
error: getErrorMessage(error),
37+
})
38+
return undefined
39+
}
40+
}
41+
2742
// Get all workspaces for the current user
2843
export const GET = withRouteHandler(async (request: Request) => {
2944
const session = await getSession()
@@ -58,6 +73,7 @@ export const GET = withRouteHandler(async (request: Request) => {
5873
lastActiveWorkspaceId,
5974
pinnedWorkspaceIds,
6075
creationPolicy,
76+
forwardOrganizationId: await resolveForwardOrganizationId(session),
6177
})
6278
}
6379

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,78 @@
11
/**
2-
* @vitest-environment node
2+
* @vitest-environment jsdom
33
*/
4-
import { authMockFns } from '@sim/testing/mocks/auth.mock'
5-
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
6-
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { act } from 'react'
5+
import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
6+
import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
7+
import { createRoot, type Root } from 'react-dom/client'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
79

8-
const { mockResolveWorkspacelessMemberPath } = vi.hoisted(() => ({
9-
mockResolveWorkspacelessMemberPath: vi.fn(),
10+
const { mockUseWorkspacesWithMetadata } = vi.hoisted(() => ({
11+
mockUseWorkspacesWithMetadata: vi.fn(),
1012
}))
1113

1214
vi.mock('next/navigation', () => nextNavigationMock)
13-
14-
vi.mock('@/lib/navigation/resolve-app-entry', () => ({
15-
resolveWorkspacelessMemberPath: mockResolveWorkspacelessMemberPath,
15+
vi.mock('@/lib/auth/client', () => authClientMock)
16+
vi.mock('@/hooks/queries/workspace', () => ({
17+
useWorkspacesWithMetadata: mockUseWorkspacesWithMetadata,
1618
}))
1719

18-
vi.mock('@/app/workspace/workspace-entry', () => ({ WorkspaceEntry: () => null }))
19-
2020
import WorkspacePage from '@/app/workspace/page'
2121

22-
const mockGetSession = authMockFns.mockGetSession
23-
const session = { user: { id: 'viewer' } }
22+
const mockReplace = nextNavigationMockFns.router.replace
23+
24+
describe('WorkspacePage for a viewer with nothing to pick', () => {
25+
let container: HTMLDivElement
26+
let root: Root
2427

25-
function visit(search: { redirect?: string } = {}) {
26-
return WorkspacePage({ searchParams: Promise.resolve(search) })
27-
}
28+
function visit(search: string, list: { forwardOrganizationId?: string }) {
29+
window.history.pushState({}, '', `/workspace${search}`)
30+
mockUseWorkspacesWithMetadata.mockReturnValue({
31+
data: {
32+
workspaces: [],
33+
lastActiveWorkspaceId: null,
34+
pinnedWorkspaceIds: [],
35+
creationPolicy: { canCreate: false, workspaceMode: 'organization' },
36+
...list,
37+
},
38+
isLoading: false,
39+
error: null,
40+
})
41+
act(() => root.render(<WorkspacePage />))
42+
}
2843

29-
describe('WorkspacePage', () => {
3044
beforeEach(() => {
31-
mockGetSession.mockResolvedValue(session)
32-
mockResolveWorkspacelessMemberPath.mockResolvedValue(null)
45+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
46+
mockReplace.mockClear()
47+
authClientMockFns.mockUseSession.mockReturnValue({
48+
data: { user: { id: 'viewer' } },
49+
isPending: false,
50+
error: null,
51+
})
52+
container = document.createElement('div')
53+
document.body.appendChild(container)
54+
root = createRoot(container)
3355
})
3456

35-
it('forwards a workspaceless member before the picker renders', async () => {
36-
mockResolveWorkspacelessMemberPath.mockResolvedValue('/o/org-1/home')
37-
await expect(visit()).rejects.toThrow('NEXT_REDIRECT:/o/org-1/home')
38-
expect(mockResolveWorkspacelessMemberPath).toHaveBeenCalledWith(session, 'home')
57+
afterEach(() => {
58+
act(() => root.unmount())
59+
container.remove()
3960
})
4061

41-
it('carries a settings request through to the destination', async () => {
42-
mockResolveWorkspacelessMemberPath.mockResolvedValue('/o/org-1/settings/general')
43-
await expect(visit({ redirect: 'settings' })).rejects.toThrow(
44-
'NEXT_REDIRECT:/o/org-1/settings/general'
45-
)
46-
expect(mockResolveWorkspacelessMemberPath).toHaveBeenCalledWith(session, 'settings')
62+
it('forwards to the organization without showing the blocked card', () => {
63+
visit('', { forwardOrganizationId: 'org-1' })
64+
expect(mockReplace).toHaveBeenCalledExactlyOnceWith('/o/org-1/home')
65+
expect(container.querySelector('h1')).toBeNull()
4766
})
4867

49-
it('leaves a stale session on the recovery surface', async () => {
50-
mockGetSession.mockResolvedValue(null)
51-
await expect(visit()).resolves.toBeTruthy()
52-
expect(mockResolveWorkspacelessMemberPath).not.toHaveBeenCalled()
68+
it('carries a settings request to the organization settings', () => {
69+
visit('?redirect=settings', { forwardOrganizationId: 'org-1' })
70+
expect(mockReplace).toHaveBeenCalledExactlyOnceWith('/o/org-1/settings/general')
5371
})
5472

55-
it.each([
56-
['session', () => mockGetSession.mockRejectedValue(new Error('down'))],
57-
['destination', () => mockResolveWorkspacelessMemberPath.mockRejectedValue(new Error('down'))],
58-
])('keeps the recovery surface when the %s lookup fails', async (_label, fail) => {
59-
fail()
60-
await expect(visit()).resolves.toBeTruthy()
73+
it('stays on the blocked card when no organization can serve them', () => {
74+
visit('', {})
75+
expect(mockReplace).not.toHaveBeenCalled()
76+
expect(container.querySelector('h1')).not.toBeNull()
6177
})
6278
})

0 commit comments

Comments
 (0)