feat: allow view title & description updates for non-owner contributors (#5532)
* new policies * backend updated * updated frontend
This commit is contained in:
committed by
GitHub
parent
43d92234a3
commit
77e36d37b3
@@ -80,7 +80,11 @@
|
||||
</LayoutMenu>
|
||||
<div
|
||||
v-tippy="
|
||||
getTooltipProps(canUpdate?.authorized ? 'Edit view' : canUpdate?.errorMessage)
|
||||
getTooltipProps(
|
||||
canOpenEditDialog?.authorized
|
||||
? 'Edit view'
|
||||
: canOpenEditDialog?.errorMessage
|
||||
)
|
||||
"
|
||||
class="shrink-0 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
@@ -91,7 +95,7 @@
|
||||
hide-text
|
||||
name="editView"
|
||||
class="shrink-0"
|
||||
:disabled="!canUpdate?.authorized || isLoading"
|
||||
:disabled="!canOpenEditDialog?.authorized"
|
||||
@click="onEdit"
|
||||
/>
|
||||
</div>
|
||||
@@ -185,7 +189,8 @@ const {
|
||||
canSetHomeView,
|
||||
isHomeView,
|
||||
canToggleVisibility,
|
||||
canMove
|
||||
canMove,
|
||||
canOpenEditDialog
|
||||
} = useSavedViewValidationHelpers({
|
||||
view: computed(() => props.view)
|
||||
})
|
||||
|
||||
@@ -30,12 +30,15 @@
|
||||
:resource-id-string="resourceIdString"
|
||||
:rules="[isRequired]"
|
||||
/>
|
||||
<FormRadioGroup
|
||||
:options="visibilityOptions"
|
||||
size="sm"
|
||||
name="visibility"
|
||||
:rules="[isRequired, validateVisibility]"
|
||||
/>
|
||||
<div v-tippy="canToggleVisibility.message">
|
||||
<FormRadioGroup
|
||||
:options="visibilityOptions"
|
||||
:disabled="!canToggleVisibility.authorized"
|
||||
size="sm"
|
||||
name="visibility"
|
||||
:rules="[isRequired, validateVisibility]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
@@ -90,9 +93,10 @@ const {
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
const updateView = useUpdateSavedView()
|
||||
const { validateVisibility, visibilityOptions } = useSavedViewValidationHelpers({
|
||||
view: computed(() => props.view)
|
||||
})
|
||||
const { validateVisibility, visibilityOptions, canToggleVisibility } =
|
||||
useSavedViewValidationHelpers({
|
||||
view: computed(() => props.view)
|
||||
})
|
||||
|
||||
const buttons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
|
||||
@@ -448,7 +448,7 @@ type Documents = {
|
||||
"\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 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 canMove {\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 canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\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,
|
||||
@@ -981,7 +981,7 @@ const documents: Documents = {
|
||||
"\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 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 canMove {\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 canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\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,
|
||||
@@ -2833,7 +2833,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 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"];
|
||||
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 canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\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 canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\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
@@ -3,6 +3,7 @@ import type { GenericValidateFunction } from 'vee-validate'
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import {
|
||||
SavedViewVisibility,
|
||||
type FullPermissionCheckResultFragment,
|
||||
type UseSavedViewValidationHelpers_SavedViewFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { Globe, User } from 'lucide-vue-next'
|
||||
@@ -22,6 +23,12 @@ graphql(`
|
||||
canMove {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canEditTitle {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canEditDescription {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -42,6 +49,30 @@ export const useSavedViewValidationHelpers = (params: {
|
||||
|
||||
const canUpdate = computed(() => params.view.value?.permissions.canUpdate)
|
||||
const canMove = computed(() => params.view.value?.permissions.canMove)
|
||||
const canEditTitle = computed(() => params.view.value?.permissions.canEditTitle)
|
||||
const canEditDescription = computed(
|
||||
() => params.view.value?.permissions.canEditDescription
|
||||
)
|
||||
|
||||
const canOpenEditDialog = computed(
|
||||
(): FullPermissionCheckResultFragment | undefined => {
|
||||
if (isLoading.value) {
|
||||
return {
|
||||
authorized: false,
|
||||
errorMessage: undefined,
|
||||
code: 'LOADING',
|
||||
message: ''
|
||||
}
|
||||
}
|
||||
|
||||
if (canUpdate.value?.authorized) return canUpdate.value
|
||||
if (canEditTitle.value?.authorized) return canEditTitle.value
|
||||
if (canEditDescription.value?.authorized) return canEditDescription.value
|
||||
if (canMove.value?.authorized) return canMove.value
|
||||
return canMove.value
|
||||
}
|
||||
)
|
||||
|
||||
const isOnlyVisibleToMe = computed(
|
||||
() => params.view.value?.visibility === SavedViewVisibility.AuthorOnly
|
||||
)
|
||||
@@ -134,6 +165,9 @@ export const useSavedViewValidationHelpers = (params: {
|
||||
canSetHomeView,
|
||||
isHomeView,
|
||||
canToggleVisibility,
|
||||
canMove
|
||||
canMove,
|
||||
canEditTitle,
|
||||
canEditDescription,
|
||||
canOpenEditDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,14 @@ extend type ProjectPermissionChecks {
|
||||
}
|
||||
|
||||
type SavedViewPermissionChecks {
|
||||
"""
|
||||
Can the current user fully update everything about this view. Even if this fails,
|
||||
the user may be able to do partial updates (e.g. just change the title)
|
||||
"""
|
||||
canUpdate: PermissionCheckResult!
|
||||
canMove: PermissionCheckResult!
|
||||
canEditTitle: PermissionCheckResult!
|
||||
canEditDescription: PermissionCheckResult!
|
||||
}
|
||||
|
||||
extend type SavedView {
|
||||
|
||||
@@ -3795,7 +3795,13 @@ export type SavedViewMutationsUpdateViewArgs = {
|
||||
|
||||
export type SavedViewPermissionChecks = {
|
||||
__typename?: 'SavedViewPermissionChecks';
|
||||
canEditDescription: PermissionCheckResult;
|
||||
canEditTitle: PermissionCheckResult;
|
||||
canMove: PermissionCheckResult;
|
||||
/**
|
||||
* Can the current user fully update everything about this view. Even if this fails,
|
||||
* the user may be able to do partial updates (e.g. just change the title)
|
||||
*/
|
||||
canUpdate: PermissionCheckResult;
|
||||
};
|
||||
|
||||
@@ -8215,6 +8221,8 @@ export type SavedViewMutationsResolvers<ContextType = GraphQLContext, ParentType
|
||||
};
|
||||
|
||||
export type SavedViewPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewPermissionChecks'] = ResolversParentTypes['SavedViewPermissionChecks']> = {
|
||||
canEditDescription?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
canEditTitle?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
canMove?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
canUpdate?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
|
||||
@@ -41,6 +41,25 @@ const resolvers: Resolvers = {
|
||||
savedViewId
|
||||
})
|
||||
return toGraphqlResult(canMove)
|
||||
},
|
||||
canEditTitle: async (parent, _args, ctx) => {
|
||||
const savedViewId = parent.savedView.id
|
||||
const canEditTitle = await ctx.authPolicies.project.savedViews.canEditTitle({
|
||||
userId: ctx.userId,
|
||||
projectId: parent.savedView.projectId,
|
||||
savedViewId
|
||||
})
|
||||
return toGraphqlResult(canEditTitle)
|
||||
},
|
||||
canEditDescription: async (parent, _args, ctx) => {
|
||||
const savedViewId = parent.savedView.id
|
||||
const canEditDescription =
|
||||
await ctx.authPolicies.project.savedViews.canEditDescription({
|
||||
userId: ctx.userId,
|
||||
projectId: parent.savedView.projectId,
|
||||
savedViewId
|
||||
})
|
||||
return toGraphqlResult(canEditDescription)
|
||||
}
|
||||
},
|
||||
SavedViewGroupPermissionChecks: {
|
||||
|
||||
@@ -357,23 +357,32 @@ const resolvers: Resolvers = {
|
||||
resourceAccessRules: ctx.resourceAccessRules
|
||||
})
|
||||
|
||||
// Different keys have different auth checks
|
||||
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 updatePolicyMap: Partial<
|
||||
Record<
|
||||
keyof typeof updates,
|
||||
Extract<
|
||||
keyof typeof ctx.authPolicies.project.savedViews,
|
||||
'canMove' | 'canUpdate' | 'canEditTitle' | 'canEditDescription'
|
||||
>
|
||||
>
|
||||
> = {
|
||||
groupId: 'canMove',
|
||||
name: 'canEditTitle',
|
||||
description: 'canEditDescription'
|
||||
}
|
||||
const results = await Promise.all(
|
||||
Object.keys(updates).map((key) => {
|
||||
const policyKey = updatePolicyMap[key as keyof typeof updates] || 'canUpdate'
|
||||
return ctx.authPolicies.project.savedViews[policyKey]({
|
||||
userId: ctx.userId,
|
||||
projectId,
|
||||
savedViewId: args.input.id
|
||||
})
|
||||
})
|
||||
)
|
||||
results.forEach(throwIfAuthNotOk)
|
||||
|
||||
const updateSavedView = updateSavedViewFactory({
|
||||
getViewerResourceGroups: buildGetViewerResourceGroups({
|
||||
|
||||
@@ -1364,7 +1364,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
expect(res.data?.projectMutations.savedViewMutations.updateView).to.not.be.ok
|
||||
})
|
||||
|
||||
it('fails if non author contributor is updating the view', async () => {
|
||||
it('succeeds if non author contributor is renaming the view', async () => {
|
||||
const newName = 'Updated View Name'
|
||||
|
||||
const res = await updateView(
|
||||
@@ -1378,8 +1378,32 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
{ authUserId: notAuthorButContributor.id }
|
||||
)
|
||||
|
||||
expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code })
|
||||
expect(res.data?.projectMutations.savedViewMutations.updateView).to.not.be.ok
|
||||
expect(res).to.not.haveGraphQLErrors({ code: ForbiddenError.code })
|
||||
expect(res.data?.projectMutations.savedViewMutations.updateView).to.be.ok
|
||||
|
||||
const update = res.data?.projectMutations.savedViewMutations.updateView
|
||||
expect(update?.name).to.equal(newName)
|
||||
})
|
||||
|
||||
it('succeeds if non author contributor is updating the description of the view', async () => {
|
||||
const newDescription = 'Updated View Description'
|
||||
|
||||
const res = await updateView(
|
||||
{
|
||||
input: {
|
||||
id: testView.id,
|
||||
projectId: updatablesProject.id,
|
||||
description: newDescription
|
||||
}
|
||||
},
|
||||
{ authUserId: notAuthorButContributor.id }
|
||||
)
|
||||
|
||||
expect(res).to.not.haveGraphQLErrors({ code: ForbiddenError.code })
|
||||
expect(res.data?.projectMutations.savedViewMutations.updateView).to.be.ok
|
||||
|
||||
const update = res.data?.projectMutations.savedViewMutations.updateView
|
||||
expect(update?.description).to.equal(newDescription)
|
||||
})
|
||||
|
||||
it('succeeds if non author contributor is just moving the view', async () => {
|
||||
@@ -1401,6 +1425,24 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
|
||||
expect(update?.groupId).to.equal(optionalGroup.id)
|
||||
})
|
||||
|
||||
it('fails if non author contributor is updating the visibility of the view', async () => {
|
||||
const newVisibility = SavedViewVisibility.authorOnly
|
||||
|
||||
const res = await updateView(
|
||||
{
|
||||
input: {
|
||||
id: testView.id,
|
||||
projectId: updatablesProject.id,
|
||||
visibility: newVisibility
|
||||
}
|
||||
},
|
||||
{ authUserId: notAuthorButContributor.id }
|
||||
)
|
||||
|
||||
expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code })
|
||||
expect(res.data?.projectMutations.savedViewMutations.updateView).to.not.be.ok
|
||||
})
|
||||
|
||||
it('fails if view does not exist', async () => {
|
||||
const res = await updateView({
|
||||
input: { id: 'non-existent-id', projectId: updatablesProject.id, name: 'x' }
|
||||
|
||||
@@ -170,6 +170,8 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
it.each(<const>[
|
||||
{ author: 'author', success: 'succeeds', access: WriteTypes.UpdateGeneral },
|
||||
{ author: 'author', success: 'succeeds', access: WriteTypes.MoveView },
|
||||
{ author: 'author', success: 'succeeds', access: WriteTypes.EditTitle },
|
||||
{ author: 'author', success: 'succeeds', access: WriteTypes.EditDescription },
|
||||
{
|
||||
author: 'not author',
|
||||
success: 'fails',
|
||||
@@ -182,6 +184,18 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.MoveView
|
||||
},
|
||||
{
|
||||
author: 'not author',
|
||||
success: 'succeeds',
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.EditTitle
|
||||
},
|
||||
{
|
||||
author: 'not author',
|
||||
success: 'succeeds',
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.EditDescription
|
||||
},
|
||||
{
|
||||
author: 'author but no longer contributor',
|
||||
success: 'fails',
|
||||
@@ -194,6 +208,18 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
error: ProjectNotEnoughPermissionsError.code,
|
||||
access: WriteTypes.MoveView
|
||||
},
|
||||
{
|
||||
author: 'author but no longer contributor',
|
||||
success: 'fails',
|
||||
error: ProjectNotEnoughPermissionsError.code,
|
||||
access: WriteTypes.EditTitle
|
||||
},
|
||||
{
|
||||
author: 'author but no longer contributor',
|
||||
success: 'fails',
|
||||
error: ProjectNotEnoughPermissionsError.code,
|
||||
access: WriteTypes.EditDescription
|
||||
},
|
||||
{
|
||||
author: 'not author but is workspace admin',
|
||||
success: 'fails',
|
||||
@@ -205,6 +231,18 @@ describe('ensureCanAccessSavedViewFragment', () => {
|
||||
success: 'succeeds',
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.MoveView
|
||||
},
|
||||
{
|
||||
author: 'not author but is workspace admin',
|
||||
success: 'succeeds',
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.EditTitle
|
||||
},
|
||||
{
|
||||
author: 'not author but is workspace admin',
|
||||
success: 'succeeds',
|
||||
error: SavedViewNoAccessError.code,
|
||||
access: WriteTypes.EditDescription
|
||||
}
|
||||
])(
|
||||
'$success if asking for $access type write access to private (as $author)',
|
||||
|
||||
@@ -35,7 +35,12 @@ 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 const WriteTypes = StringEnum([
|
||||
'UpdateGeneral',
|
||||
'MoveView',
|
||||
'EditTitle',
|
||||
'EditDescription'
|
||||
])
|
||||
export type WriteTypes = StringEnumValues<typeof WriteTypes>
|
||||
|
||||
/**
|
||||
@@ -137,11 +142,13 @@ export const ensureCanAccessSavedViewFragment: AuthPolicyEnsureFragment<
|
||||
// Non-author project writers can make specific changes
|
||||
switch (access) {
|
||||
case WriteTypes.MoveView:
|
||||
case WriteTypes.EditTitle:
|
||||
case WriteTypes.EditDescription:
|
||||
return ok()
|
||||
case WriteTypes.UpdateGeneral:
|
||||
return err(
|
||||
new SavedViewNoAccessError({
|
||||
message: 'You do not have permission to edit this view'
|
||||
message: 'You do not have permission to edit the view in this way'
|
||||
})
|
||||
)
|
||||
default:
|
||||
|
||||
@@ -45,6 +45,8 @@ 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'
|
||||
import { canEditSavedViewTitlePolicy } from './project/savedViews/canEditTitle.js'
|
||||
import { canEditSavedViewDescriptionPolicy } from './project/savedViews/canEditDescription.js'
|
||||
|
||||
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
automate: {
|
||||
@@ -86,7 +88,9 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
canUpdate: canUpdateSavedViewPolicy(loaders),
|
||||
canUpdateGroup: canUpdateSavedViewGroupPolicy(loaders),
|
||||
canRead: canReadSavedViewPolicy(loaders),
|
||||
canMove: canMoveSavedViewPolicy(loaders)
|
||||
canMove: canMoveSavedViewPolicy(loaders),
|
||||
canEditTitle: canEditSavedViewTitlePolicy(loaders),
|
||||
canEditDescription: canEditSavedViewDescriptionPolicy(loaders)
|
||||
},
|
||||
canBroadcastActivity: canBroadcastProjectActivityPolicy(loaders),
|
||||
canRead: canReadProjectPolicy(loaders),
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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 { canEditSavedViewDescriptionPolicy } from './canEditDescription.js'
|
||||
|
||||
describe('canEditSavedViewDescriptionPolicy', () => {
|
||||
const buildSUT = (
|
||||
overrides?: OverridesOf<typeof canEditSavedViewDescriptionPolicy>
|
||||
) =>
|
||||
canEditSavedViewDescriptionPolicy({
|
||||
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 canEditSavedViewDescriptionPolicy>
|
||||
) =>
|
||||
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 canEditSavedViewDescriptionPolicy: 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.EditDescription
|
||||
})
|
||||
}
|
||||
@@ -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 { canEditSavedViewTitlePolicy } from './canEditTitle.js'
|
||||
|
||||
describe('canEditSavedViewTitlePolicy', () => {
|
||||
const buildSUT = (overrides?: OverridesOf<typeof canEditSavedViewTitlePolicy>) =>
|
||||
canEditSavedViewTitlePolicy({
|
||||
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 canEditSavedViewTitlePolicy>
|
||||
) =>
|
||||
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 canEditSavedViewTitlePolicy: 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.EditTitle
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user