diff --git a/packages/backend/server/src/modules/users/resolver.ts b/packages/backend/server/src/modules/users/resolver.ts index 6f605f9899..94cbc2c81d 100644 --- a/packages/backend/server/src/modules/users/resolver.ts +++ b/packages/backend/server/src/modules/users/resolver.ts @@ -19,7 +19,13 @@ import { Auth, CurrentUser, Public, Publicable } from '../auth/guard'; import { FeatureManagementService } from '../features'; import { QuotaService } from '../quota'; import { AvatarStorage } from '../storage'; -import { DeleteAccount, RemoveAvatar, UserQuotaType, UserType } from './types'; +import { + DeleteAccount, + RemoveAvatar, + UserOrLimitedUser, + UserQuotaType, + UserType, +} from './types'; import { UsersService } from './users'; /** @@ -77,14 +83,17 @@ export class UserResolver { ttl: 60, }, }) - @Query(() => UserType, { + @Query(() => UserOrLimitedUser, { name: 'user', description: 'Get user by email', nullable: true, }) @Public() - async user(@Args('email') email: string) { - if (!(await this.feature.canEarlyAccess(email))) { + async user( + @CurrentUser() currentUser?: UserType, + @Args('email') email?: string + ) { + if (!email || !(await this.feature.canEarlyAccess(email))) { return new GraphQLError( `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`, { @@ -95,13 +104,16 @@ export class UserResolver { } ); } + // TODO: need to limit a user can only get another user witch is in the same workspace const user = await this.users.findUserByEmail(email); - if (user?.password) { - const userResponse: UserType = user; - userResponse.hasPassword = true; - } - return user; + if (currentUser) return user; + + // only return limited info when not logged in + return { + email: user?.email, + hasPassword: !!user?.password, + }; } @Throttle({ default: { limit: 10, ttl: 60 } }) diff --git a/packages/backend/server/src/modules/users/types.ts b/packages/backend/server/src/modules/users/types.ts index 8e95409f09..0da51645af 100644 --- a/packages/backend/server/src/modules/users/types.ts +++ b/packages/backend/server/src/modules/users/types.ts @@ -1,4 +1,4 @@ -import { Field, Float, ID, ObjectType } from '@nestjs/graphql'; +import { createUnionType, Field, Float, ID, ObjectType } from '@nestjs/graphql'; import type { User } from '@prisma/client'; @ObjectType('UserQuotaHumanReadable') @@ -67,6 +67,29 @@ export class UserType implements Partial { hasPassword?: boolean; } +@ObjectType() +export class LimitedUserType implements Partial { + @Field({ description: 'User email' }) + email!: string; + + @Field(() => Boolean, { + description: 'User password has been set', + nullable: true, + }) + hasPassword?: boolean; +} + +export const UserOrLimitedUser = createUnionType({ + name: 'UserOrLimitedUser', + types: () => [UserType, LimitedUserType] as const, + resolveType(value) { + if (value.id) { + return UserType; + } + return LimitedUserType; + }, +}); + @ObjectType() export class DeleteAccount { @Field() diff --git a/packages/backend/server/src/modules/workspaces/management.ts b/packages/backend/server/src/modules/workspaces/management.ts index 1981c6859c..b5a74f94ac 100644 --- a/packages/backend/server/src/modules/workspaces/management.ts +++ b/packages/backend/server/src/modules/workspaces/management.ts @@ -13,13 +13,17 @@ import { CloudThrottlerGuard, Throttle } from '../../throttler'; import { Auth, CurrentUser } from '../auth'; import { FeatureManagementService, FeatureType } from '../features'; import { UserType } from '../users'; +import { PermissionService } from './permission'; import { WorkspaceType } from './types'; @UseGuards(CloudThrottlerGuard) @Auth() @Resolver(() => WorkspaceType) export class WorkspaceManagementResolver { - constructor(private readonly feature: FeatureManagementService) {} + constructor( + private readonly feature: FeatureManagementService, + private readonly permission: PermissionService + ) {} @Throttle({ default: { @@ -77,6 +81,51 @@ export class WorkspaceManagementResolver { return this.feature.listFeatureWorkspaces(feature); } + @Mutation(() => Boolean) + async setWorkspaceExperimentalFeature( + @CurrentUser() user: UserType, + @Args('workspaceId') workspaceId: string, + @Args('feature', { type: () => FeatureType }) feature: FeatureType, + @Args('enable') enable: boolean + ): Promise { + if (!(await this.feature.canEarlyAccess(user.email))) { + throw new ForbiddenException('You are not allowed to do this'); + } + + const owner = await this.permission.getWorkspaceOwner(workspaceId); + const availableFeatures = await this.availableFeatures(user); + if (owner.user.id !== user.id || !availableFeatures.includes(feature)) { + throw new ForbiddenException('You are not allowed to do this'); + } + + if (enable) { + return await this.feature + .addWorkspaceFeatures( + workspaceId, + feature, + undefined, + 'add by experimental feature api' + ) + .then(id => id > 0); + } else { + return await this.feature.removeWorkspaceFeature(workspaceId, feature); + } + } + + @ResolveField(() => [FeatureType], { + description: 'Available features of workspace', + complexity: 2, + }) + async availableFeatures( + @CurrentUser() user: UserType + ): Promise { + if (await this.feature.canEarlyAccess(user.email)) { + return [FeatureType.Copilot]; + } else { + return []; + } + } + @ResolveField(() => [FeatureType], { description: 'Enabled features of workspace', complexity: 2, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index acbf3f2399..949bea6851 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -131,6 +131,9 @@ type WorkspaceType { """Owner of workspace""" owner: UserType! + """Available features of workspace""" + availableFeatures: [FeatureType!]! + """Enabled features of workspace""" features: [FeatureType!]! @@ -299,11 +302,21 @@ type Query { currentUser: UserType """Get user by email""" - user(email: String!): UserType + user(email: String!): UserOrLimitedUser earlyAccessUsers: [UserType!]! prices: [SubscriptionPrice!]! } +union UserOrLimitedUser = UserType | LimitedUserType + +type LimitedUserType { + """User email""" + email: String! + + """User password has been set""" + hasPassword: Boolean +} + type Mutation { signUp(name: String!, email: String!, password: String!): UserType! signIn(email: String!, password: String!): UserType! @@ -326,6 +339,7 @@ type Mutation { leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean! addWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int! removeWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int! + setWorkspaceExperimentalFeature(workspaceId: String!, feature: FeatureType!, enable: Boolean!): Boolean! sharePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "renamed to publicPage") publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage! revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage") diff --git a/packages/backend/server/tests/app.e2e.ts b/packages/backend/server/tests/app.e2e.ts index 04ee701e4b..175f58b597 100644 --- a/packages/backend/server/tests/app.e2e.ts +++ b/packages/backend/server/tests/app.e2e.ts @@ -114,8 +114,12 @@ test('should find default user', async t => { query: ` query { user(email: "alex.yang@example.org") { - email - avatarUrl + ... on UserType { + email + } + ... on LimitedUserType { + email + } } } `, diff --git a/packages/frontend/graphql/src/graphql/get-user.gql b/packages/frontend/graphql/src/graphql/get-user.gql index 8b9f82e343..82ad9e3667 100644 --- a/packages/frontend/graphql/src/graphql/get-user.gql +++ b/packages/frontend/graphql/src/graphql/get-user.gql @@ -1,9 +1,16 @@ query getUser($email: String!) { user(email: $email) { - id - name - avatarUrl - email - hasPassword + __typename + ... on UserType { + id + name + avatarUrl + email + hasPassword + } + ... on LimitedUserType { + email + hasPassword + } } } diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 23e56cd600..18cc67d522 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -345,11 +345,31 @@ export const getUserQuery = { query: ` query getUser($email: String!) { user(email: $email) { - id - name - avatarUrl - email - hasPassword + __typename + ... on UserType { + id + name + avatarUrl + email + hasPassword + } + ... on LimitedUserType { + email + hasPassword + } + } +}`, +}; + +export const getWorkspaceFeaturesQuery = { + id: 'getWorkspaceFeaturesQuery' as const, + operationName: 'getWorkspaceFeatures', + definitionName: 'workspace', + containsFile: false, + query: ` +query getWorkspaceFeatures($workspaceId: String!) { + workspace(id: $workspaceId) { + features } }`, }; @@ -383,19 +403,6 @@ query getWorkspacePublicPages($workspaceId: String!) { }`, }; -export const getWorkspaceFeaturesQuery = { - id: 'getWorkspaceFeaturesQuery' as const, - operationName: 'getWorkspaceFeatures', - definitionName: 'workspace', - containsFile: false, - query: ` -query getWorkspaceFeatures($workspaceId: String!) { - workspace(id: $workspaceId) { - features - } -}`, -}; - export const getWorkspaceQuery = { id: 'getWorkspaceQuery' as const, operationName: 'getWorkspace', @@ -773,6 +780,34 @@ mutation uploadAvatar($avatar: Upload!) { }`, }; +export const availableFeaturesQuery = { + id: 'availableFeaturesQuery' as const, + operationName: 'availableFeatures', + definitionName: 'workspace', + containsFile: false, + query: ` +query availableFeatures($id: String!) { + workspace(id: $id) { + availableFeatures + } +}`, +}; + +export const setWorkspaceExperimentalFeatureMutation = { + id: 'setWorkspaceExperimentalFeatureMutation' as const, + operationName: 'setWorkspaceExperimentalFeature', + definitionName: 'setWorkspaceExperimentalFeature', + containsFile: false, + query: ` +mutation setWorkspaceExperimentalFeature($workspaceId: String!, $feature: FeatureType!, $enable: Boolean!) { + setWorkspaceExperimentalFeature( + workspaceId: $workspaceId + feature: $feature + enable: $enable + ) +}`, +}; + export const addWorkspaceFeatureMutation = { id: 'addWorkspaceFeatureMutation' as const, operationName: 'addWorkspaceFeature', diff --git a/packages/frontend/graphql/src/graphql/workspace-experimental-feature-get.gql b/packages/frontend/graphql/src/graphql/workspace-experimental-feature-get.gql new file mode 100644 index 0000000000..2720fa6fe3 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-experimental-feature-get.gql @@ -0,0 +1,5 @@ +query availableFeatures($id: String!) { + workspace(id: $id) { + availableFeatures + } +} diff --git a/packages/frontend/graphql/src/graphql/workspace-experimental-feature-set.gql b/packages/frontend/graphql/src/graphql/workspace-experimental-feature-set.gql new file mode 100644 index 0000000000..1b9ce1f683 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-experimental-feature-set.gql @@ -0,0 +1,11 @@ +mutation setWorkspaceExperimentalFeature( + $workspaceId: String! + $feature: FeatureType! + $enable: Boolean! +) { + setWorkspaceExperimentalFeature( + workspaceId: $workspaceId + feature: $feature + enable: $enable + ) +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index e430f2edd3..5a279e5d43 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -363,14 +363,30 @@ export type GetUserQueryVariables = Exact<{ export type GetUserQuery = { __typename?: 'Query'; - user: { - __typename?: 'UserType'; - id: string; - name: string; - avatarUrl: string | null; - email: string; - hasPassword: boolean | null; - } | null; + user: + | { + __typename: 'LimitedUserType'; + email: string; + hasPassword: boolean | null; + } + | { + __typename: 'UserType'; + id: string; + name: string; + avatarUrl: string | null; + email: string; + hasPassword: boolean | null; + } + | null; +}; + +export type GetWorkspaceFeaturesQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type GetWorkspaceFeaturesQuery = { + __typename?: 'Query'; + workspace: { __typename?: 'WorkspaceType'; features: Array }; }; export type GetWorkspacePublicByIdQueryVariables = Exact<{ @@ -398,15 +414,6 @@ export type GetWorkspacePublicPagesQuery = { }; }; -export type GetWorkspaceFeaturesQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; -}>; - -export type GetWorkspaceFeaturesQuery = { - __typename?: 'Query'; - workspace: { __typename?: 'WorkspaceType'; features: Array }; -}; - export type GetWorkspaceQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -739,6 +746,29 @@ export type UploadAvatarMutation = { }; }; +export type AvailableFeaturesQueryVariables = Exact<{ + id: Scalars['String']['input']; +}>; + +export type AvailableFeaturesQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + availableFeatures: Array; + }; +}; + +export type SetWorkspaceExperimentalFeatureMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + feature: FeatureType; + enable: Scalars['Boolean']['input']; +}>; + +export type SetWorkspaceExperimentalFeatureMutation = { + __typename?: 'Mutation'; + setWorkspaceExperimentalFeature: boolean; +}; + export type AddWorkspaceFeatureMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; feature: FeatureType; @@ -857,6 +887,11 @@ export type Queries = variables: GetUserQueryVariables; response: GetUserQuery; } + | { + name: 'getWorkspaceFeaturesQuery'; + variables: GetWorkspaceFeaturesQueryVariables; + response: GetWorkspaceFeaturesQuery; + } | { name: 'getWorkspacePublicByIdQuery'; variables: GetWorkspacePublicByIdQueryVariables; @@ -867,11 +902,6 @@ export type Queries = variables: GetWorkspacePublicPagesQueryVariables; response: GetWorkspacePublicPagesQuery; } - | { - name: 'getWorkspaceFeaturesQuery'; - variables: GetWorkspaceFeaturesQueryVariables; - response: GetWorkspaceFeaturesQuery; - } | { name: 'getWorkspaceQuery'; variables: GetWorkspaceQueryVariables; @@ -917,6 +947,11 @@ export type Queries = variables: SubscriptionQueryVariables; response: SubscriptionQuery; } + | { + name: 'availableFeaturesQuery'; + variables: AvailableFeaturesQueryVariables; + response: AvailableFeaturesQuery; + } | { name: 'listWorkspaceFeaturesQuery'; variables: ListWorkspaceFeaturesQueryVariables; @@ -1064,6 +1099,11 @@ export type Mutations = variables: UploadAvatarMutationVariables; response: UploadAvatarMutation; } + | { + name: 'setWorkspaceExperimentalFeatureMutation'; + variables: SetWorkspaceExperimentalFeatureMutationVariables; + response: SetWorkspaceExperimentalFeatureMutation; + } | { name: 'addWorkspaceFeatureMutation'; variables: AddWorkspaceFeatureMutationVariables;