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
2 changes: 1 addition & 1 deletion apps/sim/app/access-requests/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest
return (
<EmptyState
title='Choose an organization'
description='Open My access requests from your workspace menu.'
description='Open My access requests from your profile menu in a workspace.'
action={<ChipLink href={WORKSPACES_PATH}>Your workspaces</ChipLink>}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,18 @@ async function selectSettings() {
}

describe('OrganizationFooter settings navigation', () => {
it('keeps only Settings and Sign out in the organization profile menu', async () => {
it('offers personal request history in the organization profile menu', async () => {
await openProfileMenu()
expect(
[...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent)
).toEqual(['Settings', 'Sign out'])
).toEqual(['Settings', 'My access requests', 'Sign out'])
expect(document.querySelector('[role="separator"]')).toBeNull()
const requests = document.querySelector<HTMLAnchorElement>(
'a[href="/access-requests?organizationId=org-1"]'
)
expect(requests).not.toBeNull()
await act(async () => requests!.click())
expect(mockPush).toHaveBeenCalledWith('/access-requests?organizationId=org-1')
})

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

import type { ComponentProps } from 'react'
import { ListChecks } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import { createSerializer } from 'nuqs/server'
import { organizationRoutes } from '@/lib/navigation/paths'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer'
import { accessRequestEntrySearchParams } from '@/ee/access-requests/components/search-params'

const serializeAccessRequestParams = createSerializer(accessRequestEntrySearchParams)

interface OrganizationFooterProps
extends Omit<
Expand All @@ -16,13 +21,23 @@ export function OrganizationFooter(props: OrganizationFooterProps) {
const { organization } = useOrganizationContext()
const router = useRouter()
const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general')
const accessRequestsHref = serializeAccessRequestParams('/access-requests', {
organizationId: organization.id,
})

return (
<SidebarFooter
{...props}
accountSettingsHref={accountSettingsHref}
onOpenAccountSettings={() => router.push(accountSettingsHref)}
navigationLinks={[]}
navigationLinks={[
{
label: 'My access requests',
icon: ListChecks,
href: accessRequestsHref,
onNavigate: () => router.push(accessRequestsHref),
},
]}
/>
)
}
78 changes: 78 additions & 0 deletions apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/** @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({ session: vi.fn(), authorize: vi.fn() }))
vi.mock('next/navigation', () => ({
redirect: (href: string) => {
throw new Error(`redirect:${href}`)
},
notFound: () => {
throw new Error('not-found')
},
}))
vi.mock('@/lib/auth', () => ({ getSession: mocks.session }))
vi.mock('@/lib/settings/application/organization-section-access', () => ({
authorizeOrganizationSettingsSection: mocks.authorize,
}))
vi.mock('@/components/settings/account-settings-renderer', () => ({
AccountSettingsRenderer: () => null,
}))
vi.mock('@/components/settings/prefetch-standalone-general', () => ({
prefetchStandaloneGeneral: vi.fn(),
}))
vi.mock('@/app/o/[organizationId]/settings/[section]/settings', () => ({
OrganizationSettings: () => null,
}))

import OrganizationSettingsSectionPage from '@/app/o/[organizationId]/settings/[section]/page'

describe('organization request settings routing', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.session.mockResolvedValue({ user: { id: 'viewer' } })
mocks.authorize.mockResolvedValue(true)
})

it('renders the canonical request section only through the shared organization gate', async () => {
const page = await OrganizationSettingsSectionPage({
params: Promise.resolve({ organizationId: 'organization', section: 'requests' }),
})
expect(page.props.section).toBe('requests')
expect(mocks.authorize).toHaveBeenCalledWith({
organizationId: 'organization',
userId: 'viewer',
section: 'requests',
})
})

it('authorizes saved review tabs as Requests and preserves their selected request', async () => {
await expect(
OrganizationSettingsSectionPage({
params: Promise.resolve({ organizationId: 'organization', section: 'access-control' }),
searchParams: Promise.resolve({
'access-view': 'requests',
'request-id': 'selected',
'request-status': 'all',
'group-id': 'old-group',
}),
})
).rejects.toThrow(
'redirect:/o/organization/settings/requests?request-id=selected&request-status=all'
)
expect(mocks.authorize).toHaveBeenCalledWith({
organizationId: 'organization',
userId: 'viewer',
section: 'requests',
})
})

it('conceals requests from viewers rejected by the organization gate', async () => {
mocks.authorize.mockResolvedValue(false)
await expect(
OrganizationSettingsSectionPage({
params: Promise.resolve({ organizationId: 'organization', section: 'access-control' }),
searchParams: Promise.resolve({ 'access-view': 'requests' }),
})
).rejects.toThrow('not-found')
})
})
14 changes: 13 additions & 1 deletion apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
import {
getOrganizationSettingsHref,
getSettingsSectionMeta,
ORGANIZATION_SETTINGS_ITEMS,
} from '@/components/settings/navigation'
Expand All @@ -16,9 +17,11 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { OrganizationSettings } from '@/app/o/[organizationId]/settings/[section]/settings'
import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation'
import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation'

interface OrganizationSettingsSectionPageProps {
params: Promise<{ organizationId: string; section: string }>
searchParams?: Promise<Record<string, string | string[] | undefined>>
}

export async function generateMetadata({
Expand All @@ -42,6 +45,7 @@ export async function generateMetadata({
*/
export default async function OrganizationSettingsSectionPage({
params,
searchParams,
}: OrganizationSettingsSectionPageProps) {
const { organizationId, section } = await params
const routes = organizationRoutes(organizationId)
Expand All @@ -58,15 +62,23 @@ export default async function OrganizationSettingsSectionPage({
}

if (resolved.plane === 'organization') {
const legacyRequestsQuery = getLegacyAccessRequestsQuery(
resolved.section,
(await searchParams) ?? {}
)
const organizationSection = legacyRequestsQuery ? 'requests' : resolved.section
if (
!(await authorizeOrganizationSettingsSection({
organizationId,
userId: session.user.id,
section: resolved.section,
section: organizationSection,
}))
) {
notFound()
}
if (legacyRequestsQuery) {
redirect(getOrganizationSettingsHref(organizationId, 'requests', legacyRequestsQuery))
}
return <OrganizationSettings section={resolved.section} />
}

Expand Down
12 changes: 11 additions & 1 deletion apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ 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 AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
)
Expand Down Expand Up @@ -100,8 +105,13 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
)}
{section === 'billing' && <Billing scope='organization' organizationId={organizationId} />}
{section === 'access-control' && (
<AccessControl organizationId={organizationId} isOrganizationAdmin={viewer.isAdmin} />
<AccessControl
organizationId={organizationId}
isOrganizationAdmin={viewer.isAdmin}
requestsHref={getOrganizationSettingsHref(organizationId, 'requests')}
/>
)}
{section === 'requests' && <OrganizationAccessRequests organizationId={organizationId} />}
{section === 'audit-logs' && <AuditLogs organizationId={organizationId} />}
{section === 'usage' && (
<UsageMonitoring
Expand Down
22 changes: 19 additions & 3 deletions apps/sim/app/o/[organizationId]/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('organization settings navigation', () => {
{ ...enterprise, hasEnterprisePlan: false, governanceActive: false },
available
).map(({ id }) => id)
).toEqual(['billing', 'members', 'recently-deleted', 'search-mcp'])
).toEqual(['billing', 'members', 'recently-deleted', 'requests', 'search-mcp'])
})

/**
Expand All @@ -65,7 +65,14 @@ describe('organization settings navigation', () => {
{ ...enterprise, hasEnterprisePlan: false, governanceActive: true },
available
).map(({ id }) => id)
).toEqual(['billing', 'members', 'recently-deleted', 'access-control', 'search-mcp'])
).toEqual([
'billing',
'members',
'recently-deleted',
'requests',
'access-control',
'search-mcp',
])
})

it('honors individual self-hosted feature flags and hides billing when disabled', () => {
Expand All @@ -80,7 +87,15 @@ describe('organization settings navigation', () => {
},
available
).map(({ id }) => id)
).toEqual(['members', 'recently-deleted', 'sso', 'integrations', 'search-mcp', 'search-slack'])
).toEqual([
'members',
'recently-deleted',
'requests',
'sso',
'integrations',
'search-mcp',
'search-slack',
])
})

it('normalizes old section names and does not expose unsupported routes', () => {
Expand All @@ -107,6 +122,7 @@ describe('organization settings navigation', () => {
'organization:usage',
'organization:whitelabeling',
'organization:recently-deleted',
'organization:requests',
'governance:audit-logs',
'governance:access-control',
'governance:sso',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
'organization',
'usage',
'access-control',
'requests',
'audit-logs',
'sso',
'security',
Expand Down Expand Up @@ -134,6 +135,36 @@ describe('WorkspaceSettingsSectionPage', () => {
}
)

it.each([
{ organizationSearch: false, destination: '/workspace/workspace-b/settings/requests' },
{ organizationSearch: true, destination: '/o/org-target/settings/requests' },
])(
'moves saved request review tabs to the canonical destination with org rollout=$organizationSearch',
async ({ organizationSearch, destination }) => {
mockGetHostContext.mockResolvedValue({
hostOrganizationId: 'org-target',
features: { organizationSearch },
})
await expect(
WorkspaceSettingsSectionPage({
...pageProps('access-control'),
searchParams: Promise.resolve({
'access-view': 'requests',
'request-id': 'selected',
'request-status': 'all',
'group-id': 'old-group',
}),
})
).rejects.toThrow(`NEXT_REDIRECT:${destination}?request-id=selected&request-status=all`)
expect(mockAuthorizeSection).toHaveBeenCalledWith({
workspaceId: 'workspace-b',
userId: 'viewer-a',
section: 'requests',
})
expect(mockSectionPrefetch).not.toHaveBeenCalled()
}
)

it.each([undefined, { credentialGroups: true, knowledgeMemberAccess: true }])(
'keeps settings in the workspace when older host context omits the org rollout',
async (features) => {
Expand Down
14 changes: 11 additions & 3 deletions apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary'
import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation'
import { SECTION_PREFETCHERS } from './prefetch'
import { SettingsPage } from './settings'

Expand Down Expand Up @@ -47,7 +48,9 @@ export default async function WorkspaceSettingsSectionPage({
/** The layout already rejected an unknown segment; this narrows the type and fails safe. */
const resolved = resolveSettingsSection(section)
if (!resolved) notFound()
const parsed = resolved.id
const queryParams = (await searchParams) ?? {}
const legacyRequestsQuery = getLegacyAccessRequestsQuery(resolved.id, queryParams)
const parsed = legacyRequestsQuery ? 'requests' : resolved.id

const access = await authorizeWorkspaceSettingsSection({
workspaceId,
Expand Down Expand Up @@ -77,8 +80,8 @@ export default async function WorkspaceSettingsSectionPage({
if (organizationSection) {
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
if (hostContext?.hostOrganizationId && hostContext.features?.organizationSearch) {
const query = new URLSearchParams()
for (const [key, value] of Object.entries((await searchParams) ?? {})) {
const query = legacyRequestsQuery ?? new URLSearchParams()
for (const [key, value] of Object.entries(legacyRequestsQuery ? {} : queryParams)) {
for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
query.append(key, entry)
}
Expand All @@ -89,6 +92,11 @@ export default async function WorkspaceSettingsSectionPage({
}
}

if (legacyRequestsQuery) {
const query = legacyRequestsQuery.toString()
redirect(`/workspace/${workspaceId}/settings/requests${query ? `?${query}` : ''}`)
}

const queryClient = getQueryClient()
/**
* Protected section data starts only after the current server-side section gate succeeds.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ const WorkflowMcpServers = 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 CustomBlocks = dynamic(() =>
import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks)
)
Expand Down Expand Up @@ -182,8 +187,12 @@ function SettingsPageContent({ section }: SettingsPageProps) {
<AccessControl
organizationId={organizationId}
isOrganizationAdmin={hostContext.viewer.isHostOrganizationAdmin}
requestsHref={`/workspace/${hostContext.workspace.id}/settings/requests`}
/>
)}
{effectiveSection === 'requests' && organizationId && (
<OrganizationAccessRequests organizationId={organizationId} />
)}
{effectiveSection === 'custom-blocks' && <CustomBlocks />}
{effectiveSection === 'audit-logs' && organizationId && (
<AuditLogs organizationId={organizationId} />
Expand Down
Loading
Loading