Skip to content

Commit

Permalink
refactor(core): update managing permissions 2 (#8700)
Browse files Browse the repository at this point in the history
  • Loading branch information
RitaDias authored Feb 20, 2025
1 parent 3fe9584 commit 0632832
Show file tree
Hide file tree
Showing 19 changed files with 218 additions and 127 deletions.
13 changes: 11 additions & 2 deletions packages/sanity/src/core/perspective/navbar/ReleasesList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {AddIcon} from '@sanity/icons'
import {Box, Flex, MenuDivider, Spinner} from '@sanity/ui'
import {type RefObject, useCallback, useEffect, useMemo, useState} from 'react'
import {type RefObject, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {css, styled} from 'styled-components'

import {MenuItem} from '../../../ui-components/menuItem/MenuItem'
Expand Down Expand Up @@ -70,10 +70,19 @@ export function ReleasesList({

const {t} = useTranslation()

const isMounted = useRef(false)
useEffect(() => {
isMounted.current = true

checkWithPermissionGuard(createRelease, createReleaseMetadata(DEFAULT_RELEASE)).then(
setHasCreatePermission,
(hasPermission) => {
if (isMounted.current) setHasCreatePermission(hasPermission)
},
)

return () => {
isMounted.current = false
}
}, [checkWithPermissionGuard, createRelease, createReleaseMetadata])

const handleCreateBundleClick = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
import {
mockUseReleasePermissions,
useReleasePermissionsMockReturn,
useReleasesPermissionsMockReturnFalse,
useReleasesPermissionsMockReturnTrue,
} from '../../../releases/store/__tests__/__mocks/useReleasePermissions.mock'
import {ReleasesList} from '../ReleasesList'

Expand All @@ -40,9 +42,7 @@ describe('ReleasesList', () => {
...useActiveReleasesMockReturn,
data: [activeASAPRelease, activeScheduledRelease, activeUndecidedRelease],
})
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)
const wrapper = await createTestProvider()
render(
<Menu>
Expand Down Expand Up @@ -122,9 +122,7 @@ describe('ReleasesList', () => {
...useActiveReleasesMockReturn,
data: [activeASAPRelease, activeScheduledRelease, activeUndecidedRelease],
})
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => false,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnFalse)
const wrapper = await createTestProvider()
render(
<Menu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {useActiveReleasesMockReturn} from '../../../releases/store/__tests__/__m
import {
mockUseReleasePermissions,
useReleasePermissionsMockReturn,
useReleasesPermissionsMockReturnTrue,
} from '../../../releases/store/__tests__/__mocks/useReleasePermissions.mock'
import {LATEST} from '../../../releases/util/const'
import {ReleasesNav} from '../ReleasesNav'
Expand Down Expand Up @@ -70,9 +71,7 @@ describe('ReleasesNav', () => {
beforeEach(() => {
vi.clearAllMocks()

mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)
})
it('should have link to releases tool', async () => {
await renderTest()
Expand Down Expand Up @@ -211,9 +210,7 @@ describe('ReleasesNav', () => {
})

it('disables button when no permissions are met', async () => {
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)
})
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {AddIcon, CalendarIcon, CopyIcon, TrashIcon} from '@sanity/icons'
import {Menu, MenuDivider, Spinner, Stack} from '@sanity/ui'
import {memo, useEffect, useState} from 'react'
import {memo, useEffect, useRef, useState} from 'react'
import {IntentLink} from 'sanity/router'
import {styled} from 'styled-components'

Expand Down Expand Up @@ -69,8 +69,17 @@ export const VersionContextMenu = memo(function VersionContextMenu(props: {
})
const hasDiscardPermission = !isPermissionsLoading && permissions?.granted

const isMounted = useRef(false)
useEffect(() => {
checkWithPermissionGuard(createRelease, DEFAULT_RELEASE).then(setHasCreatePermission)
isMounted.current = true

checkWithPermissionGuard(createRelease, DEFAULT_RELEASE).then((hasPermission) => {
if (isMounted.current) setHasCreatePermission(hasPermission)
})

return () => {
isMounted.current = false
}
}, [checkWithPermissionGuard, createRelease])

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {createTestProvider} from '../../../../../../../test/testUtils/TestProvid
import {
mockUseReleasePermissions,
useReleasePermissionsMockReturn,
useReleasesPermissionsMockReturnTrue,
} from '../../../../store/__tests__/__mocks/useReleasePermissions.mock'
import {type ReleaseDocument} from '../../../../store/types'
import {VersionContextMenu} from '../VersionContextMenu'
Expand Down Expand Up @@ -72,9 +73,7 @@ describe('VersionContextMenu', () => {
}

it('renders the menu items correctly', async () => {
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)

const wrapper = await createTestProvider()

Expand All @@ -97,9 +96,7 @@ describe('VersionContextMenu', () => {
})

it('calls onCreateRelease when "New release" is clicked', async () => {
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)

const wrapper = await createTestProvider()

Expand All @@ -122,9 +119,7 @@ describe('VersionContextMenu', () => {
})

it('hides discard version on published chip', async () => {
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)

const wrapper = await createTestProvider()
const publishedProps = {
Expand All @@ -139,9 +134,7 @@ describe('VersionContextMenu', () => {
})

it('calls onDiscard when "Discard version" is clicked', async () => {
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)

const wrapper = await createTestProvider()

Expand All @@ -158,9 +151,7 @@ describe('VersionContextMenu', () => {
})

it('calls onCreateRelease when a "new release" is clicked', async () => {
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)

const wrapper = await createTestProvider()

Expand All @@ -183,9 +174,7 @@ describe('VersionContextMenu', () => {
})

it('calls onCreateVersion when a release is clicked and sets the perspective to the release', async () => {
mockUseReleasePermissions.mockReturnValue({
checkWithPermissionGuard: async () => true,
})
mockUseReleasePermissions.mockReturnValue(useReleasesPermissionsMockReturnTrue)

const wrapper = await createTestProvider()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import {useReleasePermissions} from '../../useReleasePermissions'

export const useReleasePermissionsMockReturn: Mocked<ReturnType<typeof useReleasePermissions>> = {
checkWithPermissionGuard: vi.fn(),
permissions: {},
}

export const useReleasesPermissionsMockReturnTrue: Mocked<
ReturnType<typeof useReleasePermissions>
> = {
checkWithPermissionGuard: vi.fn().mockResolvedValue(true),
permissions: {},
}

export const useReleasesPermissionsMockReturnFalse: Mocked<
ReturnType<typeof useReleasePermissions>
> = {
checkWithPermissionGuard: vi.fn().mockResolvedValue(false),
permissions: {},
}

export const mockUseReleasePermissions = useReleasePermissions as Mock<typeof useReleasePermissions>
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {beforeEach, describe, expect, it, vi} from 'vitest'

import {useReleasePermissions} from '../useReleasePermissions'
import {createReleasePermissionsStore} from '../createReleasePermissionsStore'
import {type useReleasePermissionsValue} from '../useReleasePermissions'

const createStore = () => createReleasePermissionsStore()

describe('useReleasePermissions', () => {
let store: ReturnType<typeof useReleasePermissions>
let store: useReleasePermissionsValue

beforeEach(() => {
store = useReleasePermissions()
store = createStore()
})

it('should return true when action succeeds', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {isErrorWithDetails} from '../../error/types/isErrorWithDetails'
import {type useReleasePermissionsValue} from './useReleasePermissions'

type ReleasePermissionError = {details: {type: 'insufficientPermissionsError'}}

/**
* Checks if the error is a permission error
*
* @param error - the error to check
* @returns true if the error is a permission error
*/
export const isReleasePermissionError = (error: unknown): error is ReleasePermissionError =>
isErrorWithDetails(error) && error.details?.type === 'insufficientPermissionsError'

/**
* Store that contains if the user has permissions to perform a release action
* And a guardrail to dry run requests to check if the user has permissions
*
* @returns an object with the following properties:
* * checkWithPermissionGuard - a function that checks if the user has permissions to perform a release action by adding dryRun properties
* * permissions - an object with the permissions for each action
*
* @internal
*/
export function createReleasePermissionsStore(): useReleasePermissionsValue {
let permissions: {[key: string]: boolean} = {}

/**
* Checks if a release action can be performed by running a dry run of the given action
*
* @param action - any of the actions from the {@link ReleaseOperationStore}, e.g. publishRelease should send in also the needed props
* @param args - the arguments to pass to the action (release id, etc)
* @returns true or false depending if the user can perform the action
*/
const checkWithPermissionGuard = async <T extends (...args: any[]) => Promise<void> | void>(
action: T,
...args: Parameters<T>
): Promise<boolean> => {
if (permissions[action.name] === undefined) {
try {
await action(...args, {
dryRun: true,
skipCrossDatasetReferenceValidation: true,
})
permissions = {...permissions, [action.name]: true}

return true
} catch (e) {
permissions = {...permissions, [action.name]: false}

return !isReleasePermissionError(e)
}
} else {
return permissions[action.name]
}
}
return {
checkWithPermissionGuard: checkWithPermissionGuard,
permissions,
}
}
54 changes: 24 additions & 30 deletions packages/sanity/src/core/releases/store/useReleasePermissions.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,37 @@
import {isErrorWithDetails} from '../../error/types/isErrorWithDetails'
import {useMemo} from 'react'

import {useResourceCache} from '../../store/_legacy/ResourceCacheProvider'
import {createReleasePermissionsStore} from './createReleasePermissionsStore'

const RELEASE_PERMISSIONS_RESOURCE_CACHE_NAMESPACE = 'ReleasePermissions'

export interface useReleasePermissionsValue {
checkWithPermissionGuard: <T extends (...args: any[]) => Promise<void> | void>(
action: T,
...args: Parameters<T>
) => Promise<boolean>
permissions: {[key: string]: boolean}
}

type ReleasePermissionError = {details: {type: 'insufficientPermissionsError'}}

export const isReleasePermissionError = (error: unknown): error is ReleasePermissionError =>
isErrorWithDetails(error) && error.details?.type === 'insufficientPermissionsError'

/**
* @internal
*/
export function useReleasePermissions(): useReleasePermissionsValue {
/**
* Checks if a release action can be performed by running a dry run of the given action
*
* @param action - any of the actions from the ReleaseOperationStore, e.g. publishRelease should send in also the needed props
* @param args - the arguments to pass to the action (release id, etc)
* @returns true or false depending if the user can perform the action
*/
const checkWithPermissionGuard = async <T extends (...args: any[]) => Promise<void> | void>(
action: T,
...args: Parameters<T>
): Promise<boolean> => {
try {
await action(...args, {
dryRun: true,
skipCrossDatasetReferenceValidation: true,
})
return true
} catch (e) {
return !isReleasePermissionError(e)
}
}
return {
checkWithPermissionGuard: checkWithPermissionGuard,
}
const resourceCache = useResourceCache()

return useMemo(() => {
const releasePermissionsStore =
resourceCache.get<useReleasePermissionsValue>({
dependencies: [null],
namespace: RELEASE_PERMISSIONS_RESOURCE_CACHE_NAMESPACE,
}) || createReleasePermissionsStore()

resourceCache.set({
namespace: RELEASE_PERMISSIONS_RESOURCE_CACHE_NAMESPACE,
value: releasePermissionsStore,
dependencies: [null],
})

return releasePermissionsStore
}, [resourceCache])
}
Loading

0 comments on commit 0632832

Please sign in to comment.