feat: allow view title & description updates for non-owner contributors (#5532)

* new policies

* backend updated

* updated frontend
This commit is contained in:
Kristaps Fabians Geikins
2025-09-24 09:26:03 +02:00
committed by GitHub
parent 43d92234a3
commit 77e36d37b3
17 changed files with 781 additions and 53 deletions
@@ -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:
+5 -1
View File
@@ -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
})
}