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
68 changes: 68 additions & 0 deletions apps/sim/app/access-requests/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/** @vitest-environment node */
import { authMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { redirect } = vi.hoisted(() => ({ redirect: vi.fn() }))
vi.mock('next/navigation', () => ({ redirect }))
vi.mock('@/components/access-requests/my-access-requests', () => ({ MyAccessRequests: () => null }))
vi.mock('@/components/access-requests/organization-access-requests', () => ({
OrganizationAccessRequests: () => null,
}))

import AccessRequestsPage from '@/app/access-requests/page'

describe('access request sign-in redirect', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue(null)
redirect.mockImplementation(() => {
throw new Error('Redirect')
})
})

it.each([
{
organizationId: 'organization',
view: 'catalog',
requestId: 'request',
search: 'Slack & Notion',
page: '3',
},
{
organizationId: 'organization',
view: 'admin',
requestId: 'request',
'request-status': 'declined',
'request-page': '2',
},
])('preserves the supported $view state through sign-in', async (params) => {
await expect(AccessRequestsPage({ searchParams: Promise.resolve(params) })).rejects.toThrow(
'Redirect'
)
const loginUrl = new URL(redirect.mock.calls[0][0], 'https://example.com')
expect(loginUrl.pathname).toBe('/login')
const callback = new URL(loginUrl.searchParams.get('callbackUrl')!, loginUrl.origin)
expect(callback.pathname).toBe('/access-requests')
expect(Object.fromEntries(callback.searchParams)).toEqual(params)
})

it('drops invalid and unsupported state instead of forwarding raw query parameters', async () => {
await expect(
AccessRequestsPage({
searchParams: Promise.resolve({
organizationId: 'organization',
view: 'invalid',
page: '40001',
search: 'x'.repeat(201),
requestId: 'x'.repeat(129),
'request-page': '-1',
'request-status': 'invalid',
callbackUrl: 'https://example.com/untrusted',
}),
})
).rejects.toThrow('Redirect')
expect(redirect).toHaveBeenCalledWith(
`/login?callbackUrl=${encodeURIComponent('/access-requests?organizationId=organization')}`
)
})
})
9 changes: 3 additions & 6 deletions apps/sim/app/access-requests/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Suspense } from 'react'
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { createSearchParamsCache } from 'nuqs/server'
import { createSearchParamsCache, createSerializer } from 'nuqs/server'
import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading'
import { MyAccessRequests } from '@/components/access-requests/my-access-requests'
import { OrganizationAccessRequests } from '@/components/access-requests/organization-access-requests'
Expand All @@ -22,19 +22,16 @@ interface AccessRequestsPageProps {
}

const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams)
const serializeEntrySearchParams = createSerializer(accessRequestEntrySearchParams)

/** Session-only entry so access requests remain reachable outside the organization Search rollout. */
export default async function AccessRequestsPage({ searchParams }: AccessRequestsPageProps) {
const [rawParams, session] = await Promise.all([searchParams, getSession()])
const params = entrySearchParams.parse(rawParams)
const query = new URLSearchParams()
if (params.organizationId) query.set('organizationId', params.organizationId)
if (params.view !== 'requests') query.set('view', params.view)
if (params.requestId) query.set('requestId', params.requestId)
if (!session?.user) {
redirect(
buildAuthCrossLink('/login', {
callbackUrl: `/access-requests?${query}`,
callbackUrl: serializeEntrySearchParams('/access-requests', params),
isInviteFlow: false,
})
)
Expand Down
43 changes: 40 additions & 3 deletions apps/sim/app/api/permission-groups/user/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @vitest-environment node */
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
session: vi.fn(),
Expand All @@ -22,7 +22,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwne
vi.mock('@/lib/billing/core/subscription', () => ({
isOrganizationOnEnterprisePlan: mocks.enterprise,
}))
vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group }))
vi.mock('@/lib/permission-groups/resolve.server', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/permission-groups/resolve.server')>()),
resolveWorkspaceGroup: mocks.group,
}))

