feat(savedViews): share presentations (#5523)
* share presentations basics * fix: issues and resolvers * missing gqlgen * fix * fix: gql types * feat: minor changes * fix: fk and policies * feat: add shareLink * feat: remove useless error * fix: minor * fix: tests
This commit is contained in:
committed by
GitHub
parent
0e56be7b8f
commit
d41f59be11
@@ -3716,6 +3716,23 @@ export type SavedViewGroupPermissionChecks = {
|
||||
canUpdate: PermissionCheckResult;
|
||||
};
|
||||
|
||||
export type SavedViewGroupToken = {
|
||||
__typename?: 'SavedViewGroupToken';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
lastUsed: Scalars['DateTime']['output'];
|
||||
lifespan: Scalars['BigInt']['output'];
|
||||
projects: Array<Project>;
|
||||
savedViewGroupId: Scalars['String']['output'];
|
||||
tokenId: Scalars['String']['output'];
|
||||
user?: Maybe<LimitedUser>;
|
||||
};
|
||||
|
||||
export type SavedViewGroupTokenReturn = {
|
||||
__typename?: 'SavedViewGroupTokenReturn';
|
||||
token: Scalars['String']['output'];
|
||||
tokenMetadata: SavedViewGroupToken;
|
||||
};
|
||||
|
||||
export type SavedViewGroupViewsInput = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -3750,6 +3767,7 @@ export type SavedViewGroupsInput = {
|
||||
export type SavedViewMutations = {
|
||||
__typename?: 'SavedViewMutations';
|
||||
createGroup: SavedViewGroup;
|
||||
createToken: SavedViewGroupTokenReturn;
|
||||
createView: SavedView;
|
||||
deleteGroup: Scalars['Boolean']['output'];
|
||||
deleteView: Scalars['Boolean']['output'];
|
||||
@@ -3763,6 +3781,11 @@ export type SavedViewMutationsCreateGroupArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type SavedViewMutationsCreateTokenArgs = {
|
||||
groupId: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SavedViewMutationsCreateViewArgs = {
|
||||
input: CreateSavedViewInput;
|
||||
};
|
||||
@@ -9196,6 +9219,8 @@ export type AllObjectTypes = {
|
||||
SavedViewGroup: SavedViewGroup,
|
||||
SavedViewGroupCollection: SavedViewGroupCollection,
|
||||
SavedViewGroupPermissionChecks: SavedViewGroupPermissionChecks,
|
||||
SavedViewGroupToken: SavedViewGroupToken,
|
||||
SavedViewGroupTokenReturn: SavedViewGroupTokenReturn,
|
||||
SavedViewMutations: SavedViewMutations,
|
||||
SavedViewPermissionChecks: SavedViewPermissionChecks,
|
||||
Scope: Scope,
|
||||
@@ -10315,8 +10340,22 @@ export type SavedViewGroupCollectionFieldArgs = {
|
||||
export type SavedViewGroupPermissionChecksFieldArgs = {
|
||||
canUpdate: {},
|
||||
}
|
||||
export type SavedViewGroupTokenFieldArgs = {
|
||||
createdAt: {},
|
||||
lastUsed: {},
|
||||
lifespan: {},
|
||||
projects: {},
|
||||
savedViewGroupId: {},
|
||||
tokenId: {},
|
||||
user: {},
|
||||
}
|
||||
export type SavedViewGroupTokenReturnFieldArgs = {
|
||||
token: {},
|
||||
tokenMetadata: {},
|
||||
}
|
||||
export type SavedViewMutationsFieldArgs = {
|
||||
createGroup: SavedViewMutationsCreateGroupArgs,
|
||||
createToken: SavedViewMutationsCreateTokenArgs,
|
||||
createView: SavedViewMutationsCreateViewArgs,
|
||||
deleteGroup: SavedViewMutationsDeleteGroupArgs,
|
||||
deleteView: SavedViewMutationsDeleteViewArgs,
|
||||
@@ -11072,6 +11111,8 @@ export type AllObjectFieldArgTypes = {
|
||||
SavedViewGroup: SavedViewGroupFieldArgs,
|
||||
SavedViewGroupCollection: SavedViewGroupCollectionFieldArgs,
|
||||
SavedViewGroupPermissionChecks: SavedViewGroupPermissionChecksFieldArgs,
|
||||
SavedViewGroupToken: SavedViewGroupTokenFieldArgs,
|
||||
SavedViewGroupTokenReturn: SavedViewGroupTokenReturnFieldArgs,
|
||||
SavedViewMutations: SavedViewMutationsFieldArgs,
|
||||
SavedViewPermissionChecks: SavedViewPermissionChecksFieldArgs,
|
||||
Scope: ScopeFieldArgs,
|
||||
|
||||
@@ -18,6 +18,7 @@ extend type SavedView {
|
||||
}
|
||||
|
||||
type SavedViewGroupPermissionChecks {
|
||||
canCreateToken: PermissionCheckResult!
|
||||
canUpdate: PermissionCheckResult!
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
extend type SavedViewGroup {
|
||||
shareLink: SavedViewGroupShareLink
|
||||
}
|
||||
|
||||
type SavedViewGroupShareLink {
|
||||
id: ID!
|
||||
# going to ignore this in the API for now, its in the DB
|
||||
# createdBy: LimitedUser
|
||||
validUntil: DateTime!
|
||||
createdAt: DateTime!
|
||||
content: String!
|
||||
revoked: Boolean!
|
||||
}
|
||||
|
||||
input SavedViewGroupShareInput {
|
||||
groupId: ID!
|
||||
projectId: ID!
|
||||
}
|
||||
|
||||
input SavedViewGroupShareUpdateInput {
|
||||
groupId: ID!
|
||||
projectId: ID!
|
||||
shareId: ID!
|
||||
}
|
||||
|
||||
extend type SavedViewMutations {
|
||||
share(input: SavedViewGroupShareInput!): SavedViewGroupShareLink!
|
||||
deleteShare(input: SavedViewGroupShareUpdateInput!): Boolean!
|
||||
disableShare(input: SavedViewGroupShareUpdateInput!): SavedViewGroupShareLink!
|
||||
enableShare(input: SavedViewGroupShareUpdateInput!): SavedViewGroupShareLink!
|
||||
}
|
||||
@@ -3722,6 +3722,7 @@ export type SavedViewGroup = {
|
||||
projectId: Scalars['ID']['output'];
|
||||
/** Resources that were used to find this group */
|
||||
resourceIds: Array<Scalars['String']['output']>;
|
||||
shareLink?: Maybe<SavedViewGroupShareLink>;
|
||||
title: Scalars['String']['output'];
|
||||
views: SavedViewCollection;
|
||||
};
|
||||
@@ -3740,9 +3741,30 @@ export type SavedViewGroupCollection = {
|
||||
|
||||
export type SavedViewGroupPermissionChecks = {
|
||||
__typename?: 'SavedViewGroupPermissionChecks';
|
||||
canCreateToken: PermissionCheckResult;
|
||||
canUpdate: PermissionCheckResult;
|
||||
};
|
||||
|
||||
export type SavedViewGroupShareInput = {
|
||||
groupId: Scalars['ID']['input'];
|
||||
projectId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type SavedViewGroupShareLink = {
|
||||
__typename?: 'SavedViewGroupShareLink';
|
||||
content: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
revoked: Scalars['Boolean']['output'];
|
||||
validUntil: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type SavedViewGroupShareUpdateInput = {
|
||||
groupId: Scalars['ID']['input'];
|
||||
projectId: Scalars['ID']['input'];
|
||||
shareId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type SavedViewGroupViewsInput = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -3779,7 +3801,11 @@ export type SavedViewMutations = {
|
||||
createGroup: SavedViewGroup;
|
||||
createView: SavedView;
|
||||
deleteGroup: Scalars['Boolean']['output'];
|
||||
deleteShare: Scalars['Boolean']['output'];
|
||||
deleteView: Scalars['Boolean']['output'];
|
||||
disableShare: SavedViewGroupShareLink;
|
||||
enableShare: SavedViewGroupShareLink;
|
||||
share: SavedViewGroupShareLink;
|
||||
updateGroup: SavedViewGroup;
|
||||
updateView: SavedView;
|
||||
};
|
||||
@@ -3800,11 +3826,31 @@ export type SavedViewMutationsDeleteGroupArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type SavedViewMutationsDeleteShareArgs = {
|
||||
input: SavedViewGroupShareUpdateInput;
|
||||
};
|
||||
|
||||
|
||||
export type SavedViewMutationsDeleteViewArgs = {
|
||||
input: DeleteSavedViewInput;
|
||||
};
|
||||
|
||||
|
||||
export type SavedViewMutationsDisableShareArgs = {
|
||||
input: SavedViewGroupShareUpdateInput;
|
||||
};
|
||||
|
||||
|
||||
export type SavedViewMutationsEnableShareArgs = {
|
||||
input: SavedViewGroupShareUpdateInput;
|
||||
};
|
||||
|
||||
|
||||
export type SavedViewMutationsShareArgs = {
|
||||
input: SavedViewGroupShareInput;
|
||||
};
|
||||
|
||||
|
||||
export type SavedViewMutationsUpdateGroupArgs = {
|
||||
input: UpdateSavedViewGroupInput;
|
||||
};
|
||||
@@ -6406,6 +6452,9 @@ export type ResolversTypes = {
|
||||
SavedViewGroup: ResolverTypeWrapper<SavedViewGroupGraphQLReturn>;
|
||||
SavedViewGroupCollection: ResolverTypeWrapper<Omit<SavedViewGroupCollection, 'items'> & { items: Array<ResolversTypes['SavedViewGroup']> }>;
|
||||
SavedViewGroupPermissionChecks: ResolverTypeWrapper<SavedViewGroupPermissionChecksGraphQLReturn>;
|
||||
SavedViewGroupShareInput: SavedViewGroupShareInput;
|
||||
SavedViewGroupShareLink: ResolverTypeWrapper<SavedViewGroupShareLink>;
|
||||
SavedViewGroupShareUpdateInput: SavedViewGroupShareUpdateInput;
|
||||
SavedViewGroupViewsInput: SavedViewGroupViewsInput;
|
||||
SavedViewGroupsInput: SavedViewGroupsInput;
|
||||
SavedViewMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
|
||||
@@ -6788,6 +6837,9 @@ export type ResolversParentTypes = {
|
||||
SavedViewGroup: SavedViewGroupGraphQLReturn;
|
||||
SavedViewGroupCollection: Omit<SavedViewGroupCollection, 'items'> & { items: Array<ResolversParentTypes['SavedViewGroup']> };
|
||||
SavedViewGroupPermissionChecks: SavedViewGroupPermissionChecksGraphQLReturn;
|
||||
SavedViewGroupShareInput: SavedViewGroupShareInput;
|
||||
SavedViewGroupShareLink: SavedViewGroupShareLink;
|
||||
SavedViewGroupShareUpdateInput: SavedViewGroupShareUpdateInput;
|
||||
SavedViewGroupViewsInput: SavedViewGroupViewsInput;
|
||||
SavedViewGroupsInput: SavedViewGroupsInput;
|
||||
SavedViewMutations: MutationsObjectGraphQLReturn;
|
||||
@@ -8246,6 +8298,7 @@ export type SavedViewGroupResolvers<ContextType = GraphQLContext, ParentType ext
|
||||
permissions?: Resolver<ResolversTypes['SavedViewGroupPermissionChecks'], ParentType, ContextType>;
|
||||
projectId?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
resourceIds?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
shareLink?: Resolver<Maybe<ResolversTypes['SavedViewGroupShareLink']>, ParentType, ContextType>;
|
||||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
views?: Resolver<ResolversTypes['SavedViewCollection'], ParentType, ContextType, RequireFields<SavedViewGroupViewsArgs, 'input'>>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
@@ -8259,15 +8312,29 @@ export type SavedViewGroupCollectionResolvers<ContextType = GraphQLContext, Pare
|
||||
};
|
||||
|
||||
export type SavedViewGroupPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewGroupPermissionChecks'] = ResolversParentTypes['SavedViewGroupPermissionChecks']> = {
|
||||
canCreateToken?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
canUpdate?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SavedViewGroupShareLinkResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewGroupShareLink'] = ResolversParentTypes['SavedViewGroupShareLink']> = {
|
||||
content?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
revoked?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
validUntil?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SavedViewMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewMutations'] = ResolversParentTypes['SavedViewMutations']> = {
|
||||
createGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<SavedViewMutationsCreateGroupArgs, 'input'>>;
|
||||
createView?: Resolver<ResolversTypes['SavedView'], ParentType, ContextType, RequireFields<SavedViewMutationsCreateViewArgs, 'input'>>;
|
||||
deleteGroup?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteGroupArgs, 'input'>>;
|
||||
deleteShare?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteShareArgs, 'input'>>;
|
||||
deleteView?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteViewArgs, 'input'>>;
|
||||
disableShare?: Resolver<ResolversTypes['SavedViewGroupShareLink'], ParentType, ContextType, RequireFields<SavedViewMutationsDisableShareArgs, 'input'>>;
|
||||
enableShare?: Resolver<ResolversTypes['SavedViewGroupShareLink'], ParentType, ContextType, RequireFields<SavedViewMutationsEnableShareArgs, 'input'>>;
|
||||
share?: Resolver<ResolversTypes['SavedViewGroupShareLink'], ParentType, ContextType, RequireFields<SavedViewMutationsShareArgs, 'input'>>;
|
||||
updateGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<SavedViewMutationsUpdateGroupArgs, 'input'>>;
|
||||
updateView?: Resolver<ResolversTypes['SavedView'], ParentType, ContextType, RequireFields<SavedViewMutationsUpdateViewArgs, 'input'>>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
@@ -9192,6 +9259,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
|
||||
SavedViewGroup?: SavedViewGroupResolvers<ContextType>;
|
||||
SavedViewGroupCollection?: SavedViewGroupCollectionResolvers<ContextType>;
|
||||
SavedViewGroupPermissionChecks?: SavedViewGroupPermissionChecksResolvers<ContextType>;
|
||||
SavedViewGroupShareLink?: SavedViewGroupShareLinkResolvers<ContextType>;
|
||||
SavedViewMutations?: SavedViewMutationsResolvers<ContextType>;
|
||||
SavedViewPermissionChecks?: SavedViewPermissionChecksResolvers<ContextType>;
|
||||
Scope?: ScopeResolvers<ContextType>;
|
||||
|
||||
@@ -78,7 +78,6 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
|
||||
return new NotFoundError(e.message)
|
||||
case Authz.PersonalProjectsLimitedError.code:
|
||||
case Authz.UngroupedSavedViewGroupLockError.code:
|
||||
return new BadRequestError(e.message)
|
||||
case Authz.DashboardNoProjectsError.code:
|
||||
return new BadRequestError(e.message)
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
SavedViewGroupApiToken,
|
||||
SavedViewGroupApiTokenRecord
|
||||
} from '@/modules/viewer/domain/types/savedViewGroupApiTokens'
|
||||
import type { Exact } from 'type-fest'
|
||||
|
||||
export type StoreSavedViewGroupApiToken = <
|
||||
T extends Exact<SavedViewGroupApiTokenRecord, T>
|
||||
>(
|
||||
token: T
|
||||
) => Promise<SavedViewGroupApiTokenRecord>
|
||||
|
||||
export type DeleteSavedViewGroupApiToken = (args: {
|
||||
tokenId: string
|
||||
}) => Promise<SavedViewGroupApiTokenRecord | null>
|
||||
|
||||
export type GetSavedViewGroupApiTokens = (args: {
|
||||
savedViewGroupId: string
|
||||
}) => Promise<SavedViewGroupApiToken[]>
|
||||
|
||||
export type GetSavedViewGroupApiToken = (args: {
|
||||
savedViewGroupId: string
|
||||
}) => Promise<SavedViewGroupApiToken | null>
|
||||
@@ -0,0 +1,14 @@
|
||||
export type SavedViewGroupApiTokenRecord = {
|
||||
tokenId: string
|
||||
projectId: string
|
||||
savedViewGroupId: string
|
||||
userId: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type SavedViewGroupApiToken = SavedViewGroupApiTokenRecord & {
|
||||
createdAt: Date
|
||||
lastUsed: Date
|
||||
lifespan: number | bigint
|
||||
revoked: boolean
|
||||
}
|
||||
@@ -63,6 +63,15 @@ const resolvers: Resolvers = {
|
||||
}
|
||||
},
|
||||
SavedViewGroupPermissionChecks: {
|
||||
canCreateToken: async (parent, _args, ctx) => {
|
||||
const savedViewGroupId = parent.savedViewGroup.id
|
||||
const authResult = await ctx.authPolicies.project.savedViews.canUpdateGroup({
|
||||
userId: ctx.userId,
|
||||
projectId: parent.savedViewGroup.projectId,
|
||||
savedViewGroupId
|
||||
})
|
||||
return toGraphqlResult(authResult)
|
||||
},
|
||||
canUpdate: async (parent, _args, ctx) => {
|
||||
const savedViewGroupId = parent.savedViewGroup.id
|
||||
const canUpdate = await ctx.authPolicies.project.savedViews.canUpdateGroup({
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
|
||||
import { db } from '@/db/knex'
|
||||
import { DashboardMalformedTokenError } from '@/modules/dashboards/errors/dashboards'
|
||||
import { createTokenFactory } from '@/modules/core/services/tokens'
|
||||
import {
|
||||
getApiTokenByIdFactory,
|
||||
storeApiTokenFactory,
|
||||
storeTokenResourceAccessDefinitionsFactory,
|
||||
storeTokenScopesFactory,
|
||||
updateApiTokenFactory
|
||||
} from '@/modules/core/repositories/tokens'
|
||||
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
|
||||
import dayjs from 'dayjs'
|
||||
import type { SavedViewGroupApiToken } from '@/modules/viewer/domain/types/savedViewGroupApiTokens'
|
||||
import {
|
||||
deleteSavedViewGroupApiTokenFactory,
|
||||
getSavedViewGroupApiTokenFactory,
|
||||
storeSavedViewGroupApiTokenFactory
|
||||
} from '@/modules/viewer/repositories/tokens'
|
||||
import { createSavedViewGroupTokenFactory } from '@/modules/viewer/services/tokens'
|
||||
import { getSavedViewGroupFactory } from '@/modules/viewer/repositories/dataLoaders/savedViews'
|
||||
|
||||
const formatSavedGroupViewTokenToSavedViewGroupShare = (
|
||||
token: SavedViewGroupApiToken
|
||||
) => {
|
||||
return {
|
||||
...token,
|
||||
id: token.tokenId,
|
||||
validUntil: dayjs(token.createdAt)
|
||||
.add(Number(token.lifespan), 'milliseconds')
|
||||
.toDate()
|
||||
}
|
||||
}
|
||||
|
||||
const resolvers: Resolvers = {
|
||||
SavedViewGroup: {
|
||||
shareLink: async (parent) => {
|
||||
const token = await getSavedViewGroupApiTokenFactory({ db })({
|
||||
savedViewGroupId: parent.id
|
||||
})
|
||||
if (!token) return null
|
||||
|
||||
return formatSavedGroupViewTokenToSavedViewGroupShare(token)
|
||||
}
|
||||
},
|
||||
SavedViewMutations: {
|
||||
share: async (_, { input }, ctx) => {
|
||||
const authResult = await ctx.authPolicies.project.savedViews.canCreateToken({
|
||||
userId: ctx.userId,
|
||||
projectId: input.projectId,
|
||||
savedViewGroupId: input.groupId
|
||||
})
|
||||
|
||||
throwIfAuthNotOk(authResult)
|
||||
const existingToken = await getSavedViewGroupApiTokenFactory({ db })({
|
||||
savedViewGroupId: input.groupId
|
||||
})
|
||||
|
||||
if (existingToken) {
|
||||
return formatSavedGroupViewTokenToSavedViewGroupShare(existingToken)
|
||||
}
|
||||
|
||||
const token = await createSavedViewGroupTokenFactory({
|
||||
getSavedViewGroup: getSavedViewGroupFactory({ loaders: ctx.loaders }),
|
||||
createToken: createTokenFactory({
|
||||
storeApiToken: storeApiTokenFactory({ db }),
|
||||
storeTokenScopes: storeTokenScopesFactory({ db }),
|
||||
storeTokenResourceAccessDefinitions:
|
||||
storeTokenResourceAccessDefinitionsFactory({
|
||||
db
|
||||
})
|
||||
}),
|
||||
getToken: getApiTokenByIdFactory({ db }),
|
||||
storeSavedViewGroupApiToken: storeSavedViewGroupApiTokenFactory({ db })
|
||||
})({
|
||||
projectId: input.projectId,
|
||||
savedViewGroupId: input.groupId,
|
||||
userId: ctx.userId!
|
||||
})
|
||||
return formatSavedGroupViewTokenToSavedViewGroupShare(token.tokenMetadata)
|
||||
},
|
||||
disableShare: async (_, { input }, ctx) => {
|
||||
const authResult = await ctx.authPolicies.project.savedViews.canCreateToken({
|
||||
userId: ctx.userId,
|
||||
projectId: input.projectId,
|
||||
savedViewGroupId: input.groupId
|
||||
})
|
||||
throwIfAuthNotOk(authResult)
|
||||
const token = await getSavedViewGroupApiTokenFactory({ db })({
|
||||
savedViewGroupId: input.groupId
|
||||
})
|
||||
if (!token) throw new DashboardMalformedTokenError()
|
||||
await updateApiTokenFactory({ db })(input.shareId, { revoked: true })
|
||||
return formatSavedGroupViewTokenToSavedViewGroupShare(token)
|
||||
},
|
||||
enableShare: async (_, { input }, ctx) => {
|
||||
const authResult = await ctx.authPolicies.project.savedViews.canCreateToken({
|
||||
userId: ctx.userId,
|
||||
projectId: input.projectId,
|
||||
savedViewGroupId: input.groupId
|
||||
})
|
||||
throwIfAuthNotOk(authResult)
|
||||
await updateApiTokenFactory({ db })(input.shareId, { revoked: false })
|
||||
const token = await getSavedViewGroupApiTokenFactory({ db })({
|
||||
savedViewGroupId: input.groupId
|
||||
})
|
||||
if (!token) throw new DashboardMalformedTokenError()
|
||||
return formatSavedGroupViewTokenToSavedViewGroupShare(token)
|
||||
},
|
||||
deleteShare: async (_, { input }, ctx) => {
|
||||
const authResult = await ctx.authPolicies.project.savedViews.canCreateToken({
|
||||
userId: ctx.userId,
|
||||
projectId: input.projectId,
|
||||
savedViewGroupId: input.groupId
|
||||
})
|
||||
throwIfAuthNotOk(authResult)
|
||||
await deleteSavedViewGroupApiTokenFactory({
|
||||
db
|
||||
})({
|
||||
tokenId: input.shareId
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default resolvers
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { Knex } from 'knex'
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('saved_view_group_api_tokens', (table) => {
|
||||
table
|
||||
.string('tokenId')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('api_tokens')
|
||||
.onDelete('cascade')
|
||||
table.string('savedViewGroupId').notNullable() // can't be a fk due to multiregionality
|
||||
table
|
||||
.string('projectId')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('streams')
|
||||
.onDelete('cascade')
|
||||
table
|
||||
.string('userId')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('users')
|
||||
.onDelete('cascade')
|
||||
table.string('content').notNullable()
|
||||
table.primary(['savedViewGroupId', 'tokenId'])
|
||||
})
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('saved_view_group_api_tokens')
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { buildTableHelper } from '@/modules/core/dbSchema'
|
||||
import { ApiTokens } from '@/modules/core/dbSchema'
|
||||
import type {
|
||||
DeleteSavedViewGroupApiToken,
|
||||
GetSavedViewGroupApiToken,
|
||||
GetSavedViewGroupApiTokens,
|
||||
StoreSavedViewGroupApiToken
|
||||
} from '@/modules/viewer/domain/operations/savedViewGroupApiTokens'
|
||||
import type {
|
||||
SavedViewGroupApiToken,
|
||||
SavedViewGroupApiTokenRecord
|
||||
} from '@/modules/viewer/domain/types/savedViewGroupApiTokens'
|
||||
import type { Knex } from 'knex'
|
||||
|
||||
export const SavedViewGroupApiTokens = buildTableHelper('saved_view_group_api_tokens', [
|
||||
'tokenId',
|
||||
'projectId',
|
||||
'savedViewGroupId',
|
||||
'userId',
|
||||
'content'
|
||||
])
|
||||
|
||||
const tables = {
|
||||
savedGroupApiTokens: (db: Knex) =>
|
||||
db<SavedViewGroupApiTokenRecord>(SavedViewGroupApiTokens.name)
|
||||
}
|
||||
|
||||
export const storeSavedViewGroupApiTokenFactory =
|
||||
(deps: { db: Knex }): StoreSavedViewGroupApiToken =>
|
||||
async (token) => {
|
||||
const [newToken] = await tables
|
||||
.savedGroupApiTokens(deps.db)
|
||||
.insert(token)
|
||||
.returning('*')
|
||||
return newToken
|
||||
}
|
||||
|
||||
export const deleteSavedViewGroupApiTokenFactory =
|
||||
(deps: { db: Knex }): DeleteSavedViewGroupApiToken =>
|
||||
async ({ tokenId }) => {
|
||||
const [deletedToken] = await tables
|
||||
.savedGroupApiTokens(deps.db)
|
||||
.where({ tokenId })
|
||||
.del()
|
||||
.returning('*')
|
||||
return deletedToken
|
||||
}
|
||||
|
||||
export const getSavedViewGroupApiTokensFactory =
|
||||
(deps: { db: Knex }): GetSavedViewGroupApiTokens =>
|
||||
async ({ savedViewGroupId }) => {
|
||||
const tokens = await tables
|
||||
.savedGroupApiTokens(deps.db)
|
||||
.orderBy(ApiTokens.col.createdAt)
|
||||
.join(ApiTokens.name, ApiTokens.col.id, SavedViewGroupApiTokens.col.tokenId)
|
||||
.select<SavedViewGroupApiToken[]>([
|
||||
...SavedViewGroupApiTokens.cols,
|
||||
ApiTokens.col.createdAt,
|
||||
ApiTokens.col.lastUsed,
|
||||
ApiTokens.col.lifespan,
|
||||
ApiTokens.col.revoked
|
||||
])
|
||||
.where({ savedViewGroupId })
|
||||
return tokens
|
||||
}
|
||||
|
||||
export const getSavedViewGroupApiTokenFactory =
|
||||
(deps: { db: Knex }): GetSavedViewGroupApiToken =>
|
||||
async ({ savedViewGroupId }) => {
|
||||
const token = await tables
|
||||
.savedGroupApiTokens(deps.db)
|
||||
.orderBy(ApiTokens.col.createdAt)
|
||||
.join(ApiTokens.name, ApiTokens.col.id, SavedViewGroupApiTokens.col.tokenId)
|
||||
.select<SavedViewGroupApiToken[]>([
|
||||
...SavedViewGroupApiTokens.cols,
|
||||
ApiTokens.col.createdAt,
|
||||
ApiTokens.col.lastUsed,
|
||||
ApiTokens.col.lifespan,
|
||||
ApiTokens.col.revoked
|
||||
])
|
||||
.where({ savedViewGroupId })
|
||||
.first()
|
||||
return token ?? null
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
CreateAndStoreUserToken,
|
||||
GetApiTokenById
|
||||
} from '@/modules/core/domain/tokens/operations'
|
||||
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
|
||||
import { LogicError } from '@/modules/shared/errors'
|
||||
import type { StoreSavedViewGroupApiToken } from '@/modules/viewer/domain/operations/savedViewGroupApiTokens'
|
||||
import type { GetSavedViewGroup } from '@/modules/viewer/domain/operations/savedViews'
|
||||
import type {
|
||||
SavedViewGroupApiToken,
|
||||
SavedViewGroupApiTokenRecord
|
||||
} from '@/modules/viewer/domain/types/savedViewGroupApiTokens'
|
||||
import { Scopes } from '@speckle/shared'
|
||||
import { SavedViewGroupNotFoundError } from '@speckle/shared/authz'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
export type CreateAndStoreSavedViewGroupToken = (args: {
|
||||
savedViewGroupId: string
|
||||
userId: string
|
||||
projectId: string
|
||||
lifespan?: number | bigint
|
||||
}) => Promise<{
|
||||
token: string
|
||||
tokenMetadata: SavedViewGroupApiToken
|
||||
}>
|
||||
|
||||
export const createSavedViewGroupTokenFactory =
|
||||
(deps: {
|
||||
getSavedViewGroup: GetSavedViewGroup
|
||||
createToken: CreateAndStoreUserToken
|
||||
getToken: GetApiTokenById
|
||||
storeSavedViewGroupApiToken: StoreSavedViewGroupApiToken
|
||||
}): CreateAndStoreSavedViewGroupToken =>
|
||||
async ({ projectId, savedViewGroupId, userId, lifespan }) => {
|
||||
const savedViewGroups = await deps.getSavedViewGroup({
|
||||
id: savedViewGroupId,
|
||||
projectId
|
||||
})
|
||||
|
||||
if (!savedViewGroups) throw new SavedViewGroupNotFoundError()
|
||||
if (projectId !== savedViewGroups.projectId) throw new LogicError()
|
||||
|
||||
const { id, token } = await deps.createToken({
|
||||
userId,
|
||||
name: `svgat-${cryptoRandomString({ length: 10 })}`,
|
||||
scopes: [Scopes.Streams.Read, Scopes.Users.Read],
|
||||
limitResources: [
|
||||
{
|
||||
id: projectId,
|
||||
type: TokenResourceIdentifierType.Project
|
||||
}
|
||||
],
|
||||
lifespan
|
||||
})
|
||||
|
||||
const tokenMetadata: SavedViewGroupApiTokenRecord = {
|
||||
userId,
|
||||
projectId,
|
||||
savedViewGroupId,
|
||||
tokenId: id,
|
||||
content: token
|
||||
}
|
||||
|
||||
await deps.storeSavedViewGroupApiToken(tokenMetadata)
|
||||
|
||||
const apiToken = await deps.getToken(id)
|
||||
|
||||
if (!apiToken) {
|
||||
throw new LogicError('Failed to create api token for saved view group')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
tokenMetadata: {
|
||||
revoked: false,
|
||||
...tokenMetadata,
|
||||
...pick(apiToken, 'createdAt', 'lastUsed', 'lifespan')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ApiTokenRecord } from '@/modules/auth/repositories'
|
||||
import { LogicError } from '@/modules/shared/errors'
|
||||
import type { SavedViewGroupApiTokenRecord } from '@/modules/viewer/domain/types/savedViewGroupApiTokens'
|
||||
import { createSavedViewGroupTokenFactory } from '@/modules/viewer/services/tokens'
|
||||
import { SavedViewGroupNotFoundError } from '@speckle/shared/authz'
|
||||
import { expect } from 'chai'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
|
||||
describe('createSavedViewGroupTokenFactory returns a function, that', () => {
|
||||
const savedViewGroupId = cryptoRandomString({ length: 9 })
|
||||
const projectId = cryptoRandomString({ length: 9 })
|
||||
const userId = cryptoRandomString({ length: 9 })
|
||||
|
||||
it('returns a token associated with the given dashboard', async () => {
|
||||
let token
|
||||
const createSavedViewGroupToken = createSavedViewGroupTokenFactory({
|
||||
getSavedViewGroup: async () => ({
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
projectId,
|
||||
name: null,
|
||||
authorId: null,
|
||||
resourceIds: [cryptoRandomString({ length: 10 })],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}),
|
||||
createToken: async () => ({
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
token: cryptoRandomString({ length: 20 })
|
||||
}),
|
||||
getToken: async () => ({} as ApiTokenRecord),
|
||||
storeSavedViewGroupApiToken: async (svgt) => {
|
||||
token = svgt
|
||||
return svgt
|
||||
}
|
||||
})
|
||||
|
||||
const result = await createSavedViewGroupToken({
|
||||
savedViewGroupId,
|
||||
projectId,
|
||||
userId
|
||||
})
|
||||
|
||||
expect(result.tokenMetadata.projectId).to.equal(projectId)
|
||||
expect(token).to.not.be.undefined
|
||||
})
|
||||
|
||||
it('throws NotFound if savedViewGroup is not found', async () => {
|
||||
const createSavedViewGroupToken = createSavedViewGroupTokenFactory({
|
||||
getSavedViewGroup: async () => undefined,
|
||||
createToken: async () => ({
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
token: cryptoRandomString({ length: 20 })
|
||||
}),
|
||||
getToken: async () => ({} as ApiTokenRecord),
|
||||
storeSavedViewGroupApiToken: async () => ({} as SavedViewGroupApiTokenRecord)
|
||||
})
|
||||
|
||||
const promise = createSavedViewGroupToken({ projectId, savedViewGroupId, userId })
|
||||
|
||||
expect(promise).to.eventually.throw(SavedViewGroupNotFoundError)
|
||||
})
|
||||
|
||||
it('throws LogicError if savedViewGroup belongs to another project', async () => {
|
||||
const createSavedViewGroupToken = createSavedViewGroupTokenFactory({
|
||||
getSavedViewGroup: async () => ({
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
projectId: cryptoRandomString({ length: 10 }), // wrong project
|
||||
name: null,
|
||||
authorId: null,
|
||||
resourceIds: [cryptoRandomString({ length: 10 })],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}),
|
||||
createToken: async () => ({
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
token: cryptoRandomString({ length: 20 })
|
||||
}),
|
||||
getToken: async () => ({} as ApiTokenRecord),
|
||||
storeSavedViewGroupApiToken: async () => ({} as SavedViewGroupApiTokenRecord)
|
||||
})
|
||||
|
||||
const promise = createSavedViewGroupToken({ projectId, savedViewGroupId, userId })
|
||||
|
||||
expect(promise).to.eventually.throw(LogicError)
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,7 @@ 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'
|
||||
import { canCreateSavedViewGroupTokenPolicy } from './project/savedViews/canCreateSavedViewGroupToken.js'
|
||||
|
||||
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
automate: {
|
||||
@@ -87,6 +88,7 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
canCreate: canCreateSavedViewPolicy(loaders),
|
||||
canUpdate: canUpdateSavedViewPolicy(loaders),
|
||||
canUpdateGroup: canUpdateSavedViewGroupPolicy(loaders),
|
||||
canCreateToken: canCreateSavedViewGroupTokenPolicy(loaders),
|
||||
canRead: canReadSavedViewPolicy(loaders),
|
||||
canMove: canMoveSavedViewPolicy(loaders),
|
||||
canEditTitle: canEditSavedViewTitlePolicy(loaders),
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OverridesOf } from '../../../../tests/helpers/types.js'
|
||||
import {
|
||||
getEnvFake,
|
||||
getProjectFake,
|
||||
getSavedViewGroupFake,
|
||||
getWorkspaceFake,
|
||||
getWorkspacePlanFake,
|
||||
getWorkspaceSsoProviderFake,
|
||||
getWorkspaceSsoSessionFake
|
||||
} from '../../../../tests/fakes.js'
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import {
|
||||
ProjectNotEnoughPermissionsError,
|
||||
ServerNoAccessError,
|
||||
UngroupedSavedViewGroupLockError,
|
||||
WorkspaceNoAccessError,
|
||||
WorkspacePlanNoFeatureAccessError,
|
||||
WorkspacesNotEnabledError
|
||||
} from '../../../domain/authErrors.js'
|
||||
import { canCreateSavedViewGroupTokenPolicy } from './canCreateSavedViewGroupToken.js'
|
||||
|
||||
describe('canCreateSavedViewGroupTokenPolicy', () => {
|
||||
const buildSUT = (
|
||||
overrides?: OverridesOf<typeof canCreateSavedViewGroupTokenPolicy>
|
||||
) =>
|
||||
canCreateSavedViewGroupTokenPolicy({
|
||||
getSavedViewGroup: getSavedViewGroupFake({
|
||||
projectId: 'project-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,
|
||||
...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',
|
||||
savedViewGroupId: 'saved-group-id'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspaceNoAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
describe('w/ workspaced project', async () => {
|
||||
const buildWorkspacedSUT = (
|
||||
overrides?: OverridesOf<typeof canCreateSavedViewGroupTokenPolicy>
|
||||
) =>
|
||||
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 if user is project owner', async () => {
|
||||
const sut = buildWorkspacedSUT()
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewGroupId: 'saved-group-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',
|
||||
savedViewGroupId: 'saved-group-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',
|
||||
savedViewGroupId: 'saved-group-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',
|
||||
savedViewGroupId: 'saved-group-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: 'aaa',
|
||||
projectId: 'project-id',
|
||||
savedViewGroupId: 'saved-group-id'
|
||||
})
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ServerNoAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('fails if not owner and not the author', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getSavedViewGroup: getSavedViewGroupFake({
|
||||
projectId: 'project-id',
|
||||
authorId: 'another-user-id'
|
||||
}),
|
||||
getProjectRole: async () => Roles.Stream.Contributor
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewGroupId: 'saved-group-id'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ProjectNotEnoughPermissionsError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('fails if updating default group', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getSavedViewGroup: getSavedViewGroupFake({
|
||||
projectId: 'project-id',
|
||||
id: 'default-XXX'
|
||||
})
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewGroupId: 'default-XXX'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: UngroupedSavedViewGroupLockError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('fails if workspace plan is free', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getWorkspacePlan: getWorkspacePlanFake({
|
||||
workspaceId: 'workspace-id',
|
||||
name: 'free'
|
||||
})
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewGroupId: 'default-XXX'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspacePlanNoFeatureAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('fails if user is author', async () => {
|
||||
const sut = buildWorkspacedSUT({
|
||||
getSavedViewGroup: getSavedViewGroupFake({
|
||||
projectId: 'project-id',
|
||||
authorId: 'user-id'
|
||||
}),
|
||||
getProjectRole: async () => Roles.Stream.Contributor
|
||||
})
|
||||
|
||||
const result = await sut({
|
||||
userId: 'user-id',
|
||||
projectId: 'project-id',
|
||||
savedViewGroupId: 'saved-group-id'
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ProjectNotEnoughPermissionsError.code
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import {
|
||||
ProjectNoAccessError,
|
||||
ProjectNotEnoughPermissionsError,
|
||||
ProjectNotFoundError,
|
||||
SavedViewGroupNotFoundError,
|
||||
ServerNoAccessError,
|
||||
ServerNoSessionError,
|
||||
ServerNotEnoughPermissionsError,
|
||||
UngroupedSavedViewGroupLockError,
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceNotEnoughPermissionsError,
|
||||
WorkspacePlanNoFeatureAccessError,
|
||||
WorkspaceReadOnlyError,
|
||||
WorkspacesNotEnabledError,
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../../../domain/authErrors.js'
|
||||
import {
|
||||
MaybeUserContext,
|
||||
ProjectContext,
|
||||
SavedViewGroupContext
|
||||
} from '../../../domain/context.js'
|
||||
import { Loaders } from '../../../domain/loaders.js'
|
||||
import { AuthPolicy } from '../../../domain/policies.js'
|
||||
import {
|
||||
ensureImplicitProjectMemberWithWriteAccessFragment,
|
||||
ensureMinimumProjectRoleFragment
|
||||
} from '../../../fragments/projects.js'
|
||||
import { ensureCanAccessSavedViewGroupFragment } from '../../../fragments/savedViews.js'
|
||||
import { err, ok } from 'true-myth/result'
|
||||
|
||||
export const canCreateSavedViewGroupTokenPolicy: AuthPolicy<
|
||||
| typeof Loaders.getSavedViewGroup
|
||||
| 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.getProjectRole,
|
||||
MaybeUserContext & ProjectContext & SavedViewGroupContext,
|
||||
InstanceType<
|
||||
| typeof SavedViewGroupNotFoundError
|
||||
| typeof ProjectNotFoundError
|
||||
| typeof ServerNoAccessError
|
||||
| typeof ServerNoSessionError
|
||||
| typeof ProjectNoAccessError
|
||||
| typeof WorkspaceNoAccessError
|
||||
| typeof WorkspaceSsoSessionNoAccessError
|
||||
| typeof ServerNotEnoughPermissionsError
|
||||
| typeof ProjectNotEnoughPermissionsError
|
||||
| typeof WorkspaceNotEnoughPermissionsError
|
||||
| typeof WorkspacesNotEnabledError
|
||||
| typeof WorkspaceReadOnlyError
|
||||
| typeof WorkspacePlanNoFeatureAccessError
|
||||
| typeof UngroupedSavedViewGroupLockError
|
||||
>
|
||||
> =
|
||||
(loaders) =>
|
||||
async ({ userId, projectId, savedViewGroupId }) => {
|
||||
const canUseSavedViews = await ensureCanAccessSavedViewGroupFragment(loaders)({
|
||||
userId,
|
||||
projectId,
|
||||
savedViewGroupId,
|
||||
access: 'write'
|
||||
})
|
||||
|
||||
if (canUseSavedViews.isErr) return err(canUseSavedViews.error)
|
||||
|
||||
const env = await loaders.getEnv()
|
||||
const project = await loaders.getProject({ projectId })
|
||||
|
||||
if (!!project?.workspaceId && env.FF_WORKSPACES_MODULE_ENABLED) {
|
||||
// Ensure owner-level access and valid plan
|
||||
const ensuredProjectRole =
|
||||
await ensureImplicitProjectMemberWithWriteAccessFragment(loaders)({
|
||||
userId,
|
||||
projectId,
|
||||
role: Roles.Stream.Owner
|
||||
})
|
||||
if (ensuredProjectRole.isErr) {
|
||||
return err(ensuredProjectRole.error)
|
||||
}
|
||||
|
||||
const plan = await loaders.getWorkspacePlan({ workspaceId: project.workspaceId })
|
||||
|
||||
switch (plan?.name) {
|
||||
case 'academia':
|
||||
case 'enterprise':
|
||||
case 'pro':
|
||||
case 'proUnlimited':
|
||||
case 'proUnlimitedInvoiced':
|
||||
case 'team':
|
||||
case 'teamUnlimited':
|
||||
case 'teamUnlimitedInvoiced':
|
||||
case 'unlimited':
|
||||
return ok()
|
||||
case 'free':
|
||||
default:
|
||||
return err(new WorkspacePlanNoFeatureAccessError())
|
||||
}
|
||||
} else {
|
||||
// Ensure project owner
|
||||
const isProjectOwner = await ensureMinimumProjectRoleFragment(loaders)({
|
||||
userId: userId!,
|
||||
projectId,
|
||||
role: Roles.Stream.Owner
|
||||
})
|
||||
if (isProjectOwner.isErr) {
|
||||
return err(isProjectOwner.error)
|
||||
}
|
||||
|
||||
return ok()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user