Skip to content

Commit 575bc6b

Browse files
authored
fix(org): show impersonation session controls (#7675)
1 parent 635f613 commit 575bc6b

3 files changed

Lines changed: 153 additions & 6 deletions

File tree

apps/sim/app/o/[organizationId]/layout.test.tsx

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,26 @@ import { authMockFns } from '@sim/testing'
77
import { renderToStaticMarkup } from 'react-dom/server'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99

10-
const { mockGetOrganizationSurfaceContext, mockWorkspaceChrome, mockPrefetchUserProfile } =
11-
vi.hoisted(() => ({
12-
mockGetOrganizationSurfaceContext: vi.fn(),
13-
mockWorkspaceChrome: vi.fn(({ children }: { children: ReactNode }) => children),
14-
mockPrefetchUserProfile: vi.fn(async () => undefined),
15-
}))
10+
const {
11+
mockGetOrganizationSurfaceContext,
12+
mockWorkspaceChrome,
13+
mockPrefetchUserProfile,
14+
mockUseSession,
15+
} = vi.hoisted(() => ({
16+
mockGetOrganizationSurfaceContext: vi.fn(),
17+
mockWorkspaceChrome: vi.fn(({ children }: { children: ReactNode }) => children),
18+
mockPrefetchUserProfile: vi.fn(async () => undefined),
19+
mockUseSession: vi.fn(),
20+
}))
21+
22+
vi.mock('@/lib/auth/auth-client', () => ({ useSession: mockUseSession }))
23+
vi.mock('@/hooks/queries/admin-users', () => ({
24+
useStopImpersonating: () => ({ mutate: vi.fn(), isPending: false }),
25+
}))
26+
vi.mock('@/stores', () => ({ clearUserData: vi.fn() }))
27+
vi.mock('@/lib/auth/stale-session-recovery', () => ({
28+
recoverFromStaleSession: vi.fn(),
29+
}))
1630

1731
vi.mock('@tanstack/react-query', () => ({
1832
HydrationBoundary: ({ children }: { children: ReactNode }) => children,
@@ -67,6 +81,7 @@ describe('OrganizationLayout', () => {
6781
beforeEach(() => {
6882
vi.clearAllMocks()
6983
mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' } })
84+
mockUseSession.mockReturnValue({ data: { user: { id: 'viewer-1' } }, isPending: false })
7085
})
7186

7287
it('returns signed-out visitors to the organization entry after sign-in', async () => {
@@ -93,12 +108,58 @@ describe('OrganizationLayout', () => {
93108
expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1')
94109
expect(mockPrefetchUserProfile).toHaveBeenCalledWith({}, 'viewer-1')
95110
expect(html).toContain('Organization child')
111+
expect(html).not.toContain('Stop impersonating')
96112
expect(mockWorkspaceChrome).toHaveBeenCalledWith(
97113
expect.objectContaining({ initialSidebarCollapsed: true }),
98114
undefined
99115
)
100116
})
101117

118+
it('shows the shared impersonation banner above organization content', async () => {
119+
const session = {
120+
user: { id: 'viewer-1', name: 'QA Member', email: 'member@example.com' },
121+
session: { impersonatedBy: 'platform-admin' },
122+
}
123+
mockGetSession.mockResolvedValue(session)
124+
mockUseSession.mockReturnValue({ data: session, isPending: false })
125+
mockGetOrganizationSurfaceContext.mockResolvedValue(SURFACE_CONTEXT)
126+
127+
const html = renderToStaticMarkup(
128+
await OrganizationLayout({
129+
children: <div>Organization child</div>,
130+
params: Promise.resolve({ organizationId: 'org-1' }),
131+
})
132+
)
133+
134+
expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1')
135+
expect(html).toContain('Impersonating QA Member (member@example.com)')
136+
expect(html).toContain('Stop impersonating')
137+
expect(html.indexOf('Stop impersonating')).toBeLessThan(html.indexOf('Organization child'))
138+
})
139+
140+
it('does not use the impersonating admin to enter an organization outside the rollout', async () => {
141+
mockGetSession.mockResolvedValue({
142+
user: { id: 'customer-member' },
143+
session: { impersonatedBy: 'platform-admin' },
144+
})
145+
mockGetOrganizationSurfaceContext.mockResolvedValue({
146+
...SURFACE_CONTEXT,
147+
searchAccess: { memberScoped: false, sourceMirrored: false },
148+
})
149+
150+
await expect(
151+
OrganizationLayout({
152+
children: <div>Organization child</div>,
153+
params: Promise.resolve({ organizationId: 'customer-org' }),
154+
})
155+
).rejects.toThrow('redirect:/workspace?redirect=settings')
156+
expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith(
157+
'customer-org',
158+
'customer-member'
159+
)
160+
expect(mockWorkspaceChrome).not.toHaveBeenCalled()
161+
})
162+
102163
it('renders an explicit denial for a non-member without the surface', async () => {
103164
mockGetOrganizationSurfaceContext.mockResolvedValue(null)
104165

apps/sim/app/o/[organizationId]/layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1010
import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
1111
import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar'
1212
import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
13+
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
14+
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
1315
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
1416
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
1517

@@ -58,6 +60,8 @@ export default async function OrganizationLayout({
5860
<OrganizationProvider context={context}>
5961
<GlobalCommandsProvider>
6062
<div className='workspace-root flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
63+
<ImpersonationBanner />
64+
<SessionExpired />
6165
<WorkspaceChrome
6266
sidebar={<OrganizationSidebar />}
6367
initialSidebarCollapsed={initialSidebarCollapsed}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/** @vitest-environment node */
2+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
3+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import type { FeatureFlagsConfig } from '@/lib/core/config/feature-flags'
5+
6+
const mocks = vi.hoisted(() => ({
7+
appConfig: vi.fn(),
8+
landing: vi.fn(),
9+
platformAdmin: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: mocks.appConfig }))
13+
vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: mocks.platformAdmin }))
14+
vi.mock('@/lib/organizations/surface', () => ({ resolveOrganizationLanding: mocks.landing }))
15+
vi.mock('@/lib/billing/core/subscription', () => ({
16+
isOrganizationOnEnterprisePlan: vi.fn().mockResolvedValue(true),
17+
getOrganizationSubscriptionUsable: vi
18+
.fn()
19+
.mockResolvedValue({ plan: 'enterprise', status: 'active' }),
20+
}))
21+
vi.mock('@/lib/billing/core/access', () => ({
22+
isOrganizationBillingBlocked: vi.fn().mockResolvedValue(false),
23+
}))
24+
25+
import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability'
26+
import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry'
27+
28+
afterAll(resetEnvFlagsMock)
29+
30+
describe('organization rollout during impersonation', () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
setEnvFlags({ isAppConfigEnabled: true, isHosted: true })
34+
mocks.landing.mockImplementation(async (userId: string) =>
35+
userId === 'platform-admin' ? 'admin-org' : 'customer-org'
36+
)
37+
mocks.platformAdmin.mockImplementation(async (userId: string) => userId === 'platform-admin')
38+
})
39+
40+
it.each([
41+
{ knowledge: false, groups: false },
42+
{ knowledge: false, groups: true },
43+
{ knowledge: true, groups: false },
44+
{ knowledge: true, groups: true },
45+
])('uses the customer organization for both gates: %j', async ({ knowledge, groups }) => {
46+
const flags: FeatureFlagsConfig = {
47+
'knowledge-member-access': {
48+
orgIds: ['admin-org', ...(knowledge ? ['customer-org'] : [])],
49+
userIds: ['platform-admin'],
50+
adminEnabled: true,
51+
},
52+
'credential-groups': {
53+
orgIds: ['admin-org', ...(groups ? ['customer-org'] : [])],
54+
userIds: ['platform-admin'],
55+
adminEnabled: true,
56+
},
57+
}
58+
mocks.appConfig.mockResolvedValue(flags)
59+
60+
await expect(resolveAppEntryPath({ user: { id: 'platform-admin' } })).resolves.toBe(
61+
'/o/admin-org/home'
62+
)
63+
64+
const impersonatedSession = {
65+
user: { id: 'customer-member' },
66+
session: { impersonatedBy: 'platform-admin', activeOrganizationId: 'customer-org' },
67+
}
68+
await expect(resolveAppEntryPath(impersonatedSession)).resolves.toBe(
69+
knowledge && groups ? '/o/customer-org/home' : '/workspace?redirect=settings'
70+
)
71+
expect(mocks.landing).toHaveBeenLastCalledWith('customer-member', 'customer-org')
72+
expect(mocks.platformAdmin).not.toHaveBeenCalled()
73+
74+
if (knowledge && groups) {
75+
await expect(requireOrganizationSearchAvailable('customer-org')).resolves.toBeUndefined()
76+
} else {
77+
await expect(requireOrganizationSearchAvailable('customer-org')).rejects.toMatchObject({
78+
code: 'forbidden',
79+
})
80+
}
81+
})
82+
})

0 commit comments

Comments
 (0)