import { userPermissionConfigSchema } from '@/lib/api/contracts/permission-groups'
import { OrchestrationError } from '@/lib/core/orchestration/types'
Expand Down Expand Up @@ -53,6 +56,7 @@ function get(query = '?workspaceId=workspace') {

beforeEach(() => {
vi.clearAllMocks()
setEnvFlags({ isHosted: true, isAccessControlEnabled: true })
mocks.session.mockResolvedValue({
user: { id: 'viewer' },
session: { id: 'session', activeOrganizationId: 'unrelated-org' },
Expand All @@ -64,7 +68,40 @@ beforeEach(() => {
mocks.group.mockResolvedValue(null)
})

afterEach(resetEnvFlagsMock)

describe('user permission policy shared read', () => {
it.each([
{ hosted: false, accessControl: false, entitled: false },
{ hosted: false, accessControl: true, entitled: true },
{ hosted: true, accessControl: false, entitled: true },
])(
'matches the active permission regime ($hosted, $accessControl)',
async ({ hosted, accessControl, entitled }) => {
setEnvFlags({
isHosted: hosted,
isAccessControlEnabled: accessControl,
isBillingEnabled: false,
})
mocks.admin.mockResolvedValue(true)
const group = {
permissionGroupId: 'group',
groupName: 'Restricted',
config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true },
}
mocks.group.mockResolvedValue(group)
const expected = { ...unrestricted, ...(entitled ? group : {}), entitled, isOrgAdmin: true }
expect(await (await get()).json()).toEqual(expected)
expect(
await readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } })
).toEqual(expected)
if (!entitled) {
expect(mocks.group).not.toHaveBeenCalled()
expect(mocks.enterprise).not.toHaveBeenCalled()
}
}
)

it('authenticates before parsing or protected lookups', async () => {
mocks.session.mockResolvedValue(null)
expect((await get('')).status).toBe(401)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,21 @@ describe('toolbar access requests', () => {
expect(container.textContent).not.toContain('Access required')
expect(container.textContent).not.toContain('Locked')
})

it('does not reopen a request after requests are disabled and re-enabled', () => {
act(() => root.render(<Toolbar />))
act(() =>
container
.querySelector<HTMLButtonElement>('[aria-label="Request access to Locked tool"]')
?.click()
)
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
discovery.mockReturnValue({ data: { enabled: false } })
act(() => root.render(<Toolbar isActive={false} />))
expect(document.querySelector('[role="dialog"]')).toBeNull()
discovery.mockReturnValue({ data: { enabled: true } })
act(() => root.render(<Toolbar isActive />))
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(container.querySelector('[aria-label="Request access to Locked tool"]')).not.toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,13 @@ export const Toolbar = memo(
allTools.find((item) => item.type === requestedBlockType))
: undefined

if (
requestedBlockType !== null &&
(!requestedBlock || !workspaceId || !accessRequestsEnabled)
) {
setRequestedBlockType(null)
}

// Published custom blocks are their own section. Exclude disabled blocks (still
// resolvable so placed instances survive, but not offered for new placement) and
// the block bound to the CURRENT workflow — adding a workflow's own block recurses.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,16 +217,21 @@ export const Panel = memo(function Panel() {
scope: usageLimitScope,
isLoading: isUsageGateLoading,
} = useUsageLimits({ workspaceId })
const isMemberLimitExceeded = usageExceeded && usageLimitScope === 'member'
const memberLimitRequest = useDiscoverAccessRequests(
{ kind: 'workspace', workspaceId, targetKind: 'usage_limit', limit: 1, offset: 0 },
usageExceeded && usageLimitScope === 'member'
isMemberLimitExceeded
)
const [showLimitRequest, setShowLimitRequest] = useState(false)
const memberLimitTarget =
memberLimitRequest.isSuccess && memberLimitRequest.data.enabled
isMemberLimitExceeded && memberLimitRequest.isSuccess && memberLimitRequest.data.enabled
? memberLimitRequest.data.entries.find((entry) => entry.state === 'requestable')
: undefined

if (showLimitRequest && !memberLimitTarget) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
setShowLimitRequest(false)
}

// Workflow execution hook
const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,6 @@ export function AccessRequestReview({
<p className='break-words text-[var(--text-body)] text-sm'>
{data.group?.name ?? 'No longer available'}
</p>
<p className='text-[var(--text-muted)] text-sm'>
May affect up to {data.impact.memberCount}{' '}
{data.impact.memberCount === 1 ? 'person' : 'people'} across{' '}
{data.impact.workspaceCount}{' '}
{data.impact.workspaceCount === 1 ? 'workspace' : 'workspaces'}.
</p>
</ChipModalField>
)}
{pending && alreadyAvailable && (
Expand Down Expand Up @@ -171,7 +165,7 @@ export function AccessRequestReview({
value={newLimit}
onChange={setNewLimit}
required
hint='Applies to this member. Enter a whole number above their current limit.'
hint='Enter a whole number above the current limit.'
/>
)}
</>
Expand Down
22 changes: 21 additions & 1 deletion apps/sim/components/access-requests/my-access-requests.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,28 @@ describe('compact requester history', () => {
expect(mocks.mine).toHaveBeenCalledWith(scope, 50, undefined, false)
expect(mocks.mine).toHaveBeenCalledWith(scope, 0, 'request')
expect(mocks.discovery).toHaveBeenCalledWith(
expect.objectContaining({ search: 'slack', offset: 50 }),
expect.objectContaining({ search: 'slack', offset: 50, state: 'requestable' }),
true
)
})

it('exposes the selected view and resets pagination when switching views through nuqs', async () => {
render('?view=catalog&search=slack&page=2')
const views = container.querySelector('[role="radiogroup"][aria-label="Access request views"]')
expect(views?.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toBe(
'Browse access'
)
const history = views?.querySelector<HTMLButtonElement>('[role="radio"][value="requests"]')
expect(history).not.toBeNull()
await act(async () => history?.click())
expect(views?.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toBe(
'My requests'
)
await vi.waitFor(() =>
expect(mocks.url).toHaveBeenLastCalledWith(
expect.objectContaining({ queryString: '?search=slack' })
)
)
expect(mocks.mine).toHaveBeenLastCalledWith(scope, 0, undefined, true)
})
})
30 changes: 11 additions & 19 deletions apps/sim/components/access-requests/my-access-requests.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { Chip, ChipInput, ChipLink, ChipTag } from '@sim/emcn'
import { Chip, ChipInput, ChipLink, ChipSwitch, ChipTag } from '@sim/emcn'
import { Lock, Search } from '@sim/emcn/icons'
import { useQueryStates } from 'nuqs'
import { MyAccessRequestDetails } from '@/components/access-requests/my-access-request-details'
Expand Down Expand Up @@ -66,20 +66,15 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) {
<ChipLink href={WORKSPACES_PATH}>Your workspaces</ChipLink>
)}
</div>
<div className='flex flex-wrap items-center gap-2' aria-label='Access request views'>
<Chip
active={view === 'requests'}
onClick={() => void setParams({ view: 'requests', page: 0, requestId: null })}
>
My requests
</Chip>
<Chip
active={view === 'catalog'}
onClick={() => void setParams({ view: 'catalog', page: 0, requestId: null })}
>
Browse access
</Chip>
</div>
<ChipSwitch
aria-label='Access request views'
options={[
{ value: 'requests', label: 'My requests' },
{ value: 'catalog', label: 'Browse access' },
]}
value={view}
onChange={(value) => void setParams({ view: value, page: 0, requestId: null })}
/>
{view === 'catalog' && (
<ChipInput
icon={Search}
Expand Down Expand Up @@ -132,7 +127,7 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) {
) : !catalog.data?.enabled ? (
<EmptyState
title='Access requests are unavailable'
description='Your organization is not accepting new access requests. Existing requests remain in My requests.'
description='Your organization is not accepting new requests.'
/>
) : (
<div className={RESOURCE_LIST_STACK}>
Expand All @@ -148,9 +143,6 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) {
icon={entry.state === 'allowed' ? undefined : <Lock />}
iconVariant='plain'
title={entry.label}
description={
entry.reason ?? (entry.state === 'allowed' ? 'Available to you' : undefined)
}
badge={
entry.state === 'allowed' ? (
<ChipTag variant='gray'>Available</ChipTag>
Expand Down
Loading
Loading