diff --git a/packages/backend/server/migrations/20231103080802_permission/migration.sql b/packages/backend/server/migrations/20231103080802_permission/migration.sql new file mode 100644 index 0000000000..ec6f28f4c3 --- /dev/null +++ b/packages/backend/server/migrations/20231103080802_permission/migration.sql @@ -0,0 +1,61 @@ +-- DropForeignKey +ALTER TABLE "user_workspace_permissions" DROP CONSTRAINT "user_workspace_permissions_entity_id_fkey"; + +-- DropForeignKey +ALTER TABLE "user_workspace_permissions" DROP CONSTRAINT "user_workspace_permissions_workspace_id_fkey"; + +-- CreateTable +CREATE TABLE "workspace_pages" ( + "workspace_id" VARCHAR(36) NOT NULL, + "page_id" VARCHAR(36) NOT NULL, + "public" BOOLEAN NOT NULL DEFAULT false, + "mode" SMALLINT NOT NULL DEFAULT 0, + + CONSTRAINT "workspace_pages_pkey" PRIMARY KEY ("workspace_id","page_id") +); + +-- CreateTable +CREATE TABLE "workspace_user_permissions" ( + "id" VARCHAR(36) NOT NULL, + "workspace_id" VARCHAR(36) NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "type" SMALLINT NOT NULL, + "accepted" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_user_permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_page_user_permissions" ( + "id" VARCHAR(36) NOT NULL, + "workspace_id" VARCHAR(36) NOT NULL, + "page_id" VARCHAR(36) NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "type" SMALLINT NOT NULL, + "accepted" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_page_user_permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_user_permissions_workspace_id_user_id_key" ON "workspace_user_permissions"("workspace_id", "user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_page_user_permissions_workspace_id_page_id_user_i_key" ON "workspace_page_user_permissions"("workspace_id", "page_id", "user_id"); + +-- AddForeignKey +ALTER TABLE "workspace_pages" ADD CONSTRAINT "workspace_pages_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_user_permissions" ADD CONSTRAINT "workspace_user_permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_user_permissions" ADD CONSTRAINT "workspace_user_permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_page_user_permissions" ADD CONSTRAINT "workspace_page_user_permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_page_user_permissions" ADD CONSTRAINT "workspace_page_user_permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 0004df362c..892810ca42 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -9,51 +9,108 @@ datasource db { url = env("DATABASE_URL") } +model User { + id String @id @default(uuid()) @db.VarChar + name String + email String @unique + emailVerified DateTime? @map("email_verified") + // image field is for the next-auth + avatarUrl String? @map("avatar_url") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + /// Not available if user signed up through OAuth providers + password String? @db.VarChar + + accounts Account[] + sessions Session[] + features UserFeatureGates[] + customer UserStripeCustomer? + subscription UserSubscription? + invoices UserInvoice[] + workspacePermissions WorkspaceUserPermission[] + pagePermissions WorkspacePageUserPermission[] + + @@map("users") +} + model Workspace { - id String @id @default(uuid()) @db.VarChar + id String @id @default(uuid()) @db.VarChar public Boolean - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - users UserWorkspacePermission[] + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + pages WorkspacePage[] + permissions WorkspaceUserPermission[] + pagePermissions WorkspacePageUserPermission[] @@map("workspaces") } -model UserWorkspacePermission { - id String @id @default(uuid()) @db.VarChar - workspaceId String @map("workspace_id") @db.VarChar - subPageId String? @map("sub_page_id") @db.VarChar - userId String? @map("entity_id") @db.VarChar +// Table for workspace page meta data +// NOTE: +// We won't make sure every page has a corresponding record in this table. +// Only the ones that have ever changed will have records here, +// and for others we will make sure it's has a default value return in our bussiness logic. +model WorkspacePage { + workspaceId String @map("workspace_id") @db.VarChar(36) + pageId String @map("page_id") @db.VarChar(36) + public Boolean @default(false) + // Page/Edgeless + mode Int @default(0) @db.SmallInt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@id([workspaceId, pageId]) + @@map("workspace_pages") +} + +// @deprecated, use WorkspaceUserPermission +model DeprecatedUserWorkspacePermission { + id String @id @default(uuid()) @db.VarChar + workspaceId String @map("workspace_id") @db.VarChar + subPageId String? @map("sub_page_id") @db.VarChar + userId String? @map("entity_id") @db.VarChar /// Read/Write/Admin/Owner - type Int @db.SmallInt + type Int @db.SmallInt /// Whether the permission invitation is accepted by the user - accepted Boolean @default(false) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + accepted Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@unique([workspaceId, subPageId, userId]) @@map("user_workspace_permissions") } -model User { - id String @id @default(uuid()) @db.VarChar - name String - email String @unique - emailVerified DateTime? @map("email_verified") - // image field is for the next-auth - avatarUrl String? @map("avatar_url") @db.VarChar - accounts Account[] - sessions Session[] - workspaces UserWorkspacePermission[] - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - /// Not available if user signed up through OAuth providers - password String? @db.VarChar - features UserFeatureGates[] - customer UserStripeCustomer? - subscription UserSubscription? - invoices UserInvoice[] +model WorkspaceUserPermission { + id String @id @default(uuid()) @db.VarChar(36) + workspaceId String @map("workspace_id") @db.VarChar(36) + userId String @map("user_id") @db.VarChar(36) + // Read/Write + type Int @db.SmallInt + /// Whether the permission invitation is accepted by the user + accepted Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - @@map("users") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@unique([workspaceId, userId]) + @@map("workspace_user_permissions") +} + +model WorkspacePageUserPermission { + id String @id @default(uuid()) @db.VarChar(36) + workspaceId String @map("workspace_id") @db.VarChar(36) + pageId String @map("page_id") @db.VarChar(36) + userId String @map("user_id") @db.VarChar(36) + // Read/Write + type Int @db.SmallInt + /// Whether the permission invitation is accepted by the user + accepted Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@unique([workspaceId, pageId, userId]) + @@map("workspace_page_user_permissions") } model UserFeatureGates { diff --git a/packages/backend/server/src/data/commands/create.ts b/packages/backend/server/src/data/commands/create.ts index 2ebaa3c9ec..f8faf3a744 100644 --- a/packages/backend/server/src/data/commands/create.ts +++ b/packages/backend/server/src/data/commands/create.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Logger } from '@nestjs/common'; -import { camelCase, snakeCase, upperFirst } from 'lodash-es'; +import { camelCase, kebabCase, upperFirst } from 'lodash-es'; import { Command, CommandRunner, @@ -45,7 +45,7 @@ export class CreateCommand extends CommandRunner { const timestamp = Date.now(); const content = this.createScript(upperFirst(camelCase(name)) + timestamp); - const fileName = `${timestamp}-${snakeCase(name)}.ts`; + const fileName = `${timestamp}-${kebabCase(name)}.ts`; const filePath = join( fileURLToPath(import.meta.url), '../../migrations', @@ -54,6 +54,7 @@ export class CreateCommand extends CommandRunner { this.logger.log(`Creating ${fileName}...`); writeFileSync(filePath, content); + this.logger.log('Migration file created at', filePath); this.logger.log('Done'); } diff --git a/packages/backend/server/src/data/commands/run.ts b/packages/backend/server/src/data/commands/run.ts index f9618ab08e..5168d9b493 100644 --- a/packages/backend/server/src/data/commands/run.ts +++ b/packages/backend/server/src/data/commands/run.ts @@ -84,6 +84,7 @@ export class RunCommand extends CommandRunner { done.push(migration); } catch (e) { this.logger.error('Failed to run data migration', e); + process.exit(1); } } diff --git a/packages/backend/server/src/data/migrations/1699005339766-page-permission.ts b/packages/backend/server/src/data/migrations/1699005339766-page-permission.ts new file mode 100644 index 0000000000..86c9afc42d --- /dev/null +++ b/packages/backend/server/src/data/migrations/1699005339766-page-permission.ts @@ -0,0 +1,87 @@ +import { PrismaService } from '../../prisma'; + +export class PagePermission1699005339766 { + // do the migration + static async up(db: PrismaService) { + const turn = 0; + const lastTurnCount = 50; + const done = new Set(); + + while (lastTurnCount === 50) { + const workspaces = await db.workspace.findMany({ + skip: turn * 50, + take: 50, + orderBy: { + createdAt: 'asc', + }, + }); + + for (const workspace of workspaces) { + if (done.has(workspace.id)) { + continue; + } + + const oldPermissions = + await db.deprecatedUserWorkspacePermission.findMany({ + where: { + workspaceId: workspace.id, + }, + }); + + for (const oldPermission of oldPermissions) { + // mark subpage public + if (oldPermission.subPageId) { + const existed = await db.workspacePage.findUnique({ + where: { + workspaceId_pageId: { + workspaceId: oldPermission.workspaceId, + pageId: oldPermission.subPageId, + }, + }, + }); + if (!existed) { + await db.workspacePage.create({ + select: null, + data: { + workspaceId: oldPermission.workspaceId, + pageId: oldPermission.subPageId, + public: true, + }, + }); + } + } else if (oldPermission.userId) { + // workspace user permission + const existed = await db.workspaceUserPermission.findUnique({ + where: { + id: oldPermission.id, + }, + }); + + if (!existed) { + await db.workspaceUserPermission.create({ + select: null, + data: { + // this id is used at invite email, should keep + id: oldPermission.id, + workspaceId: oldPermission.workspaceId, + userId: oldPermission.userId, + type: oldPermission.type, + accepted: oldPermission.accepted, + }, + }); + } + } else { + // ignore wrong data + } + } + + done.add(workspace.id); + } + } + } + + // revert the migration + static async down() { + // + } +} diff --git a/packages/backend/server/src/modules/sync/events/events.gateway.ts b/packages/backend/server/src/modules/sync/events/events.gateway.ts index df2725a00a..ceea441e3c 100644 --- a/packages/backend/server/src/modules/sync/events/events.gateway.ts +++ b/packages/backend/server/src/modules/sync/events/events.gateway.ts @@ -81,7 +81,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @MessageBody() workspaceId: string, @ConnectedSocket() client: Socket ): Promise> { - const canWrite = await this.permissions.tryCheck( + const canWrite = await this.permissions.tryCheckWorkspace( workspaceId, user.id, Permission.Write @@ -181,7 +181,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { } ): Promise<{ missing: string; state?: string } | false> { if (!client.rooms.has(workspaceId)) { - const canRead = await this.permissions.tryCheck(workspaceId, user.id); + const canRead = await this.permissions.tryCheckWorkspace( + workspaceId, + user.id + ); if (!canRead) { return false; } @@ -266,7 +269,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { } ): Promise> { if (!client.rooms.has(workspaceId)) { - const canRead = await this.permissions.tryCheck(workspaceId, user.id); + const canRead = await this.permissions.tryCheckWorkspace( + workspaceId, + user.id + ); if (!canRead) { return { error: new AccessDeniedError(workspaceId), diff --git a/packages/backend/server/src/modules/workspaces/index.ts b/packages/backend/server/src/modules/workspaces/index.ts index c1ee3acc00..877b5f3d2b 100644 --- a/packages/backend/server/src/modules/workspaces/index.ts +++ b/packages/backend/server/src/modules/workspaces/index.ts @@ -4,12 +4,17 @@ import { DocModule } from '../doc'; import { UsersService } from '../users'; import { WorkspacesController } from './controller'; import { PermissionService } from './permission'; -import { WorkspaceResolver } from './resolver'; +import { PagePermissionResolver, WorkspaceResolver } from './resolver'; @Module({ imports: [DocModule.forFeature()], controllers: [WorkspacesController], - providers: [WorkspaceResolver, PermissionService, UsersService], + providers: [ + WorkspaceResolver, + PermissionService, + UsersService, + PagePermissionResolver, + ], exports: [PermissionService], }) export class WorkspaceModule {} diff --git a/packages/backend/server/src/modules/workspaces/permission.ts b/packages/backend/server/src/modules/workspaces/permission.ts index 699e1e01bd..07c12eeeee 100644 --- a/packages/backend/server/src/modules/workspaces/permission.ts +++ b/packages/backend/server/src/modules/workspaces/permission.ts @@ -4,15 +4,20 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma'; import { Permission } from './types'; +export enum PublicPageMode { + Page, + Edgeless, +} + @Injectable() export class PermissionService { constructor(private readonly prisma: PrismaService) {} + /// Start regin: workspace permission async get(ws: string, user: string) { - const data = await this.prisma.userWorkspacePermission.findFirst({ + const data = await this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId: ws, - subPageId: null, userId: user, accepted: true, }, @@ -22,7 +27,7 @@ export class PermissionService { } async getWorkspaceOwner(workspaceId: string) { - return this.prisma.userWorkspacePermission.findFirstOrThrow({ + return this.prisma.workspaceUserPermission.findFirstOrThrow({ where: { workspaceId, type: Permission.Owner, @@ -34,7 +39,7 @@ export class PermissionService { } async tryGetWorkspaceOwner(workspaceId: string) { - return this.prisma.userWorkspacePermission.findFirst({ + return this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId, type: Permission.Owner, @@ -46,77 +51,76 @@ export class PermissionService { } async isAccessible(ws: string, id: string, user?: string): Promise { - if (user) { - const hasPermission = await this.tryCheck(ws, user); - if (hasPermission) return true; + // workspace + if (ws === id) { + return this.tryCheckWorkspace(ws, user, Permission.Read); } - // check if this is a public workspace - const count = await this.prisma.workspace.count({ - where: { id: ws, public: true }, - }); - if (count > 0) { - return true; - } - - // check whether this is a public subpage - const workspace = await this.prisma.userWorkspacePermission.findMany({ - where: { - workspaceId: ws, - userId: null, - }, - }); - const subpages = workspace - .map(ws => ws.subPageId) - .filter((v): v is string => !!v); - if (subpages.length > 0 && ws === id) { - // rootDoc is always accessible when there is a public subpage - return true; - } else { - // check if this is a public subpage - return subpages.some(subpage => id === subpage); - } + return this.tryCheckPage(ws, id, user); } - async check( + async checkWorkspace( ws: string, - user: string, + user?: string, permission: Permission = Permission.Read ) { - if (!(await this.tryCheck(ws, user, permission))) { + if (!(await this.tryCheckWorkspace(ws, user, permission))) { throw new ForbiddenException('Permission denied'); } } - async tryCheck( + async tryCheckWorkspace( ws: string, - user: string, + user?: string, permission: Permission = Permission.Read ) { // If the permission is read, we should check if the workspace is public if (permission === Permission.Read) { - const data = await this.prisma.workspace.count({ + const count = await this.prisma.workspace.count({ where: { id: ws, public: true }, }); - if (data > 0) { + // workspace is public + // accessible + if (count > 0) { + return true; + } + + const publicPage = await this.prisma.workspacePage.findFirst({ + select: { + pageId: true, + }, + where: { + workspaceId: ws, + public: true, + }, + }); + + // has any public pages + if (publicPage) { return true; } } - const data = await this.prisma.userWorkspacePermission.count({ - where: { - workspaceId: ws, - subPageId: null, - userId: user, - accepted: true, - type: { - gte: permission, + if (user) { + // normally check if the user has the permission + const count = await this.prisma.workspaceUserPermission.count({ + where: { + workspaceId: ws, + userId: user, + accepted: true, + type: { + gte: permission, + }, }, - }, - }); + }); - return data > 0; + return count > 0; + } + + // unsigned in, workspace is not public + // unaccessible + return false; } async grant( @@ -124,10 +128,9 @@ export class PermissionService { user: string, permission: Permission = Permission.Read ): Promise { - const data = await this.prisma.userWorkspacePermission.findFirst({ + const data = await this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId: ws, - subPageId: null, userId: user, accepted: true, }, @@ -136,9 +139,12 @@ export class PermissionService { if (data) { const [p] = await this.prisma.$transaction( [ - this.prisma.userWorkspacePermission.update({ + this.prisma.workspaceUserPermission.update({ where: { - id: data.id, + workspaceId_userId: { + workspaceId: ws, + userId: user, + }, }, data: { type: permission, @@ -147,7 +153,7 @@ export class PermissionService { // If the new permission is owner, we need to revoke old owner permission === Permission.Owner - ? this.prisma.userWorkspacePermission.updateMany({ + ? this.prisma.workspaceUserPermission.updateMany({ where: { workspaceId: ws, type: Permission.Owner, @@ -166,11 +172,10 @@ export class PermissionService { return p.id; } - return this.prisma.userWorkspacePermission + return this.prisma.workspaceUserPermission .create({ data: { workspaceId: ws, - subPageId: null, userId: user, type: permission, }, @@ -178,10 +183,10 @@ export class PermissionService { .then(p => p.id); } - async getInvitationById(inviteId: string, workspaceId: string) { - return this.prisma.userWorkspacePermission.findUniqueOrThrow({ + async getWorkspaceInvitation(invitationId: string, workspaceId: string) { + return this.prisma.workspaceUserPermission.findUniqueOrThrow({ where: { - id: inviteId, + id: invitationId, workspaceId, }, include: { @@ -190,11 +195,11 @@ export class PermissionService { }); } - async acceptById(ws: string, id: string) { - const result = await this.prisma.userWorkspacePermission.updateMany({ + async acceptWorkspaceInvitation(invitationId: string, workspaceId: string) { + const result = await this.prisma.workspaceUserPermission.updateMany({ where: { - id, - workspaceId: ws, + id: invitationId, + workspaceId: workspaceId, }, data: { accepted: true, @@ -204,27 +209,10 @@ export class PermissionService { return result.count > 0; } - async accept(ws: string, user: string) { - const result = await this.prisma.userWorkspacePermission.updateMany({ + async revokeWorkspace(ws: string, user: string) { + const result = await this.prisma.workspaceUserPermission.deleteMany({ where: { workspaceId: ws, - subPageId: null, - userId: user, - accepted: false, - }, - data: { - accepted: true, - }, - }); - - return result.count > 0; - } - - async revoke(ws: string, user: string) { - const result = await this.prisma.userWorkspacePermission.deleteMany({ - where: { - workspaceId: ws, - subPageId: null, userId: user, type: { // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading @@ -235,56 +223,177 @@ export class PermissionService { return result.count > 0; } + /// End regin: workspace permission - async isPageAccessible(ws: string, page: string, user?: string) { - const data = await this.prisma.userWorkspacePermission.findFirst({ + /// Start regin: page permission + async checkPagePermission( + ws: string, + page: string, + user?: string, + permission = Permission.Read + ) { + if (!(await this.tryCheckPage(ws, page, user, permission))) { + throw new ForbiddenException('Permission denied'); + } + } + + async tryCheckPage( + ws: string, + page: string, + user?: string, + permission = Permission.Read + ) { + // check whether page is public + const count = await this.prisma.workspacePage.count({ where: { workspaceId: ws, - subPageId: page, - userId: user, + pageId: page, + public: true, }, }); - return data?.accepted || false; + // page is public + // accessible + if (count > 0) { + return true; + } + + if (user) { + const count = await this.prisma.workspacePageUserPermission.count({ + where: { + workspaceId: ws, + pageId: page, + userId: user, + accepted: true, + type: { + gte: permission, + }, + }, + }); + + // page shared to user + // accessible + if (count > 0) { + return true; + } + } + + // check whether user has workspace related permission + return this.tryCheckWorkspace(ws, user, permission); + } + + async publishPage(ws: string, page: string, mode = PublicPageMode.Page) { + return this.prisma.workspacePage.upsert({ + where: { + workspaceId_pageId: { + workspaceId: ws, + pageId: page, + }, + }, + update: { + mode, + }, + create: { + workspaceId: ws, + pageId: page, + mode, + public: true, + }, + }); + } + + async revokePublicPage(ws: string, page: string) { + const workspacePage = await this.prisma.workspacePage.findUnique({ + where: { + workspaceId_pageId: { + workspaceId: ws, + pageId: page, + }, + }, + }); + if (!workspacePage) { + throw new Error('Page is not public'); + } + + return this.prisma.workspacePage.update({ + where: { + workspaceId_pageId: { + workspaceId: ws, + pageId: page, + }, + }, + data: { + public: false, + }, + }); } async grantPage( ws: string, page: string, - user?: string, + user: string, permission: Permission = Permission.Read ) { - const data = await this.prisma.userWorkspacePermission.findFirst({ + const data = await this.prisma.workspacePageUserPermission.findFirst({ where: { workspaceId: ws, - subPageId: page, + pageId: page, userId: user, + accepted: true, }, }); if (data) { - return data.accepted; + const [p] = await this.prisma.$transaction( + [ + this.prisma.workspacePageUserPermission.update({ + where: { + id: data.id, + }, + data: { + type: permission, + }, + }), + + // If the new permission is owner, we need to revoke old owner + permission === Permission.Owner + ? this.prisma.workspacePageUserPermission.updateMany({ + where: { + workspaceId: ws, + pageId: page, + type: Permission.Owner, + userId: { + not: user, + }, + }, + data: { + type: Permission.Admin, + }, + }) + : null, + ].filter(Boolean) as Prisma.PrismaPromise[] + ); + + return p.id; } - return this.prisma.userWorkspacePermission + return this.prisma.workspacePageUserPermission .create({ data: { workspaceId: ws, - subPageId: page, + pageId: page, userId: user, - // if provide user id, user need to accept the invitation - accepted: user ? false : true, type: permission, }, }) - .then(ret => ret.accepted); + .then(p => p.id); } - async revokePage(ws: string, page: string, user?: string) { - const result = await this.prisma.userWorkspacePermission.deleteMany({ + async revokePage(ws: string, page: string, user: string) { + const result = await this.prisma.workspacePageUserPermission.deleteMany({ where: { workspaceId: ws, - subPageId: page, + pageId: page, userId: user, type: { // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading @@ -295,4 +404,5 @@ export class PermissionService { return result.count > 0; } + /// End regin: page permission } diff --git a/packages/backend/server/src/modules/workspaces/resolver.ts b/packages/backend/server/src/modules/workspaces/resolver.ts index 246086dcc5..2f3c81a2ac 100644 --- a/packages/backend/server/src/modules/workspaces/resolver.ts +++ b/packages/backend/server/src/modules/workspaces/resolver.ts @@ -25,7 +25,11 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import type { User, Workspace } from '@prisma/client'; +import type { + User, + Workspace, + WorkspacePage as PrismaWorkspacePage, +} from '@prisma/client'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { applyUpdate, Doc } from 'yjs'; @@ -39,7 +43,7 @@ import { MailService } from '../auth/mailer'; import { AuthService } from '../auth/service'; import { UsersService } from '../users'; import { UserType } from '../users/resolver'; -import { PermissionService } from './permission'; +import { PermissionService, PublicPageMode } from './permission'; import { Permission } from './types'; import { defaultWorkspaceAvatar } from './utils'; @@ -172,28 +176,11 @@ export class WorkspaceResolver { complexity: 2, }) memberCount(@Parent() workspace: WorkspaceType) { - return this.prisma.userWorkspacePermission.count({ - where: { - workspaceId: workspace.id, - userId: { - not: null, - }, - }, - }); - } - - @ResolveField(() => [String], { - description: 'Shared pages of workspace', - complexity: 2, - }) - async sharedPages(@Parent() workspace: WorkspaceType) { - const data = await this.prisma.userWorkspacePermission.findMany({ + return this.prisma.workspaceUserPermission.count({ where: { workspaceId: workspace.id, }, }); - - return data.map(item => item.subPageId).filter(Boolean); } @ResolveField(() => UserType, { @@ -215,12 +202,9 @@ export class WorkspaceResolver { @Args('skip', { type: () => Int, nullable: true }) skip?: number, @Args('take', { type: () => Int, nullable: true }) take?: number ) { - const data = await this.prisma.userWorkspacePermission.findMany({ + const data = await this.prisma.workspaceUserPermission.findMany({ where: { workspaceId: workspace.id, - userId: { - not: null, - }, }, skip, take: take || 8, @@ -265,7 +249,7 @@ export class WorkspaceResolver { complexity: 2, }) async workspaces(@CurrentUser() user: User) { - const data = await this.prisma.userWorkspacePermission.findMany({ + const data = await this.prisma.workspaceUserPermission.findMany({ where: { userId: user.id, accepted: true, @@ -309,7 +293,7 @@ export class WorkspaceResolver { description: 'Get workspace by id', }) async workspace(@CurrentUser() user: UserType, @Args('id') id: string) { - await this.permissions.check(id, user.id); + await this.permissions.checkWorkspace(id, user.id); const workspace = await this.prisma.workspace.findUnique({ where: { id } }); if (!workspace) { @@ -343,7 +327,7 @@ export class WorkspaceResolver { const workspace = await this.prisma.workspace.create({ data: { public: false, - users: { + permissions: { create: { type: Permission.Owner, user: { @@ -378,7 +362,7 @@ export class WorkspaceResolver { @Args({ name: 'input', type: () => UpdateWorkspaceInput }) { id, ...updates }: UpdateWorkspaceInput ) { - await this.permissions.check(id, user.id, Permission.Admin); + await this.permissions.checkWorkspace(id, user.id, Permission.Admin); return this.prisma.workspace.update({ where: { @@ -390,7 +374,7 @@ export class WorkspaceResolver { @Mutation(() => Boolean) async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) { - await this.permissions.check(id, user.id, Permission.Owner); + await this.permissions.checkWorkspace(id, user.id, Permission.Owner); await this.prisma.workspace.delete({ where: { @@ -422,7 +406,11 @@ export class WorkspaceResolver { @Args('permission', { type: () => Permission }) permission: Permission, @Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean ) { - await this.permissions.check(workspaceId, user.id, Permission.Admin); + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Admin + ); if (permission === Permission.Owner) { throw new ForbiddenException('Cannot change owner'); @@ -431,7 +419,7 @@ export class WorkspaceResolver { const target = await this.users.findUserByEmail(email); if (target) { - const originRecord = await this.prisma.userWorkspacePermission.findFirst({ + const originRecord = await this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId, userId: target.id, @@ -463,7 +451,10 @@ export class WorkspaceResolver { }, }); } catch (e) { - const ret = await this.permissions.revoke(workspaceId, target.id); + const ret = await this.permissions.revokeWorkspace( + workspaceId, + target.id + ); if (!ret) { this.logger.fatal( @@ -502,7 +493,10 @@ export class WorkspaceResolver { }, }); } catch (e) { - const ret = await this.permissions.revoke(workspaceId, user.id); + const ret = await this.permissions.revokeWorkspace( + workspaceId, + user.id + ); if (!ret) { this.logger.fatal( @@ -532,7 +526,7 @@ export class WorkspaceResolver { description: 'Update workspace', }) async getInviteInfo(@Args('inviteId') inviteId: string) { - const workspaceId = await this.prisma.userWorkspacePermission + const workspaceId = await this.prisma.workspaceUserPermission .findUniqueOrThrow({ where: { id: inviteId, @@ -556,7 +550,7 @@ export class WorkspaceResolver { const metaJSON = doc.getMap('meta').toJSON(); const owner = await this.permissions.getWorkspaceOwner(workspaceId); - const invitee = await this.permissions.getInvitationById( + const invitee = await this.permissions.getWorkspaceInvitation( inviteId, workspaceId ); @@ -588,9 +582,13 @@ export class WorkspaceResolver { @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { - await this.permissions.check(workspaceId, user.id, Permission.Admin); + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Admin + ); - return this.permissions.revoke(workspaceId, userId); + return this.permissions.revokeWorkspace(workspaceId, userId); } @Mutation(() => Boolean) @@ -619,15 +617,7 @@ export class WorkspaceResolver { }); } - return this.permissions.acceptById(workspaceId, inviteId); - } - - @Mutation(() => Boolean) - async acceptInvite( - @CurrentUser() user: UserType, - @Args('workspaceId') workspaceId: string - ) { - return this.permissions.accept(workspaceId, user.id); + return this.permissions.acceptWorkspaceInvitation(inviteId, workspaceId); } @Mutation(() => Boolean) @@ -637,7 +627,7 @@ export class WorkspaceResolver { @Args('workspaceName') workspaceName: string, @Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean ) { - await this.permissions.check(workspaceId, user.id); + await this.permissions.checkWorkspace(workspaceId, user.id); const owner = await this.permissions.getWorkspaceOwner(workspaceId); @@ -654,50 +644,7 @@ export class WorkspaceResolver { }); } - return this.permissions.revoke(workspaceId, user.id); - } - - @Mutation(() => Boolean) - async sharePage( - @CurrentUser() user: UserType, - @Args('workspaceId') workspaceId: string, - @Args('pageId') pageId: string - ) { - const docId = new DocID(pageId, workspaceId); - - if (docId.isWorkspace) { - throw new ForbiddenException('Expect page not to be workspace'); - } - - const userWorkspace = await this.prisma.userWorkspacePermission.findFirst({ - where: { - userId: user.id, - workspaceId: docId.workspace, - }, - }); - - if (!userWorkspace?.accepted) { - throw new ForbiddenException('Permission denied'); - } - - return this.permissions.grantPage(docId.workspace, docId.guid); - } - - @Mutation(() => Boolean) - async revokePage( - @CurrentUser() user: UserType, - @Args('workspaceId') workspaceId: string, - @Args('pageId') pageId: string - ) { - const docId = new DocID(pageId, workspaceId); - - if (docId.isWorkspace) { - throw new ForbiddenException('Expect page not to be workspace'); - } - - await this.permissions.check(docId.workspace, user.id, Permission.Admin); - - return this.permissions.revokePage(docId.workspace, docId.guid); + return this.permissions.revokeWorkspace(workspaceId, user.id); } @Query(() => [String], { @@ -707,7 +654,7 @@ export class WorkspaceResolver { @CurrentUser() user: UserType, @Args('workspaceId') workspaceId: string ) { - await this.permissions.check(workspaceId, user.id); + await this.permissions.checkWorkspace(workspaceId, user.id); return this.storage.listBlobs(workspaceId); } @@ -717,14 +664,14 @@ export class WorkspaceResolver { @CurrentUser() user: UserType, @Args('workspaceId') workspaceId: string ) { - await this.permissions.check(workspaceId, user.id); + await this.permissions.checkWorkspace(workspaceId, user.id); return this.storage.blobsSize([workspaceId]).then(size => ({ size })); } @Query(() => WorkspaceBlobSizes) async collectAllBlobSizes(@CurrentUser() user: UserType) { - const workspaces = await this.prisma.userWorkspacePermission + const workspaces = await this.prisma.workspaceUserPermission .findMany({ where: { userId: user.id, @@ -751,7 +698,7 @@ export class WorkspaceResolver { @Args('workspaceId') workspaceId: string, @Args('size', { type: () => Float }) size: number ) { - const canWrite = await this.permissions.tryCheck( + const canWrite = await this.permissions.tryCheckWorkspace( workspaceId, user.id, Permission.Write @@ -775,7 +722,11 @@ export class WorkspaceResolver { @Args({ name: 'blob', type: () => GraphQLUpload }) blob: FileUpload ) { - await this.permissions.check(workspaceId, user.id, Permission.Write); + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Write + ); // quota was apply to owner's account const { user: owner } = @@ -831,8 +782,151 @@ export class WorkspaceResolver { @Args('workspaceId') workspaceId: string, @Args('hash') hash: string ) { - await this.permissions.check(workspaceId, user.id); + await this.permissions.checkWorkspace(workspaceId, user.id); return this.storage.deleteBlob(workspaceId, hash); } } + +registerEnumType(PublicPageMode, { + name: 'PublicPageMode', + description: 'The mode which the public page default in', +}); + +@ObjectType() +class WorkspacePage implements Partial { + @Field(() => String, { name: 'id' }) + pageId!: string; + + @Field() + workspaceId!: string; + + @Field(() => PublicPageMode) + mode!: PublicPageMode; + + @Field() + public!: boolean; +} + +@UseGuards(CloudThrottlerGuard) +@Auth() +@Resolver(() => WorkspaceType) +export class PagePermissionResolver { + constructor( + private readonly prisma: PrismaService, + private readonly permission: PermissionService + ) {} + + /** + * @deprecated + */ + @ResolveField(() => [String], { + description: 'Shared pages of workspace', + complexity: 2, + deprecationReason: 'use WorkspaceType.publicPages', + }) + async sharedPages(@Parent() workspace: WorkspaceType) { + const data = await this.prisma.workspacePage.findMany({ + where: { + workspaceId: workspace.id, + public: true, + }, + }); + + return data.map(row => row.pageId); + } + + @ResolveField(() => [WorkspacePage], { + description: 'Public pages of a workspace', + complexity: 2, + }) + async publicPages(@Parent() workspace: WorkspaceType) { + return this.prisma.workspacePage.findMany({ + where: { + workspaceId: workspace.id, + public: true, + }, + }); + } + + /** + * @deprecated + */ + @Mutation(() => Boolean, { + name: 'sharePage', + deprecationReason: 'renamed to publicPage', + }) + async deprecatedSharePage( + @CurrentUser() user: UserType, + @Args('workspaceId') workspaceId: string, + @Args('pageId') pageId: string + ) { + await this.publishPage(user, workspaceId, pageId, PublicPageMode.Page); + return true; + } + + @Mutation(() => WorkspacePage) + async publishPage( + @CurrentUser() user: UserType, + @Args('workspaceId') workspaceId: string, + @Args('pageId') pageId: string, + @Args({ + name: 'mode', + type: () => PublicPageMode, + nullable: true, + defaultValue: PublicPageMode.Page, + }) + mode: PublicPageMode + ) { + const docId = new DocID(pageId, workspaceId); + + if (docId.isWorkspace) { + throw new ForbiddenException('Expect page not to be workspace'); + } + + await this.permission.checkWorkspace( + workspaceId, + user.id, + Permission.Admin + ); + + return this.permission.publishPage(docId.workspace, docId.guid, mode); + } + + /** + * @deprecated + */ + @Mutation(() => Boolean, { + name: 'revokePage', + deprecationReason: 'use revokePublicPage', + }) + async deprecatedRevokePage( + @CurrentUser() user: UserType, + @Args('workspaceId') workspaceId: string, + @Args('pageId') pageId: string + ) { + await this.revokePublicPage(user, workspaceId, pageId); + return true; + } + + @Mutation(() => WorkspacePage) + async revokePublicPage( + @CurrentUser() user: UserType, + @Args('workspaceId') workspaceId: string, + @Args('pageId') pageId: string + ) { + const docId = new DocID(pageId, workspaceId); + + if (docId.isWorkspace) { + throw new ForbiddenException('Expect page not to be workspace'); + } + + await this.permission.checkWorkspace( + docId.workspace, + user.id, + Permission.Admin + ); + + return this.permission.revokePublicPage(docId.workspace, docId.guid); + } +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 7e735b488b..af26c82dc2 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -184,11 +184,14 @@ type WorkspaceType { """member count of workspace""" memberCount: Int! - """Shared pages of workspace""" - sharedPages: [String!]! - """Owner of workspace""" owner: UserType! + + """Shared pages of workspace""" + sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") + + """Public pages of a workspace""" + publicPages: [WorkspacePage!]! } type InvitationWorkspaceType { @@ -216,6 +219,19 @@ type InvitationType { invitee: UserType! } +type WorkspacePage { + id: String! + workspaceId: String! + mode: PublicPageMode! + public: Boolean! +} + +"""The mode which the public page default in""" +enum PublicPageMode { + Page + Edgeless +} + type Query { """Get is owner of workspace""" isOwner(workspaceId: String!): Boolean! @@ -265,12 +281,13 @@ type Mutation { invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String! revoke(workspaceId: String!, userId: String!): Boolean! acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean! - acceptInvite(workspaceId: String!): Boolean! leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean! - sharePage(workspaceId: String!, pageId: String!): Boolean! - revokePage(workspaceId: String!, pageId: String!): Boolean! setBlob(workspaceId: String!, blob: Upload!): String! deleteBlob(workspaceId: String!, hash: String!): 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") + revokePublicPage(workspaceId: String!, pageId: String!): WorkspacePage! """Upload user avatar""" uploadAvatar(avatar: Upload!): UserType! diff --git a/packages/backend/server/tests/mailer.spec.ts b/packages/backend/server/tests/mailer.spec.ts index 7318f3aaae..8d8c395c54 100644 --- a/packages/backend/server/tests/mailer.spec.ts +++ b/packages/backend/server/tests/mailer.spec.ts @@ -60,7 +60,7 @@ const FakePrisma = { return { id: this.id, blob: Buffer.from([0, 0]) }; }, }, - get userWorkspacePermission() { + get workspaceUserPermission() { return { id: randomUUID(), prisma: this, @@ -79,7 +79,7 @@ const FakePrisma = { async findFirstOrThrow() { return { id: this.id, user: this.prisma.fakeUser }; }, - async userWorkspacePermission() { + async workspaceUserPermission() { return { id: randomUUID(), createdAt: new Date(), diff --git a/packages/backend/server/tests/utils.ts b/packages/backend/server/tests/utils.ts index 69b9b553be..916195b244 100644 --- a/packages/backend/server/tests/utils.ts +++ b/packages/backend/server/tests/utils.ts @@ -78,11 +78,11 @@ async function createWorkspace( return res.body.data.createWorkspace; } -export async function getWorkspaceSharedPages( +export async function getWorkspacePublicPages( app: INestApplication, token: string, workspaceId: string -): Promise { +) { const res = await request(app.getHttpServer()) .post(gql) .auth(token, { type: 'bearer' }) @@ -91,13 +91,16 @@ export async function getWorkspaceSharedPages( query: ` query { workspace(id: "${workspaceId}") { - sharedPages + publicPages { + id + mode + } } } `, }) .expect(200); - return res.body.data.workspace.sharedPages; + return res.body.data.workspace.publicPages; } async function getWorkspace( @@ -210,26 +213,6 @@ async function acceptInviteById( return res.body.data.acceptInviteById; } -async function acceptInvite( - app: INestApplication, - token: string, - workspaceId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - acceptInvite(workspaceId: "${workspaceId}") - } - `, - }) - .expect(200); - return res.body.data.acceptInvite; -} - async function leaveWorkspace( app: INestApplication, token: string, @@ -272,12 +255,12 @@ async function revokeUser( return res.body.data.revoke; } -async function sharePage( +async function publishPage( app: INestApplication, token: string, workspaceId: string, pageId: string -): Promise { +) { const res = await request(app.getHttpServer()) .post(gql) .auth(token, { type: 'bearer' }) @@ -285,20 +268,23 @@ async function sharePage( .send({ query: ` mutation { - sharePage(workspaceId: "${workspaceId}", pageId: "${pageId}") + publishPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { + id + mode + } } `, }) .expect(200); - return res.body.errors?.[0]?.message || res.body.data?.sharePage; + return res.body.errors?.[0]?.message || res.body.data?.publishPage; } -async function revokePage( +async function revokePublicPage( app: INestApplication, token: string, workspaceId: string, pageId: string -): Promise { +) { const res = await request(app.getHttpServer()) .post(gql) .auth(token, { type: 'bearer' }) @@ -306,12 +292,16 @@ async function revokePage( .send({ query: ` mutation { - revokePage(workspaceId: "${workspaceId}", pageId: "${pageId}") + revokePublicPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { + id + mode + public + } } `, }) .expect(200); - return res.body.errors?.[0]?.message || res.body.data?.revokePage; + return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage; } async function listBlobs( @@ -572,7 +562,6 @@ export class FakePrisma { } export { - acceptInvite, acceptInviteById, changeEmail, checkBlobSize, @@ -587,12 +576,12 @@ export { inviteUser, leaveWorkspace, listBlobs, - revokePage, + publishPage, + revokePublicPage, revokeUser, sendChangeEmail, sendVerifyChangeEmail, setBlob, - sharePage, signUp, updateWorkspace, }; diff --git a/packages/backend/server/tests/workspace-invite.e2e.ts b/packages/backend/server/tests/workspace-invite.e2e.ts index e285fe1355..dcaf623e18 100644 --- a/packages/backend/server/tests/workspace-invite.e2e.ts +++ b/packages/backend/server/tests/workspace-invite.e2e.ts @@ -12,7 +12,6 @@ import { AppModule } from '../src/app'; import { MailService } from '../src/modules/auth/mailer'; import { AuthService } from '../src/modules/auth/service'; import { - acceptInvite, acceptInviteById, createWorkspace, getWorkspace, @@ -78,33 +77,20 @@ test('should invite a user', async t => { t.truthy(invite, 'failed to invite user'); }); -test('should accept an invite', async t => { - const { app } = t.context; - const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); - const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1'); - - const workspace = await createWorkspace(app, u1.token.token); - await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); - - const accept = await acceptInvite(app, u2.token.token, workspace.id); - t.is(accept, true, 'failed to accept invite'); - - const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id); - const currMember = currWorkspace.members.find(u => u.email === u2.email); - t.not(currMember, undefined, 'failed to invite user'); - t.is(currMember!.id, u2.id, 'failed to invite user'); - t.true(!currMember!.accepted, 'failed to invite user'); - t.pass(); -}); - test('should leave a workspace', async t => { const { app } = t.context; const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1'); const workspace = await createWorkspace(app, u1.token.token); - await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); - await acceptInvite(app, u2.token.token, workspace.id); + const id = await inviteUser( + app, + u1.token.token, + workspace.id, + u2.email, + 'Admin' + ); + await acceptInviteById(app, workspace.id, id, false); const leave = await leaveWorkspace(app, u2.token.token, workspace.id); @@ -253,11 +239,23 @@ test('should support pagination for member', async t => { const u3 = await signUp(app, 'u3', 'u3@affine.pro', '1'); const workspace = await createWorkspace(app, u1.token.token); - await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); - await inviteUser(app, u1.token.token, workspace.id, u3.email, 'Admin'); + const invite1 = await inviteUser( + app, + u1.token.token, + workspace.id, + u2.email, + 'Admin' + ); + const invite2 = await inviteUser( + app, + u1.token.token, + workspace.id, + u3.email, + 'Admin' + ); - await acceptInvite(app, u2.token.token, workspace.id); - await acceptInvite(app, u3.token.token, workspace.id); + await acceptInviteById(app, workspace.id, invite1, false); + await acceptInviteById(app, workspace.id, invite2, false); const firstPageWorkspace = await getWorkspace( app, diff --git a/packages/backend/server/tests/workspace-usage.spec.ts b/packages/backend/server/tests/workspace-usage.spec.ts index d940926870..6cda0e2ad2 100644 --- a/packages/backend/server/tests/workspace-usage.spec.ts +++ b/packages/backend/server/tests/workspace-usage.spec.ts @@ -12,7 +12,7 @@ import { StorageProvide } from '../src/storage'; import { FakePrisma } from './utils'; class FakePermission { - async tryCheck() { + async tryCheckWorkspace() { return true; } async getWorkspaceOwner() { @@ -37,7 +37,7 @@ test.beforeEach(async t => { }) .overrideProvider(PrismaService) .useValue({ - userWorkspacePermission: { + workspaceUserPermission: { async findMany() { return []; }, diff --git a/packages/backend/server/tests/workspace.e2e.ts b/packages/backend/server/tests/workspace.e2e.ts index 1379724b22..41bf97befc 100644 --- a/packages/backend/server/tests/workspace.e2e.ts +++ b/packages/backend/server/tests/workspace.e2e.ts @@ -7,14 +7,14 @@ import request from 'supertest'; import { AppModule } from '../src/app'; import { - acceptInvite, + acceptInviteById, createWorkspace, currentUser, getPublicWorkspace, - getWorkspaceSharedPages, + getWorkspacePublicPages, inviteUser, - revokePage, - sharePage, + publishPage, + revokePublicPage, signUp, updateWorkspace, } from './utils'; @@ -122,15 +122,19 @@ test('should share a page', async t => { const workspace = await createWorkspace(app, u1.token.token); - const share = await sharePage(app, u1.token.token, workspace.id, 'page1'); - t.true(share, 'failed to share page'); - const pages = await getWorkspaceSharedPages( + const share = await publishPage(app, u1.token.token, workspace.id, 'page1'); + t.is(share.id, 'page1', 'failed to share page'); + const pages = await getWorkspacePublicPages( app, u1.token.token, workspace.id ); t.is(pages.length, 1, 'failed to get shared pages'); - t.is(pages[0], 'page1', 'failed to get shared page: page1'); + t.deepEqual( + pages[0], + { id: 'page1', mode: 'Page' }, + 'failed to get shared page: page1' + ); const resp1 = await request(app.getHttpServer()) .get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`) @@ -139,7 +143,7 @@ test('should share a page', async t => { const resp2 = await request(app.getHttpServer()).get( `/api/workspaces/${workspace.id}/docs/${workspace.id}` ); - t.is(resp2.statusCode, 200, 'should not get root doc without token'); + t.is(resp2.statusCode, 200, 'failed to get root doc with public pages'); const resp3 = await request(app.getHttpServer()) .get(`/api/workspaces/${workspace.id}/docs/page1`) @@ -152,32 +156,55 @@ test('should share a page', async t => { // 404 because we don't put the page doc to server t.is(resp4.statusCode, 404, 'should not get shared doc without token'); - const msg1 = await sharePage(app, u2.token.token, 'not_exists_ws', 'page2'); + const msg1 = await publishPage(app, u2.token.token, 'not_exists_ws', 'page2'); t.is(msg1, 'Permission denied', 'unauthorized user can share page'); - const msg2 = await revokePage(app, u2.token.token, 'not_exists_ws', 'page2'); + const msg2 = await revokePublicPage( + app, + u2.token.token, + 'not_exists_ws', + 'page2' + ); t.is(msg2, 'Permission denied', 'unauthorized user can share page'); - await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); - await acceptInvite(app, u2.token.token, workspace.id); - const invited = await sharePage(app, u2.token.token, workspace.id, 'page2'); - t.true(invited, 'failed to share page'); + await acceptInviteById( + app, + workspace.id, + await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin') + ); + const invited = await publishPage(app, u2.token.token, workspace.id, 'page2'); + t.is(invited.id, 'page2', 'failed to share page'); - const revoke = await revokePage(app, u1.token.token, workspace.id, 'page1'); - t.true(revoke, 'failed to revoke page'); - const pages2 = await getWorkspaceSharedPages( + const revoke = await revokePublicPage( + app, + u1.token.token, + workspace.id, + 'page1' + ); + t.false(revoke.public, 'failed to revoke page'); + const pages2 = await getWorkspacePublicPages( app, u1.token.token, workspace.id ); t.is(pages2.length, 1, 'failed to get shared pages'); - t.is(pages2[0], 'page2', 'failed to get shared page: page2'); + t.is(pages2[0].id, 'page2', 'failed to get shared page: page2'); - const msg3 = await revokePage(app, u1.token.token, workspace.id, 'page3'); - t.false(msg3, 'can revoke non-exists page'); + const msg3 = await revokePublicPage( + app, + u1.token.token, + workspace.id, + 'page3' + ); + t.is(msg3, 'Page is not public'); - const msg4 = await revokePage(app, u1.token.token, workspace.id, 'page2'); - t.true(msg4, 'failed to revoke page'); - const page3 = await getWorkspaceSharedPages( + const msg4 = await revokePublicPage( + app, + u1.token.token, + workspace.id, + 'page2' + ); + t.false(msg4.public, 'failed to revoke page'); + const page3 = await getWorkspacePublicPages( app, u1.token.token, workspace.id @@ -211,13 +238,17 @@ test('should can get workspace doc', async t => { .auth(u2.token.token, { type: 'bearer' }) .expect(403); - await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); await request(app.getHttpServer()) .get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`) .auth(u2.token.token, { type: 'bearer' }) .expect(403); - await acceptInvite(app, u2.token.token, workspace.id); + await acceptInviteById( + app, + workspace.id, + await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin') + ); + const res2 = await request(app.getHttpServer()) .get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`) .auth(u2.token.token, { type: 'bearer' }) diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 5a5c189da0..5de29e1419 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -665,14 +665,3 @@ mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!, $send ) }`, }; - -export const acceptInviteByWorkspaceIdMutation = { - id: 'acceptInviteByWorkspaceIdMutation' as const, - operationName: 'acceptInviteByWorkspaceId', - definitionName: 'acceptInvite', - containsFile: false, - query: ` -mutation acceptInviteByWorkspaceId($workspaceId: String!) { - acceptInvite(workspaceId: $workspaceId) -}`, -}; diff --git a/packages/frontend/graphql/src/graphql/workspace-invite-accept-by-workspace-id.gql b/packages/frontend/graphql/src/graphql/workspace-invite-accept-by-workspace-id.gql deleted file mode 100644 index fe715ed663..0000000000 --- a/packages/frontend/graphql/src/graphql/workspace-invite-accept-by-workspace-id.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation acceptInviteByWorkspaceId($workspaceId: String!) { - acceptInvite(workspaceId: $workspaceId) -} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 1b6c989b7f..f026ab0f8d 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -52,6 +52,12 @@ export enum Permission { Write = 'Write', } +/** The mode which the public page default in */ +export enum PublicPageMode { + Edgeless = 'Edgeless', + Page = 'Page', +} + export enum SubscriptionPlan { Enterprise = 'Enterprise', Free = 'Free', @@ -615,15 +621,6 @@ export type AcceptInviteByInviteIdMutation = { acceptInviteById: boolean; }; -export type AcceptInviteByWorkspaceIdMutationVariables = Exact<{ - workspaceId: Scalars['String']['input']; -}>; - -export type AcceptInviteByWorkspaceIdMutation = { - __typename?: 'Mutation'; - acceptInvite: boolean; -}; - export type Queries = | { name: 'checkBlobSizesQuery'; @@ -856,9 +853,4 @@ export type Mutations = name: 'acceptInviteByInviteIdMutation'; variables: AcceptInviteByInviteIdMutationVariables; response: AcceptInviteByInviteIdMutation; - } - | { - name: 'acceptInviteByWorkspaceIdMutation'; - variables: AcceptInviteByWorkspaceIdMutationVariables; - response: AcceptInviteByWorkspaceIdMutation; }; diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index 48a26dccb9..f1d8c50770 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -79,10 +79,9 @@ export async function addUserToWorkspace( if (workspace == null) { throw new Error(`workspace ${workspaceId} not found`); } - await client.userWorkspacePermission.create({ + await client.workspaceUserPermission.create({ data: { workspaceId: workspace.id, - subPageId: null, userId, accepted: true, type: permission,