feat: allow moving views for non-author project contributors+ (#5373)

* new auth policies & fragments

* server changes

* frontend changes
This commit is contained in:
Kristaps Fabians Geikins
2025-09-03 15:28:42 +03:00
committed by GitHub
parent af7b0a2de2
commit 3558087b1f
21 changed files with 591 additions and 156 deletions
@@ -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)
}
}
/**
+3 -1
View File
@@ -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
})
}