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
92 changes: 73 additions & 19 deletions apps/sim/app/access-requests/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,29 @@
import { authMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { redirect, organizationContext } = vi.hoisted(() => ({
const { redirect, organizationContext, organizationAccess } = vi.hoisted(() => ({
redirect: vi.fn(),
organizationContext: vi.fn(),
organizationAccess: vi.fn(),
}))
vi.mock('next/navigation', () => ({ redirect }))
vi.mock('@/lib/organizations/surface', () => ({
getOrganizationSurfaceContext: organizationContext,
}))
vi.mock('@/ee/access-requests/components/my-access-requests', () => ({
MyAccessRequests: () => null,
vi.mock('@/ee/access-requests/components/access-requests-settings', () => ({
AccessRequestsSettings: () => null,
}))
vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({
OrganizationAccessRequests: () => null,
vi.mock('@/lib/organizations/settings-access', () => ({
getOrganizationSettingsAccess: organizationAccess,
}))

import AccessRequestsPage from '@/app/access-requests/page'
import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests'
import { AccessRequestsSettings } from '@/ee/access-requests/components/access-requests-settings'

describe('access request sign-in redirect', () => {
beforeEach(() => {
vi.clearAllMocks()
organizationAccess.mockResolvedValue({ isAdmin: false, isMember: true })
authMockFns.mockGetSession.mockResolvedValue(null)
redirect.mockImplementation(() => {
throw new Error('Redirect')
Expand Down Expand Up @@ -92,7 +94,7 @@ describe('access request sign-in redirect', () => {
).rejects.toThrow('Redirect')
expect(organizationContext).toHaveBeenCalledWith('organization', 'viewer')
const destination = new URL(redirect.mock.calls[0][0], 'https://example.com')
expect(destination.pathname).toBe('/o/organization/access-requests')
expect(destination.pathname).toBe('/o/organization/settings/requests')
expect(Object.fromEntries(destination.searchParams)).toEqual({
view: 'catalog',
requestId: 'request/a',
Expand All @@ -107,38 +109,90 @@ describe('access request sign-in redirect', () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
organizationContext.mockResolvedValue(context)
await AccessRequestsPage({
searchParams: Promise.resolve({ organizationId: 'organization' }),
searchParams: Promise.resolve({ organizationId: 'organization', view: 'requests' }),
})
expect(redirect).not.toHaveBeenCalled()
}
)

it('keeps authenticated administrator email links on the review surface', async () => {
it('normalizes saved administrator email links without losing review state', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
await AccessRequestsPage({
searchParams: Promise.resolve({
organizationId: 'organization',
view: 'admin',
requestId: 'request',
}),
await expect(
AccessRequestsPage({
searchParams: Promise.resolve({
organizationId: 'organization',
view: 'admin',
requestId: 'request',
'request-status': 'declined',
}),
})
).rejects.toThrow('Redirect')
const destination = new URL(redirect.mock.calls[0][0], 'https://example.com')
expect(Object.fromEntries(destination.searchParams)).toEqual({
organizationId: 'organization',
view: 'review',
'request-id': 'request',
'request-status': 'declined',
})
expect(redirect).not.toHaveBeenCalled()
expect(organizationContext).not.toHaveBeenCalled()
})

it('routes reviewer links into organization settings when the shell is available', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
organizationContext.mockResolvedValue({ searchAccess: { memberScoped: true } })
await expect(
AccessRequestsPage({
searchParams: Promise.resolve({
organizationId: 'organization',
view: 'review',
'request-id': 'request',
}),
})
).rejects.toThrow('Redirect')
expect(redirect).toHaveBeenCalledWith(
'/o/organization/settings/requests?request-id=request&view=review'
)
})

it.each([undefined, 'invalid', ['requests', 'review']])(
'keeps invalid or old requester links on My requests: %j',
async (view) => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
await expect(
AccessRequestsPage({
searchParams: Promise.resolve({
organizationId: 'organization',
requestId: 'request',
view,
}),
})
).rejects.toThrow('Redirect')
const destination = new URL(redirect.mock.calls[0][0], 'https://example.com')
expect(destination.searchParams.get('view')).toBe('requests')
expect(destination.searchParams.get('requestId')).toBe('request')
}
)

it('renders the standalone requester when the optional organization navigation lookup fails', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
organizationContext.mockRejectedValue(new Error('Organization context unavailable'))

const page = await AccessRequestsPage({
searchParams: Promise.resolve({ organizationId: 'organization', requestId: 'request' }),
searchParams: Promise.resolve({
organizationId: 'organization',
view: 'requests',
requestId: 'request',
}),
})

expect(redirect).not.toHaveBeenCalled()
expect(page.props.children.type).toBe(MyAccessRequests)
expect(page.props.children.props).toEqual({
expect(page.props.children.props.children.props.children.props.children.type).toBe(
AccessRequestsSettings
)
expect(page.props.children.props.children.props.children.props.children.props).toEqual({
scope: { kind: 'organization', organizationId: 'organization' },
standalone: true,
reviewOrganizationId: undefined,
})
})
})
84 changes: 42 additions & 42 deletions apps/sim/app/access-requests/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@ import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { createSearchParamsCache, createSerializer } from 'nuqs/server'
import { EmptyState } from '@/components/empty-state/empty-state'
import { ORGANIZATION_SETTINGS_ITEMS, toSettingsHeaderMeta } from '@/components/settings/navigation'
import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
import { SettingsSectionProvider } from '@/components/settings/settings-panel'
import { getSession } from '@/lib/auth'
import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths'
import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading'
import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests'
import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests'
import {
accessRequestEntrySearchParams,
accessRequestSearchParams,
} from '@/ee/access-requests/components/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { AccessRequestsSettings } from '@/ee/access-requests/components/access-requests-settings'
import { accessRequestEntrySearchParams } from '@/ee/access-requests/components/search-params'
import { getLegacyAccessRequestsSettingsQuery } from '@/ee/access-requests/lib/navigation'

export const metadata: Metadata = {
title: 'Access requests',
Expand All @@ -28,7 +29,6 @@ interface AccessRequestsPageProps {

const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams)
const serializeEntrySearchParams = createSerializer(accessRequestEntrySearchParams)
const serializeRequesterSearchParams = createSerializer(accessRequestSearchParams)
const logger = createLogger('AccessRequestsPage')

/** Session-only entry so access requests remain reachable outside the organization Search rollout. */
Expand All @@ -48,50 +48,50 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest
return (
<EmptyState
title='Choose an organization'
description='Open My access requests from your profile menu in an organization or workspace.'
description='Open Settings → Requests in an organization or workspace.'
action={<ChipLink href={APP_ENTRY_PATH}>Back to Sim</ChipLink>}
/>
)
}

if (params.view !== 'admin') {
const context = await getOrganizationSurfaceContext(
params.organizationId,
session.user.id
).catch((error) => {
const query = getLegacyAccessRequestsSettingsQuery(rawParams)
if (params.view === 'admin' || params.view !== rawParams.view) {
const normalized = new URLSearchParams(query)
normalized.set('organizationId', params.organizationId)
redirect(`/access-requests?${normalized}`)
}

const context = await getOrganizationSurfaceContext(params.organizationId, session.user.id).catch(
(error) => {
logger.warn('Unable to resolve organization navigation for access requests', { error })
return null
})
if (context?.searchAccess.memberScoped) {
redirect(
serializeRequesterSearchParams(organizationRoutes(params.organizationId).accessRequests, {
view: params.view,
search: params.search,
page: params.page,
requestId: params.requestId,
})
)
}
)
if (context?.searchAccess.memberScoped) {
redirect(organizationRoutes(params.organizationId).settingsSection('requests') + query)
}

const access = await getOrganizationSettingsAccess(params.organizationId, session.user.id)
const meta = ORGANIZATION_SETTINGS_ITEMS.find((item) => item.id === 'requests')!
return (
<Suspense fallback={<AccessRequestsLoading />}>
{params.view === 'admin' ? (
<main className='flex-1 px-6 py-8'>
<div className='mx-auto flex max-w-3xl flex-col gap-6'>
<div className='flex items-center justify-between gap-4'>
<h1 className='text-[var(--text-primary)] text-lg'>Access requests</h1>
<ChipLink href={APP_ENTRY_PATH}>Back to Sim</ChipLink>
</div>
<OrganizationAccessRequests organizationId={params.organizationId} standalone />
</div>
</main>
) : (
<MyAccessRequests
scope={{ kind: 'organization', organizationId: params.organizationId }}
standalone
/>
)}
</Suspense>
<SettingsHeaderProvider>
<SettingsHeaderShell meta={toSettingsHeaderMeta(meta)}>
<SettingsSectionProvider section='requests' meta={meta}>
<Suspense
fallback={
<SettingsEmptyState variant='inline'>
<span role='status'>Loading requests...</span>
</SettingsEmptyState>
}
>
<AccessRequestsSettings
scope={{ kind: 'organization', organizationId: params.organizationId }}
reviewOrganizationId={access.isAdmin ? params.organizationId : undefined}
standalone
/>
</Suspense>
</SettingsSectionProvider>
</SettingsHeaderShell>
</SettingsHeaderProvider>
)
}
28 changes: 14 additions & 14 deletions apps/sim/app/o/[organizationId]/access-requests/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading'
import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests'
import { redirect } from 'next/navigation'
import {
getAccessRequestsSettingsHref,
getLegacyAccessRequestsSettingsQuery,
} from '@/ee/access-requests/lib/navigation'

export const metadata: Metadata = { title: 'My access requests' }

interface OrganizationAccessRequestsPageProps {
interface AccessRequestsPageProps {
params: Promise<{ organizationId: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}

export default async function OrganizationAccessRequestsPage({
export default async function AccessRequestsPage({
params,
}: OrganizationAccessRequestsPageProps) {
const { organizationId } = await params
return (
<Suspense fallback={<AccessRequestsLoading />}>
<MyAccessRequests scope={{ kind: 'organization', organizationId }} />
</Suspense>
searchParams,
}: AccessRequestsPageProps) {
const [{ organizationId }, query] = await Promise.all([params, searchParams])
redirect(
getAccessRequestsSettingsHref({ kind: 'organization', organizationId }) +
getLegacyAccessRequestsSettingsQuery(query)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,9 @@ describe('OrganizationFooter settings navigation', () => {
await openProfileMenu()
expect(
[...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent)
).toEqual(['Settings', 'My access requests', 'Sign out'])
).toEqual(['Settings', 'Sign out'])
expect(document.querySelector('[role="separator"]')).toBeNull()
const requests = document.querySelector<HTMLAnchorElement>('a[href="/o/org-1/access-requests"]')
expect(requests).not.toBeNull()
await act(async () => requests!.click())
expect(mockPush).toHaveBeenCalledWith('/o/org-1/access-requests')
expect(document.body.textContent).not.toContain('My access requests')
})

it('navigates immediately when settings are clean', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client'

import type { ComponentProps } from 'react'
import { ListChecks } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import { organizationRoutes } from '@/lib/navigation/paths'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
Expand All @@ -17,21 +16,13 @@ export function OrganizationFooter(props: OrganizationFooterProps) {
const { organization } = useOrganizationContext()
const router = useRouter()
const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general')
const accessRequestsHref = organizationRoutes(organization.id).accessRequests

return (
<SidebarFooter
{...props}
accountSettingsHref={accountSettingsHref}
onOpenAccountSettings={() => router.push(accountSettingsHref)}
navigationLinks={[
{
label: 'My access requests',
icon: ListChecks,
href: accessRequestsHref,
onNavigate: () => router.push(accessRequestsHref),
},
]}
navigationLinks={[]}
/>
)
}
13 changes: 9 additions & 4 deletions apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ const Billing = dynamic(() =>
const AccessControl = dynamic(() =>
import('@/ee/access-control/components/access-control').then((m) => m.AccessControl)
)
const OrganizationAccessRequests = dynamic(() =>
import('@/ee/access-requests/components/organization-access-requests').then(
(m) => m.OrganizationAccessRequests
const AccessRequestsSettings = dynamic(() =>
import('@/ee/access-requests/components/access-requests-settings').then(
(m) => m.AccessRequestsSettings
)
)
const AuditLogs = dynamic(() =>
Expand Down Expand Up @@ -111,7 +111,12 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
requestsHref={getOrganizationSettingsHref(organizationId, 'requests')}
/>
)}
{section === 'requests' && <OrganizationAccessRequests organizationId={organizationId} />}
{section === 'requests' && (
<AccessRequestsSettings
scope={{ kind: 'organization', organizationId }}
reviewOrganizationId={viewer.isAdmin ? organizationId : undefined}
/>
)}
{section === 'audit-logs' && <AuditLogs organizationId={organizationId} />}
{section === 'usage' && (
<UsageMonitoring
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/o/[organizationId]/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('organization settings navigation', () => {
it('exposes MCP setup and the read-only roster to an ordinary organization member', () => {
expect(
organizationSettingsNavigation(false, enterprise, available).map(({ id }) => id)
).toEqual(['members', 'recently-deleted', 'search-mcp'])
).toEqual(['members', 'recently-deleted', 'requests', 'search-mcp'])
})

it('uses Sources for administration when Search is available', () => {
Expand Down Expand Up @@ -138,7 +138,7 @@ describe('organization settings navigation', () => {
it('hosts the account General section ahead of the organization sections', () => {
expect(
organizationSurfaceSettingsNavigation(false, enterprise, available).map(({ id }) => id)
).toEqual(['general', 'members', 'recently-deleted', 'search-mcp'])
).toEqual(['general', 'members', 'recently-deleted', 'requests', 'search-mcp'])
expect(ORGANIZATION_SETTINGS_GROUPS.map(({ key }) => key)).toEqual([
'account',
'organization',
Expand Down
Loading
Loading