feat: allow moving views for non-author project contributors+ (#5373)
* new auth policies & fragments * server changes * frontend changes
This commit is contained in:
committed by
GitHub
parent
af7b0a2de2
commit
3558087b1f
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="!isLowerPlan" class="flex items-center gap-0.5">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<FormButton
|
||||
v-tippy="getTooltipProps('Search views')"
|
||||
size="sm"
|
||||
@@ -63,40 +63,38 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!isLowerPlan">
|
||||
<div class="px-3 pt-3">
|
||||
<ViewerButtonGroup>
|
||||
<ViewerButtonGroupButton
|
||||
v-for="viewsType in Object.values(ViewsType)"
|
||||
:key="viewsType"
|
||||
:is-active="selectedViewsType === viewsType"
|
||||
class="grow"
|
||||
@click="() => (selectedViewsType = viewsType)"
|
||||
>
|
||||
<span class="text-body-2xs text-foreground px-2 py-1">
|
||||
{{ viewsTypeLabels[viewsType] }}
|
||||
</span>
|
||||
</ViewerButtonGroupButton>
|
||||
</ViewerButtonGroup>
|
||||
</div>
|
||||
<div class="text-body-sm flex-1 min-h-0 overflow-y-auto simple-scrollbar">
|
||||
<ViewerSavedViewsPanelGroups
|
||||
:views-type="selectedViewsType"
|
||||
:search="searchMode ? search || undefined : undefined"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isViewerSeat && !hideViewerSeatDisclaimer"
|
||||
class="absolute bottom-0 left-0 right-0 p-2"
|
||||
>
|
||||
<CommonPromoAlert
|
||||
title="Save your views"
|
||||
text="With an Editor seat, unlock the option to save views. A workspace admin can update your seat type."
|
||||
show-closer
|
||||
@close="hideViewerSeatDisclaimer = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-3 pt-3">
|
||||
<ViewerButtonGroup>
|
||||
<ViewerButtonGroupButton
|
||||
v-for="viewsType in Object.values(ViewsType)"
|
||||
:key="viewsType"
|
||||
:is-active="selectedViewsType === viewsType"
|
||||
class="grow"
|
||||
@click="() => (selectedViewsType = viewsType)"
|
||||
>
|
||||
<span class="text-body-2xs text-foreground px-2 py-1">
|
||||
{{ viewsTypeLabels[viewsType] }}
|
||||
</span>
|
||||
</ViewerButtonGroupButton>
|
||||
</ViewerButtonGroup>
|
||||
</div>
|
||||
<div class="text-body-sm flex-1 min-h-0 overflow-y-auto simple-scrollbar">
|
||||
<ViewerSavedViewsPanelGroups
|
||||
:views-type="selectedViewsType"
|
||||
:search="searchMode ? search || undefined : undefined"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isViewerSeat && !hideViewerSeatDisclaimer"
|
||||
class="absolute bottom-0 left-0 right-0 p-2"
|
||||
>
|
||||
<CommonPromoAlert
|
||||
title="Save your views"
|
||||
text="With an Editor seat, unlock the option to save views. A workspace admin can update your seat type."
|
||||
show-closer
|
||||
@close="hideViewerSeatDisclaimer = true"
|
||||
/>
|
||||
</div>
|
||||
<ViewerSavedViewsPanelGroupsCreateDialog
|
||||
v-model:open="showCreateGroupDialog"
|
||||
@success="onAddGroup"
|
||||
@@ -164,8 +162,6 @@ const canCreateViewOrGroup = computed(
|
||||
const isViewerSeat = computed(
|
||||
() => project.value?.workspace?.seatType === WorkspaceSeatType.Viewer
|
||||
)
|
||||
const isLowerPlan = computed(() => !project.value?.workspace?.planSupportsSavedViews)
|
||||
|
||||
const onAddView = async () => {
|
||||
if (isLoading.value) return
|
||||
const view = await createSavedView({})
|
||||
|
||||
@@ -183,7 +183,8 @@ const {
|
||||
isOnlyVisibleToMe,
|
||||
canSetHomeView,
|
||||
isHomeView,
|
||||
canToggleVisibility
|
||||
canToggleVisibility,
|
||||
canMove
|
||||
} = useSavedViewValidationHelpers({
|
||||
view: computed(() => props.view)
|
||||
})
|
||||
@@ -217,8 +218,8 @@ const menuItems = computed((): LayoutMenuItem<MenuItems>[][] => [
|
||||
{
|
||||
id: MenuItems.MoveToGroup,
|
||||
title: 'Move to group',
|
||||
disabled: !canUpdate.value?.authorized || isLoading.value,
|
||||
disabledTooltip: canUpdate.value?.errorMessage
|
||||
disabled: !canMove.value?.authorized || isLoading.value,
|
||||
disabledTooltip: canMove.value?.errorMessage
|
||||
},
|
||||
{
|
||||
id: MenuItems.ReplaceView,
|
||||
|
||||
@@ -433,9 +433,9 @@ type Documents = {
|
||||
"\n fragment UseDeleteSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n groupId\n projectId\n isUngroupedViewsGroup\n }\n": typeof types.UseDeleteSavedViewGroup_SavedViewGroupFragmentDoc,
|
||||
"\n mutation UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n updateGroup(input: $input) {\n id\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n }\n }\n }\n": typeof types.UpdateSavedViewGroupDocument,
|
||||
"\n fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n projectId\n groupId\n title\n isUngroupedViewsGroup\n }\n": typeof types.UseUpdateSavedViewGroup_SavedViewGroupFragmentDoc,
|
||||
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.UseDraggableView_SavedViewFragmentDoc,
|
||||
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.UseDraggableView_SavedViewFragmentDoc,
|
||||
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": typeof types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
|
||||
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
|
||||
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
|
||||
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": typeof types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
|
||||
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": typeof types.ViewerCommentThreadFragmentDoc,
|
||||
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": typeof types.ViewerCommentsReplyItemFragmentDoc,
|
||||
@@ -953,9 +953,9 @@ const documents: Documents = {
|
||||
"\n fragment UseDeleteSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n groupId\n projectId\n isUngroupedViewsGroup\n }\n": types.UseDeleteSavedViewGroup_SavedViewGroupFragmentDoc,
|
||||
"\n mutation UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n updateGroup(input: $input) {\n id\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n }\n }\n }\n": types.UpdateSavedViewGroupDocument,
|
||||
"\n fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n projectId\n groupId\n title\n isUngroupedViewsGroup\n }\n": types.UseUpdateSavedViewGroup_SavedViewGroupFragmentDoc,
|
||||
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.UseDraggableView_SavedViewFragmentDoc,
|
||||
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.UseDraggableView_SavedViewFragmentDoc,
|
||||
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
|
||||
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
|
||||
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
|
||||
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
|
||||
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": types.ViewerCommentThreadFragmentDoc,
|
||||
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": types.ViewerCommentsReplyItemFragmentDoc,
|
||||
@@ -2747,7 +2747,7 @@ export function graphql(source: "\n fragment UseUpdateSavedViewGroup_SavedViewG
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n"): (typeof documents)["\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n"];
|
||||
export function graphql(source: "\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n"): (typeof documents)["\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -2755,7 +2755,7 @@ export function graphql(source: "\n fragment UseDraggableViewTargetGroup_SavedV
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { useMutation, type MutateResult } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type {
|
||||
CreateSavedViewGroupInput,
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
UpdateSavedViewGroupInput,
|
||||
UpdateSavedViewGroupMutationVariables,
|
||||
UpdateSavedViewInput,
|
||||
UpdateSavedViewMutation,
|
||||
UseDeleteSavedView_SavedViewFragment,
|
||||
UseDeleteSavedViewGroup_SavedViewGroupFragment,
|
||||
UseUpdateSavedView_SavedViewFragment,
|
||||
@@ -246,6 +247,13 @@ export const useUpdateSavedView = () => {
|
||||
* Whether to skip toast notifications
|
||||
*/
|
||||
skipToast: boolean
|
||||
/**
|
||||
* To get the full response, use this callback
|
||||
*/
|
||||
onFullResult?: (
|
||||
res: Awaited<MutateResult<UpdateSavedViewMutation>>,
|
||||
success: boolean
|
||||
) => void
|
||||
}>
|
||||
) => {
|
||||
if (!isLoggedIn.value) return
|
||||
@@ -337,6 +345,7 @@ export const useUpdateSavedView = () => {
|
||||
}
|
||||
}
|
||||
|
||||
options?.onFullResult?.(result, !!res)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ graphql(`
|
||||
id
|
||||
}
|
||||
permissions {
|
||||
canUpdate {
|
||||
canMove {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export const useDraggableView = (params: {
|
||||
const vOn = {
|
||||
dragstart: (event: DragEvent) => {
|
||||
if (!event.dataTransfer) return
|
||||
if (!params.view.value.permissions.canUpdate.authorized || isLoading.value) {
|
||||
if (!params.view.value.permissions.canMove.authorized || isLoading.value) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -109,27 +109,34 @@ export const useDraggableViewTargetGroup = (params: {
|
||||
return
|
||||
}
|
||||
|
||||
const success = await updateView({
|
||||
view,
|
||||
input: {
|
||||
id: view.id,
|
||||
projectId: view.projectId,
|
||||
groupId: params.group.value.id
|
||||
await updateView(
|
||||
{
|
||||
view,
|
||||
input: {
|
||||
id: view.id,
|
||||
projectId: view.projectId,
|
||||
groupId: params.group.value.id
|
||||
}
|
||||
},
|
||||
{
|
||||
skipToast: true,
|
||||
onFullResult: (res, success) => {
|
||||
if (success) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `Moved "${view.name}" to "${params.group.value.title}"`
|
||||
})
|
||||
params.onMoved?.()
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Failed to move view',
|
||||
description: getFirstGqlErrorMessage(res?.errors)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (success) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `Moved "${view.name}" to "${params.group.value.title}"`
|
||||
})
|
||||
params.onMoved?.()
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Failed to move view'
|
||||
})
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
|
||||
@@ -19,6 +19,9 @@ graphql(`
|
||||
canUpdate {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canMove {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -38,6 +41,7 @@ export const useSavedViewValidationHelpers = (params: {
|
||||
} = useInjectedViewerState()
|
||||
|
||||
const canUpdate = computed(() => params.view.value?.permissions.canUpdate)
|
||||
const canMove = computed(() => params.view.value?.permissions.canMove)
|
||||
const isOnlyVisibleToMe = computed(
|
||||
() => params.view.value?.visibility === SavedViewVisibility.AuthorOnly
|
||||
)
|
||||
@@ -129,6 +133,7 @@ export const useSavedViewValidationHelpers = (params: {
|
||||
isOnlyVisibleToMe,
|
||||
canSetHomeView,
|
||||
isHomeView,
|
||||
canToggleVisibility
|
||||
canToggleVisibility,
|
||||
canMove
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ extend type ProjectPermissionChecks {
|
||||
|
||||
type SavedViewPermissionChecks {
|
||||
canUpdate: PermissionCheckResult!
|
||||
canMove: PermissionCheckResult!
|
||||
}
|
||||
|
||||
extend type SavedView {
|
||||
|
||||
@@ -3750,6 +3750,7 @@ export type SavedViewMutationsUpdateViewArgs = {
|
||||
|
||||
export type SavedViewPermissionChecks = {
|
||||
__typename?: 'SavedViewPermissionChecks';
|
||||
canMove: PermissionCheckResult;
|
||||
canUpdate: PermissionCheckResult;
|
||||
};
|
||||
|
||||
@@ -8133,6 +8134,7 @@ export type SavedViewMutationsResolvers<ContextType = GraphQLContext, ParentType
|
||||
};
|
||||
|
||||
export type SavedViewPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewPermissionChecks'] = ResolversParentTypes['SavedViewPermissionChecks']> = {
|
||||
canMove?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
canUpdate?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
@@ -9503,7 +9505,7 @@ export type CanUpdateSavedViewQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type CanUpdateSavedViewQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, savedView: { __typename?: 'SavedView', id: string, permissions: { __typename?: 'SavedViewPermissionChecks', canUpdate: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record<string, unknown> | null } } } } };
|
||||
export type CanUpdateSavedViewQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, savedView: { __typename?: 'SavedView', id: string, permissions: { __typename?: 'SavedViewPermissionChecks', canUpdate: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record<string, unknown> | null }, canMove: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record<string, unknown> | null } } } } };
|
||||
|
||||
export type UpdateSavedViewMutationVariables = Exact<{
|
||||
input: UpdateSavedViewInput;
|
||||
@@ -10672,7 +10674,7 @@ export const GetProjectSavedViewDocument = {"kind":"Document","definitions":[{"k
|
||||
export const GetProjectSavedViewIfExistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectSavedViewIfExists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewIfExists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}}]} as unknown as DocumentNode<GetProjectSavedViewIfExistsQuery, GetProjectSavedViewIfExistsQueryVariables>;
|
||||
export const DeleteSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteSavedViewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode<DeleteSavedViewMutation, DeleteSavedViewMutationVariables>;
|
||||
export const CanCreateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanCreateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canCreateSavedView"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}}]}}]}}]}}]} as unknown as DocumentNode<CanCreateSavedViewQuery, CanCreateSavedViewQueryVariables>;
|
||||
export const CanUpdateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanUpdateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"savedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<CanUpdateSavedViewQuery, CanUpdateSavedViewQueryVariables>;
|
||||
export const CanUpdateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanUpdateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"savedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canMove"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<CanUpdateSavedViewQuery, CanUpdateSavedViewQueryVariables>;
|
||||
export const UpdateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSavedViewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}}]} as unknown as DocumentNode<UpdateSavedViewMutation, UpdateSavedViewMutationVariables>;
|
||||
export const DeleteSavedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSavedViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteSavedViewGroupInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode<DeleteSavedViewGroupMutation, DeleteSavedViewGroupMutationVariables>;
|
||||
export const CanUpdateSavedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanUpdateSavedViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"groupId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"savedViewGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"groupId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<CanUpdateSavedViewGroupQuery, CanUpdateSavedViewGroupQueryVariables>;
|
||||
|
||||
@@ -32,6 +32,15 @@ const resolvers: Resolvers = {
|
||||
savedViewId
|
||||
})
|
||||
return toGraphqlResult(canUpdate)
|
||||
},
|
||||
canMove: async (parent, _args, ctx) => {
|
||||
const savedViewId = parent.savedView.id
|
||||
const canMove = await ctx.authPolicies.project.savedViews.canMove({
|
||||
userId: ctx.userId,
|
||||
projectId: parent.savedView.projectId,
|
||||
savedViewId
|
||||
})
|
||||
return toGraphqlResult(canMove)
|
||||
}
|
||||
},
|
||||
SavedViewGroupPermissionChecks: {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
getSavedViewGroupFactory
|
||||
} from '@/modules/viewer/repositories/dataLoaders/savedViews'
|
||||
import type { RequestDataLoaders } from '@/modules/core/loaders'
|
||||
import { omit } from 'lodash-es'
|
||||
|
||||
const buildGetViewerResourceGroups = (params: {
|
||||
projectDb: Knex
|
||||
@@ -340,12 +341,23 @@ const resolvers: Resolvers = {
|
||||
resourceAccessRules: ctx.resourceAccessRules
|
||||
})
|
||||
|
||||
const canUpdate = await ctx.authPolicies.project.savedViews.canUpdate({
|
||||
userId: ctx.userId,
|
||||
projectId,
|
||||
savedViewId: args.input.id
|
||||
})
|
||||
throwIfAuthNotOk(canUpdate)
|
||||
const updates = omit(args.input, 'id', 'projectId')
|
||||
const isJustMove = Object.keys(updates).length === 1 && 'groupId' in updates
|
||||
if (isJustMove) {
|
||||
const canMove = await ctx.authPolicies.project.savedViews.canMove({
|
||||
userId: ctx.userId,
|
||||
projectId,
|
||||
savedViewId: args.input.id
|
||||
})
|
||||
throwIfAuthNotOk(canMove)
|
||||
} else {
|
||||
const canUpdate = await ctx.authPolicies.project.savedViews.canUpdate({
|
||||
userId: ctx.userId,
|
||||
projectId,
|
||||
savedViewId: args.input.id
|
||||
})
|
||||
throwIfAuthNotOk(canUpdate)
|
||||
}
|
||||
|
||||
const updateSavedView = updateSavedViewFactory({
|
||||
getViewerResourceGroups: buildGetViewerResourceGroups({
|
||||
|
||||
@@ -187,6 +187,12 @@ export const canUpdateSavedViewQuery = gql`
|
||||
message
|
||||
payload
|
||||
}
|
||||
canMove {
|
||||
authorized
|
||||
code
|
||||
message
|
||||
payload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +70,9 @@ import { createTestBranch } from '@/test/speckle-helpers/branchHelper'
|
||||
import { createTestObject } from '@/test/speckle-helpers/commitHelper'
|
||||
import type { BasicTestStream } from '@/test/speckle-helpers/streamHelper'
|
||||
import { addToStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
|
||||
import { Roles, WorkspacePlans } from '@speckle/shared'
|
||||
import { Roles, SeatTypes, WorkspacePlans } from '@speckle/shared'
|
||||
import {
|
||||
ProjectNotEnoughPermissionsError,
|
||||
SavedViewNoAccessError,
|
||||
WorkspaceNoAccessError
|
||||
} from '@speckle/shared/authz'
|
||||
import * as ViewerRoute from '@speckle/shared/viewer/route'
|
||||
@@ -107,13 +106,6 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
overrides || {}
|
||||
)
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Test that default group can be resolved even if view has more specific resourceIds w/ versions
|
||||
* - Test that default group shows up or doesn't depending if there are views in it, regardless of
|
||||
* whether there's filtering
|
||||
*/
|
||||
|
||||
;(FF_SAVED_VIEWS_ENABLED ? describe : describe.skip)('Saved Views GraphQL CRUD', () => {
|
||||
let apollo: TestApolloServer
|
||||
let me: BasicTestUser
|
||||
@@ -849,8 +841,19 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
let testView: BasicSavedViewFragment
|
||||
let testView2: BasicSavedViewFragment
|
||||
let optionalGroup: BasicSavedViewGroupFragment
|
||||
let notAuthorButContributor: BasicTestUser
|
||||
|
||||
before(async () => {
|
||||
notAuthorButContributor = await createTestUser({
|
||||
name: 'not author but contributor'
|
||||
})
|
||||
await assignToWorkspace(
|
||||
myProjectWorkspace,
|
||||
notAuthorButContributor,
|
||||
Roles.Workspace.Member,
|
||||
SeatTypes.Editor
|
||||
)
|
||||
|
||||
updatablesProject = await createTestStream(
|
||||
buildBasicTestProject({
|
||||
name: 'updatables-project',
|
||||
@@ -858,7 +861,15 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
}),
|
||||
me
|
||||
)
|
||||
await addToStream(updatablesProject, otherGuy, Roles.Stream.Reviewer)
|
||||
|
||||
await Promise.all([
|
||||
addToStream(updatablesProject, otherGuy, Roles.Stream.Reviewer),
|
||||
addToStream(
|
||||
updatablesProject,
|
||||
notAuthorButContributor,
|
||||
Roles.Stream.Contributor
|
||||
)
|
||||
])
|
||||
|
||||
models = await Promise.all(
|
||||
times(3, async (i) => {
|
||||
@@ -1253,7 +1264,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
expect(res.data?.projectMutations.savedViewMutations.updateView).to.not.be.ok
|
||||
})
|
||||
|
||||
it('fails if user has no access to update the view', async () => {
|
||||
it('fails if non author contributor is updating the view', async () => {
|
||||
const newName = 'Updated View Name'
|
||||
|
||||
const res = await updateView(
|
||||
@@ -1264,13 +1275,32 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
name: newName
|
||||
}
|
||||
},
|
||||
{ authUserId: otherGuy.id }
|
||||
{ authUserId: notAuthorButContributor.id }
|
||||
)
|
||||
|
||||
expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code })
|
||||
expect(res.data?.projectMutations.savedViewMutations.updateView).to.not.be.ok
|
||||
})
|
||||
|
||||
it('succeeds if non author contributor is just moving the view', async () => {
|
||||
const res = await updateView(
|
||||
{
|
||||
input: {
|
||||
id: testView.id,
|
||||
projectId: updatablesProject.id,
|
||||
groupId: optionalGroup.id
|
||||
}
|
||||
},
|
||||
{ authUserId: notAuthorButContributor.id }
|
||||
)
|
||||
|
||||
expect(res).to.not.haveGraphQLErrors()
|
||||
expect(res.data?.projectMutations.savedViewMutations.updateView).to.be.ok
|
||||
|
||||
const update = res.data?.projectMutations.savedViewMutations.updateView
|
||||
expect(update?.groupId).to.equal(optionalGroup.id)
|
||||
})
|
||||
|
||||
it('fails if view does not exist', async () => {
|
||||
const res = await updateView({
|
||||
input: { id: 'non-existent-id', projectId: updatablesProject.id, name: 'x' }
|
||||
@@ -1676,7 +1706,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
|
||||
const data = res.data?.project.savedView.permissions.canUpdate
|
||||
expect(data?.authorized).to.be.false
|
||||
expect(data?.code).to.equal(SavedViewNoAccessError.code)
|
||||
expect(data?.code).to.equal(ProjectNotEnoughPermissionsError.code)
|
||||
})
|
||||
|
||||
describe('of groups', async () => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { OverridesOf } from '../../tests/helpers/types.js'
|
||||
import {
|
||||
ensureCanAccessSavedViewFragment,
|
||||
ensureCanAccessSavedViewGroupFragment
|
||||
ensureCanAccessSavedViewGroupFragment,
|
||||
WriteTypes
|
||||
} from './savedViews.js'
|
||||
import {
|
||||
getEnvFake,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
WorkspaceNoAccessError
|
||||
} from '../domain/authErrors.js'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { ProjectVisibility } from '../domain/projects/types.js'
|
||||
|
||||
const userId = 'user-id'
|
||||
const savedViewId = 'saved-view-id'
|
||||
@@ -53,10 +55,11 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
getWorkspaceRole: async () => null,
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getAdminOverrideEnabled: async () => false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
it.each(<const>['read', 'write'])(
|
||||
it.each(<const>['read', ...Object.values(WriteTypes)])(
|
||||
'fails when not workspaced project (%s)',
|
||||
async (access) => {
|
||||
const sut = buildSUT()
|
||||
@@ -110,7 +113,6 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
const expectSuccess = success === 'succeeds'
|
||||
|
||||
const sut = buildWorkspaceSUT({
|
||||
getProjectRole: async () => null,
|
||||
getSavedView: getSavedViewFake({
|
||||
id: savedViewId,
|
||||
projectId,
|
||||
@@ -138,22 +140,75 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('succeeds if asking for read access to public projects public view, even if not a part of the project or workspace', async () => {
|
||||
const sut = buildWorkspaceSUT({
|
||||
getSavedView: getSavedViewFake({
|
||||
id: savedViewId,
|
||||
projectId,
|
||||
visibility: SavedViewVisibility.public,
|
||||
authorId: userId
|
||||
}),
|
||||
getProject: getProjectFake({
|
||||
id: projectId,
|
||||
workspaceId,
|
||||
visibility: ProjectVisibility.Public
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspaceRole: async () => null
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId,
|
||||
projectId,
|
||||
savedViewId,
|
||||
access: 'read'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it.each(<const>[
|
||||
{ author: 'author', success: 'succeeds' },
|
||||
{ author: 'not author', success: 'fails', error: SavedViewNoAccessError.code },
|
||||
{ author: 'author', success: 'succeeds', access: WriteTypes.UpdateGeneral },
|
||||
{ author: 'author', success: 'succeeds', access: WriteTypes.MoveView },
|
||||
{
|
||||
author: 'not author',
|
||||
success: 'fails',
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.UpdateGeneral
|
||||
},
|
||||
{
|
||||
author: 'not author',
|
||||
success: 'succeeds',
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.MoveView
|
||||
},
|
||||
{
|
||||
author: 'author but no longer contributor',
|
||||
success: 'fails',
|
||||
error: ProjectNotEnoughPermissionsError.code
|
||||
error: ProjectNotEnoughPermissionsError.code,
|
||||
access: WriteTypes.UpdateGeneral
|
||||
},
|
||||
{
|
||||
author: 'author but no longer contributor',
|
||||
success: 'fails',
|
||||
error: ProjectNotEnoughPermissionsError.code,
|
||||
access: WriteTypes.MoveView
|
||||
},
|
||||
{
|
||||
author: 'not author but is workspace admin',
|
||||
success: 'fails',
|
||||
error: SavedViewNoAccessError.code
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.UpdateGeneral
|
||||
},
|
||||
{
|
||||
author: 'not author but is workspace admin',
|
||||
success: 'succeeds',
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.MoveView
|
||||
}
|
||||
])(
|
||||
'$success if asking for write access to private (as $author)',
|
||||
async ({ author, success, error }) => {
|
||||
'$success if asking for $access type write access to private (as $author)',
|
||||
async ({ author, success, error, access }) => {
|
||||
const isAuthor = author.startsWith('author')
|
||||
const isNotContributor = author.includes('no longer contributor')
|
||||
const isWorkspaceAdmin = author.includes('is workspace admin')
|
||||
@@ -176,7 +231,7 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
userId,
|
||||
projectId,
|
||||
savedViewId,
|
||||
access: 'write'
|
||||
access
|
||||
})
|
||||
|
||||
if (expectSuccess) {
|
||||
@@ -189,7 +244,7 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(<const>['read', 'write'])(
|
||||
it.each(<const>['read', ...Object.values(WriteTypes)])(
|
||||
'succeeds to %s even on free plan',
|
||||
async (access) => {
|
||||
const sut = buildWorkspaceSUT({
|
||||
@@ -214,7 +269,7 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(<const>['read', 'write'])(
|
||||
it.each(<const>['read', ...Object.values(WriteTypes)])(
|
||||
'fails if view doesnt exist (%s)',
|
||||
async (access) => {
|
||||
const sut = buildWorkspaceSUT({
|
||||
@@ -233,7 +288,7 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(<const>['read', 'write'])(
|
||||
it.each(<const>['read', ...Object.values(WriteTypes)])(
|
||||
`doesn't fail if view doesnt exist and allowNonExistent set (%s)`,
|
||||
async (access) => {
|
||||
const sut = buildWorkspaceSUT({
|
||||
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
import { Roles } from '../../core/constants.js'
|
||||
import { WorkspacePlanFeatures } from '../../workspaces/index.js'
|
||||
import { isUngroupedGroup } from '../../saved-views/index.js'
|
||||
import { StringEnum, StringEnumValues, throwUncoveredError } from '../../core/index.js'
|
||||
|
||||
export const WriteTypes = StringEnum(['UpdateGeneral', 'MoveView'])
|
||||
export type WriteTypes = StringEnumValues<typeof WriteTypes>
|
||||
|
||||
/**
|
||||
* Ensure the user can access the view
|
||||
@@ -47,11 +51,12 @@ export const ensureCanAccessSavedViewFragment: AuthPolicyEnsureFragment<
|
||||
| typeof Loaders.getWorkspacePlan
|
||||
| typeof Loaders.getWorkspaceSsoProvider
|
||||
| typeof Loaders.getWorkspaceSsoSession
|
||||
| typeof Loaders.getAdminOverrideEnabled
|
||||
| typeof Loaders.getProjectRole,
|
||||
MaybeUserContext &
|
||||
ProjectContext &
|
||||
SavedViewContext & {
|
||||
access: 'read' | 'write'
|
||||
access: 'read' | WriteTypes
|
||||
/**
|
||||
* In some cases we want to just ignore a view being non-existant, instead of throwing
|
||||
*/
|
||||
@@ -89,43 +94,59 @@ export const ensureCanAccessSavedViewFragment: AuthPolicyEnsureFragment<
|
||||
if (allowNonExistent) return ok()
|
||||
return err(new SavedViewNotFoundError())
|
||||
}
|
||||
|
||||
const isPublic = savedView.visibility === SavedViewVisibility.public
|
||||
if (isPublic && access === 'read') {
|
||||
return ok()
|
||||
}
|
||||
|
||||
const isAuthor = savedView.authorId === userId
|
||||
if (isAuthor) {
|
||||
if (access === 'write') {
|
||||
// Check for write access to project first
|
||||
const ensuredWriteAccess =
|
||||
await ensureImplicitProjectMemberWithWriteAccessFragment(loaders)({
|
||||
userId,
|
||||
projectId
|
||||
|
||||
// Validate read access
|
||||
if (access === 'read') {
|
||||
if (isAuthor || isPublic) {
|
||||
return ok()
|
||||
} else {
|
||||
return err(
|
||||
new SavedViewNoAccessError({
|
||||
message: 'You do not have permission to read this saved view.'
|
||||
})
|
||||
if (ensuredWriteAccess.isErr) {
|
||||
if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code)
|
||||
return err(
|
||||
new ProjectNotEnoughPermissionsError({
|
||||
message:
|
||||
"Your role on this project doesn't give you permission to update views."
|
||||
})
|
||||
)
|
||||
return err(ensuredWriteAccess.error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate write access
|
||||
// Check for write access to project first
|
||||
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
|
||||
loaders
|
||||
)({
|
||||
userId,
|
||||
projectId
|
||||
})
|
||||
if (ensuredWriteAccess.isErr) {
|
||||
if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code)
|
||||
return err(
|
||||
new ProjectNotEnoughPermissionsError({
|
||||
message:
|
||||
"Your role on this project doesn't give you permission to update views."
|
||||
})
|
||||
)
|
||||
return err(ensuredWriteAccess.error)
|
||||
}
|
||||
|
||||
if (isAuthor) {
|
||||
// authors can write whatever
|
||||
return ok()
|
||||
}
|
||||
|
||||
return err(
|
||||
new SavedViewNoAccessError({
|
||||
message:
|
||||
access === 'write'
|
||||
? 'You do not have permission to edit this view'
|
||||
: 'You do not have read access for this view'
|
||||
})
|
||||
)
|
||||
// Non-author project writers can make specific changes
|
||||
switch (access) {
|
||||
case WriteTypes.MoveView:
|
||||
return ok()
|
||||
case WriteTypes.UpdateGeneral:
|
||||
return err(
|
||||
new SavedViewNoAccessError({
|
||||
message: 'You do not have permission to edit this view'
|
||||
})
|
||||
)
|
||||
default:
|
||||
throwUncoveredError(access)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ import { canCreateDashboardsPolicy } from './workspace/canCreateDashboards.js'
|
||||
import { canCreateDashboardTokenPolicy } from './dashboard/canCreateToken.js'
|
||||
import { canEditDashboardPolicy } from './dashboard/canEdit.js'
|
||||
import { canReadDashboardPolicy } from './dashboard/canRead.js'
|
||||
import { canMoveSavedViewPolicy } from './project/savedViews/canMove.js'
|
||||
|
||||
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
automate: {
|
||||
@@ -84,7 +85,8 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
canCreate: canCreateSavedViewPolicy(loaders),
|
||||
canUpdate: canUpdateSavedViewPolicy(loaders),
|
||||
canUpdateGroup: canUpdateSavedViewGroupPolicy(loaders),
|
||||
canRead: canReadSavedViewPolicy(loaders)
|
||||
canRead: canReadSavedViewPolicy(loaders),
|
||||
canMove: canMoveSavedViewPolicy(loaders)
|
||||
},
|
||||
canBroadcastActivity: canBroadcastProjectActivityPolicy(loaders),
|
||||
canRead: canReadProjectPolicy(loaders),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OverridesOf } from '../../../../tests/helpers/types.js'
|
||||
import {
|
||||
getEnvFake,
|
||||
getProjectFake,
|
||||
getSavedViewFake,
|
||||
getWorkspaceFake,
|
||||
getWorkspacePlanFake,
|
||||
getWorkspaceSsoProviderFake,
|
||||
getWorkspaceSsoSessionFake
|
||||
} from '../../../../tests/fakes.js'
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import {
|
||||
ProjectNotEnoughPermissionsError,
|
||||
ServerNoAccessError,
|
||||
WorkspaceNoAccessError,
|
||||
WorkspacePlanNoFeatureAccessError,
|
||||
WorkspacesNotEnabledError
|
||||
} from '../../../domain/authErrors.js'
|
||||
import { canMoveSavedViewPolicy } from './canMove.js'
|
||||
|
||||
describe('canMoveSavedViewPolicy', () => {
|
||||
const buildSUT = (overrides?: OverridesOf<typeof canMoveSavedViewPolicy>) =>
|
||||
canMoveSavedViewPolicy({
|
||||
getSavedView: getSavedViewFake({
|
||||
projectId: 'project-id',
|
||||
authorId: 'user-id'
|
||||
}),
|
||||
getProject: getProjectFake({
|
||||
id: 'project-id',
|
||||
workspaceId: null
|
||||
}),
|
||||
getEnv: getEnvFake({
|
||||
FF_WORKSPACES_MODULE_ENABLED: true,
|
||||
FF_SAVED_VIEWS_ENABLED: true
|
||||
}),
|
||||
getServerRole: async () => Roles.Server.User,
|
||||
getProjectRole: async () => Roles.Stream.Owner,
|
||||
getWorkspaceRole: async () => null,
|
||||
getWorkspace: async () => null,
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspacePlan: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getAdminOverrideEnabled: async () => false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
it('fails in non-workspaced project, even if project owner', async () => {
|
||||
const policy = buildSUT()
|
||||
|
||||
const result = await policy({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewId: 'saved-view-id'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspaceNoAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
describe('w/ workspaced project', async () => {
|
||||
const buildWorkspacedSUT = (
|
||||
overrides?: OverridesOf<typeof canMoveSavedViewPolicy>
|
||||
) =>
|
||||
buildSUT({
|
||||
getProject: getProjectFake({
|
||||
id: 'project-id',
|
||||
workspaceId: 'workspace-id'
|
||||
}),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Member,
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id'
|
||||
}),
|
||||
getWorkspacePlan: getWorkspacePlanFake({
|
||||
workspaceId: 'workspace-id',
|
||||
name: 'pro'
|
||||
}),
|
||||
getWorkspaceSsoProvider: getWorkspaceSsoProviderFake({
|
||||
providerId: 'sso-provider-id'
|
||||
}),
|
||||
getWorkspaceSsoSession: getWorkspaceSsoSessionFake({
|
||||
providerId: 'sso-provider-id'
|
||||
}),
|
||||
...overrides
|
||||
})
|
||||
|
||||
it('works for non-author if user is workspace admin', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getWorkspaceRole: async () => Roles.Workspace.Admin
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-idx',
|
||||
projectId: 'project-id',
|
||||
savedViewId: 'saved-view-id'
|
||||
})
|
||||
|
||||
expect(result).toBeOKResult()
|
||||
})
|
||||
|
||||
it('works for non-author if user is contributor', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getProjectRole: async () => Roles.Stream.Contributor
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-idx',
|
||||
projectId: 'project-id',
|
||||
savedViewId: 'saved-view-id'
|
||||
})
|
||||
|
||||
expect(result).toBeOKResult()
|
||||
})
|
||||
|
||||
it('fails if workspaces disabled', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getEnv: getEnvFake({
|
||||
FF_WORKSPACES_MODULE_ENABLED: false,
|
||||
FF_SAVED_VIEWS_ENABLED: true
|
||||
})
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewId: 'saved-view-id'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspacesNotEnabledError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('fails if saved views disabled', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getEnv: getEnvFake({
|
||||
FF_WORKSPACES_MODULE_ENABLED: true,
|
||||
FF_SAVED_VIEWS_ENABLED: false
|
||||
})
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewId: 'saved-view-id'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspacePlanNoFeatureAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('fails if just reviewer', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getProjectRole: async () => Roles.Stream.Reviewer
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewId: 'saved-view-id'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ProjectNotEnoughPermissionsError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('fails if logged out', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getWorkspaceRole: async () => null,
|
||||
getServerRole: async () => null,
|
||||
getProjectRole: async () => null
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewId: 'saved-view-id'
|
||||
})
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ServerNoAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('succeeds if view author', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getSavedView: getSavedViewFake({
|
||||
projectId: 'project-id',
|
||||
authorId: 'user-id'
|
||||
})
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewId: 'saved-view-id'
|
||||
})
|
||||
|
||||
expect(result).toBeOKResult()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
ProjectNoAccessError,
|
||||
ProjectNotEnoughPermissionsError,
|
||||
ProjectNotFoundError,
|
||||
SavedViewNoAccessError,
|
||||
SavedViewNotFoundError,
|
||||
ServerNoAccessError,
|
||||
ServerNoSessionError,
|
||||
ServerNotEnoughPermissionsError,
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceNotEnoughPermissionsError,
|
||||
WorkspacePlanNoFeatureAccessError,
|
||||
WorkspaceReadOnlyError,
|
||||
WorkspacesNotEnabledError,
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../../../domain/authErrors.js'
|
||||
import {
|
||||
MaybeUserContext,
|
||||
ProjectContext,
|
||||
SavedViewContext
|
||||
} from '../../../domain/context.js'
|
||||
import { Loaders } from '../../../domain/loaders.js'
|
||||
import { AuthPolicy } from '../../../domain/policies.js'
|
||||
import {
|
||||
ensureCanAccessSavedViewFragment,
|
||||
WriteTypes
|
||||
} from '../../../fragments/savedViews.js'
|
||||
|
||||
export const canMoveSavedViewPolicy: AuthPolicy<
|
||||
| typeof Loaders.getSavedView
|
||||
| typeof Loaders.getProject
|
||||
| typeof Loaders.getEnv
|
||||
| typeof Loaders.getServerRole
|
||||
| typeof Loaders.getWorkspaceRole
|
||||
| typeof Loaders.getWorkspace
|
||||
| typeof Loaders.getWorkspacePlan
|
||||
| typeof Loaders.getWorkspaceSsoProvider
|
||||
| typeof Loaders.getWorkspaceSsoSession
|
||||
| typeof Loaders.getAdminOverrideEnabled
|
||||
| typeof Loaders.getProjectRole,
|
||||
MaybeUserContext & ProjectContext & SavedViewContext,
|
||||
InstanceType<
|
||||
| typeof SavedViewNotFoundError
|
||||
| typeof SavedViewNoAccessError
|
||||
| typeof ProjectNotFoundError
|
||||
| typeof ServerNoAccessError
|
||||
| typeof ServerNoSessionError
|
||||
| typeof ProjectNoAccessError
|
||||
| typeof WorkspaceNoAccessError
|
||||
| typeof WorkspaceSsoSessionNoAccessError
|
||||
| typeof ServerNotEnoughPermissionsError
|
||||
| typeof ProjectNotEnoughPermissionsError
|
||||
| typeof WorkspaceNotEnoughPermissionsError
|
||||
| typeof WorkspacesNotEnabledError
|
||||
| typeof WorkspaceReadOnlyError
|
||||
| typeof WorkspacePlanNoFeatureAccessError
|
||||
>
|
||||
> =
|
||||
(loaders) =>
|
||||
async ({ userId, projectId, savedViewId }) => {
|
||||
return await ensureCanAccessSavedViewFragment(loaders)({
|
||||
userId,
|
||||
projectId,
|
||||
savedViewId,
|
||||
access: WriteTypes.MoveView
|
||||
})
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export const canReadSavedViewPolicy: AuthPolicy<
|
||||
| typeof Loaders.getWorkspacePlan
|
||||
| typeof Loaders.getWorkspaceSsoProvider
|
||||
| typeof Loaders.getWorkspaceSsoSession
|
||||
| typeof Loaders.getAdminOverrideEnabled
|
||||
| typeof Loaders.getProjectRole,
|
||||
MaybeUserContext &
|
||||
ProjectContext &
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('canUpdateSavedViewPolicy', () => {
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspacePlan: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getAdminOverrideEnabled: async () => false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
} from '../../../domain/context.js'
|
||||
import { Loaders } from '../../../domain/loaders.js'
|
||||
import { AuthPolicy } from '../../../domain/policies.js'
|
||||
import { ensureCanAccessSavedViewFragment } from '../../../fragments/savedViews.js'
|
||||
import {
|
||||
ensureCanAccessSavedViewFragment,
|
||||
WriteTypes
|
||||
} from '../../../fragments/savedViews.js'
|
||||
|
||||
export const canUpdateSavedViewPolicy: AuthPolicy<
|
||||
| typeof Loaders.getSavedView
|
||||
@@ -33,6 +36,7 @@ export const canUpdateSavedViewPolicy: AuthPolicy<
|
||||
| typeof Loaders.getWorkspacePlan
|
||||
| typeof Loaders.getWorkspaceSsoProvider
|
||||
| typeof Loaders.getWorkspaceSsoSession
|
||||
| typeof Loaders.getAdminOverrideEnabled
|
||||
| typeof Loaders.getProjectRole,
|
||||
MaybeUserContext & ProjectContext & SavedViewContext,
|
||||
InstanceType<
|
||||
@@ -58,6 +62,6 @@ export const canUpdateSavedViewPolicy: AuthPolicy<
|
||||
userId,
|
||||
projectId,
|
||||
savedViewId,
|
||||
access: 'write'
|
||||
access: WriteTypes.UpdateGeneral
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user