refactor(server): separate page visibility from workspace permission (#4836)

This commit is contained in:
liuyi
2023-11-06 11:49:39 +08:00
committed by GitHub
parent e8987457ab
commit f491ff94cc
19 changed files with 796 additions and 362 deletions

View File

@@ -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;

View File

@@ -9,51 +9,108 @@ datasource db {
url = env("DATABASE_URL") 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 { model Workspace {
id String @id @default(uuid()) @db.VarChar id String @id @default(uuid()) @db.VarChar
public Boolean public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
users UserWorkspacePermission[]
pages WorkspacePage[]
permissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[]
@@map("workspaces") @@map("workspaces")
} }
model UserWorkspacePermission { // Table for workspace page meta data
id String @id @default(uuid()) @db.VarChar // NOTE:
workspaceId String @map("workspace_id") @db.VarChar // We won't make sure every page has a corresponding record in this table.
subPageId String? @map("sub_page_id") @db.VarChar // Only the ones that have ever changed will have records here,
userId String? @map("entity_id") @db.VarChar // 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 /// Read/Write/Admin/Owner
type Int @db.SmallInt type Int @db.SmallInt
/// Whether the permission invitation is accepted by the user /// Whether the permission invitation is accepted by the user
accepted Boolean @default(false) accepted Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) 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, subPageId, userId]) @@unique([workspaceId, subPageId, userId])
@@map("user_workspace_permissions") @@map("user_workspace_permissions")
} }
model User { model WorkspaceUserPermission {
id String @id @default(uuid()) @db.VarChar id String @id @default(uuid()) @db.VarChar(36)
name String workspaceId String @map("workspace_id") @db.VarChar(36)
email String @unique userId String @map("user_id") @db.VarChar(36)
emailVerified DateTime? @map("email_verified") // Read/Write
// image field is for the next-auth type Int @db.SmallInt
avatarUrl String? @map("avatar_url") @db.VarChar /// Whether the permission invitation is accepted by the user
accounts Account[] accepted Boolean @default(false)
sessions Session[] createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
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[]
@@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 { model UserFeatureGates {

View File

@@ -3,7 +3,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { camelCase, snakeCase, upperFirst } from 'lodash-es'; import { camelCase, kebabCase, upperFirst } from 'lodash-es';
import { import {
Command, Command,
CommandRunner, CommandRunner,
@@ -45,7 +45,7 @@ export class CreateCommand extends CommandRunner {
const timestamp = Date.now(); const timestamp = Date.now();
const content = this.createScript(upperFirst(camelCase(name)) + timestamp); const content = this.createScript(upperFirst(camelCase(name)) + timestamp);
const fileName = `${timestamp}-${snakeCase(name)}.ts`; const fileName = `${timestamp}-${kebabCase(name)}.ts`;
const filePath = join( const filePath = join(
fileURLToPath(import.meta.url), fileURLToPath(import.meta.url),
'../../migrations', '../../migrations',
@@ -54,6 +54,7 @@ export class CreateCommand extends CommandRunner {
this.logger.log(`Creating ${fileName}...`); this.logger.log(`Creating ${fileName}...`);
writeFileSync(filePath, content); writeFileSync(filePath, content);
this.logger.log('Migration file created at', filePath);
this.logger.log('Done'); this.logger.log('Done');
} }

View File

@@ -84,6 +84,7 @@ export class RunCommand extends CommandRunner {
done.push(migration); done.push(migration);
} catch (e) { } catch (e) {
this.logger.error('Failed to run data migration', e); this.logger.error('Failed to run data migration', e);
process.exit(1);
} }
} }

View File

@@ -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<string>();
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() {
//
}
}

View File

@@ -81,7 +81,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() workspaceId: string, @MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket @ConnectedSocket() client: Socket
): Promise<EventResponse<{ clientId: string }>> { ): Promise<EventResponse<{ clientId: string }>> {
const canWrite = await this.permissions.tryCheck( const canWrite = await this.permissions.tryCheckWorkspace(
workspaceId, workspaceId,
user.id, user.id,
Permission.Write Permission.Write
@@ -181,7 +181,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
} }
): Promise<{ missing: string; state?: string } | false> { ): Promise<{ missing: string; state?: string } | false> {
if (!client.rooms.has(workspaceId)) { 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) { if (!canRead) {
return false; return false;
} }
@@ -266,7 +269,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
} }
): Promise<EventResponse<{ missing: string; state?: string }>> { ): Promise<EventResponse<{ missing: string; state?: string }>> {
if (!client.rooms.has(workspaceId)) { 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) { if (!canRead) {
return { return {
error: new AccessDeniedError(workspaceId), error: new AccessDeniedError(workspaceId),

View File

@@ -4,12 +4,17 @@ import { DocModule } from '../doc';
import { UsersService } from '../users'; import { UsersService } from '../users';
import { WorkspacesController } from './controller'; import { WorkspacesController } from './controller';
import { PermissionService } from './permission'; import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver'; import { PagePermissionResolver, WorkspaceResolver } from './resolver';
@Module({ @Module({
imports: [DocModule.forFeature()], imports: [DocModule.forFeature()],
controllers: [WorkspacesController], controllers: [WorkspacesController],
providers: [WorkspaceResolver, PermissionService, UsersService], providers: [
WorkspaceResolver,
PermissionService,
UsersService,
PagePermissionResolver,
],
exports: [PermissionService], exports: [PermissionService],
}) })
export class WorkspaceModule {} export class WorkspaceModule {}

View File

@@ -4,15 +4,20 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { Permission } from './types'; import { Permission } from './types';
export enum PublicPageMode {
Page,
Edgeless,
}
@Injectable() @Injectable()
export class PermissionService { export class PermissionService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/// Start regin: workspace permission
async get(ws: string, user: string) { async get(ws: string, user: string) {
const data = await this.prisma.userWorkspacePermission.findFirst({ const data = await this.prisma.workspaceUserPermission.findFirst({
where: { where: {
workspaceId: ws, workspaceId: ws,
subPageId: null,
userId: user, userId: user,
accepted: true, accepted: true,
}, },
@@ -22,7 +27,7 @@ export class PermissionService {
} }
async getWorkspaceOwner(workspaceId: string) { async getWorkspaceOwner(workspaceId: string) {
return this.prisma.userWorkspacePermission.findFirstOrThrow({ return this.prisma.workspaceUserPermission.findFirstOrThrow({
where: { where: {
workspaceId, workspaceId,
type: Permission.Owner, type: Permission.Owner,
@@ -34,7 +39,7 @@ export class PermissionService {
} }
async tryGetWorkspaceOwner(workspaceId: string) { async tryGetWorkspaceOwner(workspaceId: string) {
return this.prisma.userWorkspacePermission.findFirst({ return this.prisma.workspaceUserPermission.findFirst({
where: { where: {
workspaceId, workspaceId,
type: Permission.Owner, type: Permission.Owner,
@@ -46,77 +51,76 @@ export class PermissionService {
} }
async isAccessible(ws: string, id: string, user?: string): Promise<boolean> { async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
if (user) { // workspace
const hasPermission = await this.tryCheck(ws, user); if (ws === id) {
if (hasPermission) return true; return this.tryCheckWorkspace(ws, user, Permission.Read);
} }
// check if this is a public workspace return this.tryCheckPage(ws, id, user);
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);
}
} }
async check( async checkWorkspace(
ws: string, ws: string,
user: string, user?: string,
permission: Permission = Permission.Read permission: Permission = Permission.Read
) { ) {
if (!(await this.tryCheck(ws, user, permission))) { if (!(await this.tryCheckWorkspace(ws, user, permission))) {
throw new ForbiddenException('Permission denied'); throw new ForbiddenException('Permission denied');
} }
} }
async tryCheck( async tryCheckWorkspace(
ws: string, ws: string,
user: string, user?: string,
permission: Permission = Permission.Read permission: Permission = Permission.Read
) { ) {
// If the permission is read, we should check if the workspace is public // If the permission is read, we should check if the workspace is public
if (permission === Permission.Read) { if (permission === Permission.Read) {
const data = await this.prisma.workspace.count({ const count = await this.prisma.workspace.count({
where: { id: ws, public: true }, 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; return true;
} }
} }
const data = await this.prisma.userWorkspacePermission.count({ if (user) {
where: { // normally check if the user has the permission
workspaceId: ws, const count = await this.prisma.workspaceUserPermission.count({
subPageId: null, where: {
userId: user, workspaceId: ws,
accepted: true, userId: user,
type: { accepted: true,
gte: permission, type: {
gte: permission,
},
}, },
}, });
});
return data > 0; return count > 0;
}
// unsigned in, workspace is not public
// unaccessible
return false;
} }
async grant( async grant(
@@ -124,10 +128,9 @@ export class PermissionService {
user: string, user: string,
permission: Permission = Permission.Read permission: Permission = Permission.Read
): Promise<string> { ): Promise<string> {
const data = await this.prisma.userWorkspacePermission.findFirst({ const data = await this.prisma.workspaceUserPermission.findFirst({
where: { where: {
workspaceId: ws, workspaceId: ws,
subPageId: null,
userId: user, userId: user,
accepted: true, accepted: true,
}, },
@@ -136,9 +139,12 @@ export class PermissionService {
if (data) { if (data) {
const [p] = await this.prisma.$transaction( const [p] = await this.prisma.$transaction(
[ [
this.prisma.userWorkspacePermission.update({ this.prisma.workspaceUserPermission.update({
where: { where: {
id: data.id, workspaceId_userId: {
workspaceId: ws,
userId: user,
},
}, },
data: { data: {
type: permission, type: permission,
@@ -147,7 +153,7 @@ export class PermissionService {
// If the new permission is owner, we need to revoke old owner // If the new permission is owner, we need to revoke old owner
permission === Permission.Owner permission === Permission.Owner
? this.prisma.userWorkspacePermission.updateMany({ ? this.prisma.workspaceUserPermission.updateMany({
where: { where: {
workspaceId: ws, workspaceId: ws,
type: Permission.Owner, type: Permission.Owner,
@@ -166,11 +172,10 @@ export class PermissionService {
return p.id; return p.id;
} }
return this.prisma.userWorkspacePermission return this.prisma.workspaceUserPermission
.create({ .create({
data: { data: {
workspaceId: ws, workspaceId: ws,
subPageId: null,
userId: user, userId: user,
type: permission, type: permission,
}, },
@@ -178,10 +183,10 @@ export class PermissionService {
.then(p => p.id); .then(p => p.id);
} }
async getInvitationById(inviteId: string, workspaceId: string) { async getWorkspaceInvitation(invitationId: string, workspaceId: string) {
return this.prisma.userWorkspacePermission.findUniqueOrThrow({ return this.prisma.workspaceUserPermission.findUniqueOrThrow({
where: { where: {
id: inviteId, id: invitationId,
workspaceId, workspaceId,
}, },
include: { include: {
@@ -190,11 +195,11 @@ export class PermissionService {
}); });
} }
async acceptById(ws: string, id: string) { async acceptWorkspaceInvitation(invitationId: string, workspaceId: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({ const result = await this.prisma.workspaceUserPermission.updateMany({
where: { where: {
id, id: invitationId,
workspaceId: ws, workspaceId: workspaceId,
}, },
data: { data: {
accepted: true, accepted: true,
@@ -204,27 +209,10 @@ export class PermissionService {
return result.count > 0; return result.count > 0;
} }
async accept(ws: string, user: string) { async revokeWorkspace(ws: string, user: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({ const result = await this.prisma.workspaceUserPermission.deleteMany({
where: { where: {
workspaceId: ws, 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, userId: user,
type: { type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading // 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; return result.count > 0;
} }
/// End regin: workspace permission
async isPageAccessible(ws: string, page: string, user?: string) { /// Start regin: page permission
const data = await this.prisma.userWorkspacePermission.findFirst({ 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: { where: {
workspaceId: ws, workspaceId: ws,
subPageId: page, pageId: page,
userId: user, 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( async grantPage(
ws: string, ws: string,
page: string, page: string,
user?: string, user: string,
permission: Permission = Permission.Read permission: Permission = Permission.Read
) { ) {
const data = await this.prisma.userWorkspacePermission.findFirst({ const data = await this.prisma.workspacePageUserPermission.findFirst({
where: { where: {
workspaceId: ws, workspaceId: ws,
subPageId: page, pageId: page,
userId: user, userId: user,
accepted: true,
}, },
}); });
if (data) { 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<any>[]
);
return p.id;
} }
return this.prisma.userWorkspacePermission return this.prisma.workspacePageUserPermission
.create({ .create({
data: { data: {
workspaceId: ws, workspaceId: ws,
subPageId: page, pageId: page,
userId: user, userId: user,
// if provide user id, user need to accept the invitation
accepted: user ? false : true,
type: permission, type: permission,
}, },
}) })
.then(ret => ret.accepted); .then(p => p.id);
} }
async revokePage(ws: string, page: string, user?: string) { async revokePage(ws: string, page: string, user: string) {
const result = await this.prisma.userWorkspacePermission.deleteMany({ const result = await this.prisma.workspacePageUserPermission.deleteMany({
where: { where: {
workspaceId: ws, workspaceId: ws,
subPageId: page, pageId: page,
userId: user, userId: user,
type: { type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading // 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; return result.count > 0;
} }
/// End regin: page permission
} }

View File

@@ -25,7 +25,11 @@ import {
ResolveField, ResolveField,
Resolver, Resolver,
} from '@nestjs/graphql'; } 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 GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs'; import { applyUpdate, Doc } from 'yjs';
@@ -39,7 +43,7 @@ import { MailService } from '../auth/mailer';
import { AuthService } from '../auth/service'; import { AuthService } from '../auth/service';
import { UsersService } from '../users'; import { UsersService } from '../users';
import { UserType } from '../users/resolver'; import { UserType } from '../users/resolver';
import { PermissionService } from './permission'; import { PermissionService, PublicPageMode } from './permission';
import { Permission } from './types'; import { Permission } from './types';
import { defaultWorkspaceAvatar } from './utils'; import { defaultWorkspaceAvatar } from './utils';
@@ -172,28 +176,11 @@ export class WorkspaceResolver {
complexity: 2, complexity: 2,
}) })
memberCount(@Parent() workspace: WorkspaceType) { memberCount(@Parent() workspace: WorkspaceType) {
return this.prisma.userWorkspacePermission.count({ return this.prisma.workspaceUserPermission.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({
where: { where: {
workspaceId: workspace.id, workspaceId: workspace.id,
}, },
}); });
return data.map(item => item.subPageId).filter(Boolean);
} }
@ResolveField(() => UserType, { @ResolveField(() => UserType, {
@@ -215,12 +202,9 @@ export class WorkspaceResolver {
@Args('skip', { type: () => Int, nullable: true }) skip?: number, @Args('skip', { type: () => Int, nullable: true }) skip?: number,
@Args('take', { type: () => Int, nullable: true }) take?: number @Args('take', { type: () => Int, nullable: true }) take?: number
) { ) {
const data = await this.prisma.userWorkspacePermission.findMany({ const data = await this.prisma.workspaceUserPermission.findMany({
where: { where: {
workspaceId: workspace.id, workspaceId: workspace.id,
userId: {
not: null,
},
}, },
skip, skip,
take: take || 8, take: take || 8,
@@ -265,7 +249,7 @@ export class WorkspaceResolver {
complexity: 2, complexity: 2,
}) })
async workspaces(@CurrentUser() user: User) { async workspaces(@CurrentUser() user: User) {
const data = await this.prisma.userWorkspacePermission.findMany({ const data = await this.prisma.workspaceUserPermission.findMany({
where: { where: {
userId: user.id, userId: user.id,
accepted: true, accepted: true,
@@ -309,7 +293,7 @@ export class WorkspaceResolver {
description: 'Get workspace by id', description: 'Get workspace by id',
}) })
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) { 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 } }); const workspace = await this.prisma.workspace.findUnique({ where: { id } });
if (!workspace) { if (!workspace) {
@@ -343,7 +327,7 @@ export class WorkspaceResolver {
const workspace = await this.prisma.workspace.create({ const workspace = await this.prisma.workspace.create({
data: { data: {
public: false, public: false,
users: { permissions: {
create: { create: {
type: Permission.Owner, type: Permission.Owner,
user: { user: {
@@ -378,7 +362,7 @@ export class WorkspaceResolver {
@Args({ name: 'input', type: () => UpdateWorkspaceInput }) @Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: 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({ return this.prisma.workspace.update({
where: { where: {
@@ -390,7 +374,7 @@ export class WorkspaceResolver {
@Mutation(() => Boolean) @Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) { 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({ await this.prisma.workspace.delete({
where: { where: {
@@ -422,7 +406,11 @@ export class WorkspaceResolver {
@Args('permission', { type: () => Permission }) permission: Permission, @Args('permission', { type: () => Permission }) permission: Permission,
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean @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) { if (permission === Permission.Owner) {
throw new ForbiddenException('Cannot change owner'); throw new ForbiddenException('Cannot change owner');
@@ -431,7 +419,7 @@ export class WorkspaceResolver {
const target = await this.users.findUserByEmail(email); const target = await this.users.findUserByEmail(email);
if (target) { if (target) {
const originRecord = await this.prisma.userWorkspacePermission.findFirst({ const originRecord = await this.prisma.workspaceUserPermission.findFirst({
where: { where: {
workspaceId, workspaceId,
userId: target.id, userId: target.id,
@@ -463,7 +451,10 @@ export class WorkspaceResolver {
}, },
}); });
} catch (e) { } catch (e) {
const ret = await this.permissions.revoke(workspaceId, target.id); const ret = await this.permissions.revokeWorkspace(
workspaceId,
target.id
);
if (!ret) { if (!ret) {
this.logger.fatal( this.logger.fatal(
@@ -502,7 +493,10 @@ export class WorkspaceResolver {
}, },
}); });
} catch (e) { } catch (e) {
const ret = await this.permissions.revoke(workspaceId, user.id); const ret = await this.permissions.revokeWorkspace(
workspaceId,
user.id
);
if (!ret) { if (!ret) {
this.logger.fatal( this.logger.fatal(
@@ -532,7 +526,7 @@ export class WorkspaceResolver {
description: 'Update workspace', description: 'Update workspace',
}) })
async getInviteInfo(@Args('inviteId') inviteId: string) { async getInviteInfo(@Args('inviteId') inviteId: string) {
const workspaceId = await this.prisma.userWorkspacePermission const workspaceId = await this.prisma.workspaceUserPermission
.findUniqueOrThrow({ .findUniqueOrThrow({
where: { where: {
id: inviteId, id: inviteId,
@@ -556,7 +550,7 @@ export class WorkspaceResolver {
const metaJSON = doc.getMap('meta').toJSON(); const metaJSON = doc.getMap('meta').toJSON();
const owner = await this.permissions.getWorkspaceOwner(workspaceId); const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const invitee = await this.permissions.getInvitationById( const invitee = await this.permissions.getWorkspaceInvitation(
inviteId, inviteId,
workspaceId workspaceId
); );
@@ -588,9 +582,13 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('userId') userId: 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) @Mutation(() => Boolean)
@@ -619,15 +617,7 @@ export class WorkspaceResolver {
}); });
} }
return this.permissions.acceptById(workspaceId, inviteId); return this.permissions.acceptWorkspaceInvitation(inviteId, workspaceId);
}
@Mutation(() => Boolean)
async acceptInvite(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
return this.permissions.accept(workspaceId, user.id);
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
@@ -637,7 +627,7 @@ export class WorkspaceResolver {
@Args('workspaceName') workspaceName: string, @Args('workspaceName') workspaceName: string,
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean @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); const owner = await this.permissions.getWorkspaceOwner(workspaceId);
@@ -654,50 +644,7 @@ export class WorkspaceResolver {
}); });
} }
return this.permissions.revoke(workspaceId, user.id); return this.permissions.revokeWorkspace(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);
} }
@Query(() => [String], { @Query(() => [String], {
@@ -707,7 +654,7 @@ export class WorkspaceResolver {
@CurrentUser() user: UserType, @CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string @Args('workspaceId') workspaceId: string
) { ) {
await this.permissions.check(workspaceId, user.id); await this.permissions.checkWorkspace(workspaceId, user.id);
return this.storage.listBlobs(workspaceId); return this.storage.listBlobs(workspaceId);
} }
@@ -717,14 +664,14 @@ export class WorkspaceResolver {
@CurrentUser() user: UserType, @CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string @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 })); return this.storage.blobsSize([workspaceId]).then(size => ({ size }));
} }
@Query(() => WorkspaceBlobSizes) @Query(() => WorkspaceBlobSizes)
async collectAllBlobSizes(@CurrentUser() user: UserType) { async collectAllBlobSizes(@CurrentUser() user: UserType) {
const workspaces = await this.prisma.userWorkspacePermission const workspaces = await this.prisma.workspaceUserPermission
.findMany({ .findMany({
where: { where: {
userId: user.id, userId: user.id,
@@ -751,7 +698,7 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('size', { type: () => Float }) size: number @Args('size', { type: () => Float }) size: number
) { ) {
const canWrite = await this.permissions.tryCheck( const canWrite = await this.permissions.tryCheckWorkspace(
workspaceId, workspaceId,
user.id, user.id,
Permission.Write Permission.Write
@@ -775,7 +722,11 @@ export class WorkspaceResolver {
@Args({ name: 'blob', type: () => GraphQLUpload }) @Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload 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 // quota was apply to owner's account
const { user: owner } = const { user: owner } =
@@ -831,8 +782,151 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('hash') hash: 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); return this.storage.deleteBlob(workspaceId, hash);
} }
} }
registerEnumType(PublicPageMode, {
name: 'PublicPageMode',
description: 'The mode which the public page default in',
});
@ObjectType()
class WorkspacePage implements Partial<PrismaWorkspacePage> {
@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);
}
}

View File

@@ -184,11 +184,14 @@ type WorkspaceType {
"""member count of workspace""" """member count of workspace"""
memberCount: Int! memberCount: Int!
"""Shared pages of workspace"""
sharedPages: [String!]!
"""Owner of workspace""" """Owner of workspace"""
owner: UserType! owner: UserType!
"""Shared pages of workspace"""
sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages")
"""Public pages of a workspace"""
publicPages: [WorkspacePage!]!
} }
type InvitationWorkspaceType { type InvitationWorkspaceType {
@@ -216,6 +219,19 @@ type InvitationType {
invitee: UserType! 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 { type Query {
"""Get is owner of workspace""" """Get is owner of workspace"""
isOwner(workspaceId: String!): Boolean! isOwner(workspaceId: String!): Boolean!
@@ -265,12 +281,13 @@ type Mutation {
invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String! invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String!
revoke(workspaceId: String!, userId: String!): Boolean! revoke(workspaceId: String!, userId: String!): Boolean!
acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean! acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean!
acceptInvite(workspaceId: String!): Boolean!
leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): 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! setBlob(workspaceId: String!, blob: Upload!): String!
deleteBlob(workspaceId: String!, hash: String!): Boolean! 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""" """Upload user avatar"""
uploadAvatar(avatar: Upload!): UserType! uploadAvatar(avatar: Upload!): UserType!

View File

@@ -60,7 +60,7 @@ const FakePrisma = {
return { id: this.id, blob: Buffer.from([0, 0]) }; return { id: this.id, blob: Buffer.from([0, 0]) };
}, },
}, },
get userWorkspacePermission() { get workspaceUserPermission() {
return { return {
id: randomUUID(), id: randomUUID(),
prisma: this, prisma: this,
@@ -79,7 +79,7 @@ const FakePrisma = {
async findFirstOrThrow() { async findFirstOrThrow() {
return { id: this.id, user: this.prisma.fakeUser }; return { id: this.id, user: this.prisma.fakeUser };
}, },
async userWorkspacePermission() { async workspaceUserPermission() {
return { return {
id: randomUUID(), id: randomUUID(),
createdAt: new Date(), createdAt: new Date(),

View File

@@ -78,11 +78,11 @@ async function createWorkspace(
return res.body.data.createWorkspace; return res.body.data.createWorkspace;
} }
export async function getWorkspaceSharedPages( export async function getWorkspacePublicPages(
app: INestApplication, app: INestApplication,
token: string, token: string,
workspaceId: string workspaceId: string
): Promise<string[]> { ) {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(gql) .post(gql)
.auth(token, { type: 'bearer' }) .auth(token, { type: 'bearer' })
@@ -91,13 +91,16 @@ export async function getWorkspaceSharedPages(
query: ` query: `
query { query {
workspace(id: "${workspaceId}") { workspace(id: "${workspaceId}") {
sharedPages publicPages {
id
mode
}
} }
} }
`, `,
}) })
.expect(200); .expect(200);
return res.body.data.workspace.sharedPages; return res.body.data.workspace.publicPages;
} }
async function getWorkspace( async function getWorkspace(
@@ -210,26 +213,6 @@ async function acceptInviteById(
return res.body.data.acceptInviteById; return res.body.data.acceptInviteById;
} }
async function acceptInvite(
app: INestApplication,
token: string,
workspaceId: string
): Promise<boolean> {
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( async function leaveWorkspace(
app: INestApplication, app: INestApplication,
token: string, token: string,
@@ -272,12 +255,12 @@ async function revokeUser(
return res.body.data.revoke; return res.body.data.revoke;
} }
async function sharePage( async function publishPage(
app: INestApplication, app: INestApplication,
token: string, token: string,
workspaceId: string, workspaceId: string,
pageId: string pageId: string
): Promise<boolean | string> { ) {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(gql) .post(gql)
.auth(token, { type: 'bearer' }) .auth(token, { type: 'bearer' })
@@ -285,20 +268,23 @@ async function sharePage(
.send({ .send({
query: ` query: `
mutation { mutation {
sharePage(workspaceId: "${workspaceId}", pageId: "${pageId}") publishPage(workspaceId: "${workspaceId}", pageId: "${pageId}") {
id
mode
}
} }
`, `,
}) })
.expect(200); .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, app: INestApplication,
token: string, token: string,
workspaceId: string, workspaceId: string,
pageId: string pageId: string
): Promise<boolean | string> { ) {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(gql) .post(gql)
.auth(token, { type: 'bearer' }) .auth(token, { type: 'bearer' })
@@ -306,12 +292,16 @@ async function revokePage(
.send({ .send({
query: ` query: `
mutation { mutation {
revokePage(workspaceId: "${workspaceId}", pageId: "${pageId}") revokePublicPage(workspaceId: "${workspaceId}", pageId: "${pageId}") {
id
mode
public
}
} }
`, `,
}) })
.expect(200); .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( async function listBlobs(
@@ -572,7 +562,6 @@ export class FakePrisma {
} }
export { export {
acceptInvite,
acceptInviteById, acceptInviteById,
changeEmail, changeEmail,
checkBlobSize, checkBlobSize,
@@ -587,12 +576,12 @@ export {
inviteUser, inviteUser,
leaveWorkspace, leaveWorkspace,
listBlobs, listBlobs,
revokePage, publishPage,
revokePublicPage,
revokeUser, revokeUser,
sendChangeEmail, sendChangeEmail,
sendVerifyChangeEmail, sendVerifyChangeEmail,
setBlob, setBlob,
sharePage,
signUp, signUp,
updateWorkspace, updateWorkspace,
}; };

View File

@@ -12,7 +12,6 @@ import { AppModule } from '../src/app';
import { MailService } from '../src/modules/auth/mailer'; import { MailService } from '../src/modules/auth/mailer';
import { AuthService } from '../src/modules/auth/service'; import { AuthService } from '../src/modules/auth/service';
import { import {
acceptInvite,
acceptInviteById, acceptInviteById,
createWorkspace, createWorkspace,
getWorkspace, getWorkspace,
@@ -78,33 +77,20 @@ test('should invite a user', async t => {
t.truthy(invite, 'failed to invite user'); 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 => { test('should leave a workspace', async t => {
const { app } = t.context; const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1'); const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token); const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); const id = await inviteUser(
await acceptInvite(app, u2.token.token, workspace.id); 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); 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 u3 = await signUp(app, 'u3', 'u3@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token); const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); const invite1 = await inviteUser(
await inviteUser(app, u1.token.token, workspace.id, u3.email, 'Admin'); 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 acceptInviteById(app, workspace.id, invite1, false);
await acceptInvite(app, u3.token.token, workspace.id); await acceptInviteById(app, workspace.id, invite2, false);
const firstPageWorkspace = await getWorkspace( const firstPageWorkspace = await getWorkspace(
app, app,

View File

@@ -12,7 +12,7 @@ import { StorageProvide } from '../src/storage';
import { FakePrisma } from './utils'; import { FakePrisma } from './utils';
class FakePermission { class FakePermission {
async tryCheck() { async tryCheckWorkspace() {
return true; return true;
} }
async getWorkspaceOwner() { async getWorkspaceOwner() {
@@ -37,7 +37,7 @@ test.beforeEach(async t => {
}) })
.overrideProvider(PrismaService) .overrideProvider(PrismaService)
.useValue({ .useValue({
userWorkspacePermission: { workspaceUserPermission: {
async findMany() { async findMany() {
return []; return [];
}, },

View File

@@ -7,14 +7,14 @@ import request from 'supertest';
import { AppModule } from '../src/app'; import { AppModule } from '../src/app';
import { import {
acceptInvite, acceptInviteById,
createWorkspace, createWorkspace,
currentUser, currentUser,
getPublicWorkspace, getPublicWorkspace,
getWorkspaceSharedPages, getWorkspacePublicPages,
inviteUser, inviteUser,
revokePage, publishPage,
sharePage, revokePublicPage,
signUp, signUp,
updateWorkspace, updateWorkspace,
} from './utils'; } from './utils';
@@ -122,15 +122,19 @@ test('should share a page', async t => {
const workspace = await createWorkspace(app, u1.token.token); const workspace = await createWorkspace(app, u1.token.token);
const share = await sharePage(app, u1.token.token, workspace.id, 'page1'); const share = await publishPage(app, u1.token.token, workspace.id, 'page1');
t.true(share, 'failed to share page'); t.is(share.id, 'page1', 'failed to share page');
const pages = await getWorkspaceSharedPages( const pages = await getWorkspacePublicPages(
app, app,
u1.token.token, u1.token.token,
workspace.id workspace.id
); );
t.is(pages.length, 1, 'failed to get shared pages'); 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()) const resp1 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`) .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( const resp2 = await request(app.getHttpServer()).get(
`/api/workspaces/${workspace.id}/docs/${workspace.id}` `/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()) const resp3 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/page1`) .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 // 404 because we don't put the page doc to server
t.is(resp4.statusCode, 404, 'should not get shared doc without token'); 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'); 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'); t.is(msg2, 'Permission denied', 'unauthorized user can share page');
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); await acceptInviteById(
await acceptInvite(app, u2.token.token, workspace.id); app,
const invited = await sharePage(app, u2.token.token, workspace.id, 'page2'); workspace.id,
t.true(invited, 'failed to share page'); 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'); const revoke = await revokePublicPage(
t.true(revoke, 'failed to revoke page'); app,
const pages2 = await getWorkspaceSharedPages( u1.token.token,
workspace.id,
'page1'
);
t.false(revoke.public, 'failed to revoke page');
const pages2 = await getWorkspacePublicPages(
app, app,
u1.token.token, u1.token.token,
workspace.id workspace.id
); );
t.is(pages2.length, 1, 'failed to get shared pages'); 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'); const msg3 = await revokePublicPage(
t.false(msg3, 'can revoke non-exists page'); 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'); const msg4 = await revokePublicPage(
t.true(msg4, 'failed to revoke page'); app,
const page3 = await getWorkspaceSharedPages( u1.token.token,
workspace.id,
'page2'
);
t.false(msg4.public, 'failed to revoke page');
const page3 = await getWorkspacePublicPages(
app, app,
u1.token.token, u1.token.token,
workspace.id workspace.id
@@ -211,13 +238,17 @@ test('should can get workspace doc', async t => {
.auth(u2.token.token, { type: 'bearer' }) .auth(u2.token.token, { type: 'bearer' })
.expect(403); .expect(403);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await request(app.getHttpServer()) await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`) .get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' }) .auth(u2.token.token, { type: 'bearer' })
.expect(403); .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()) const res2 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`) .get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' }) .auth(u2.token.token, { type: 'bearer' })

View File

@@ -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)
}`,
};

View File

@@ -1,3 +0,0 @@
mutation acceptInviteByWorkspaceId($workspaceId: String!) {
acceptInvite(workspaceId: $workspaceId)
}

View File

@@ -52,6 +52,12 @@ export enum Permission {
Write = 'Write', Write = 'Write',
} }
/** The mode which the public page default in */
export enum PublicPageMode {
Edgeless = 'Edgeless',
Page = 'Page',
}
export enum SubscriptionPlan { export enum SubscriptionPlan {
Enterprise = 'Enterprise', Enterprise = 'Enterprise',
Free = 'Free', Free = 'Free',
@@ -615,15 +621,6 @@ export type AcceptInviteByInviteIdMutation = {
acceptInviteById: boolean; acceptInviteById: boolean;
}; };
export type AcceptInviteByWorkspaceIdMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type AcceptInviteByWorkspaceIdMutation = {
__typename?: 'Mutation';
acceptInvite: boolean;
};
export type Queries = export type Queries =
| { | {
name: 'checkBlobSizesQuery'; name: 'checkBlobSizesQuery';
@@ -856,9 +853,4 @@ export type Mutations =
name: 'acceptInviteByInviteIdMutation'; name: 'acceptInviteByInviteIdMutation';
variables: AcceptInviteByInviteIdMutationVariables; variables: AcceptInviteByInviteIdMutationVariables;
response: AcceptInviteByInviteIdMutation; response: AcceptInviteByInviteIdMutation;
}
| {
name: 'acceptInviteByWorkspaceIdMutation';
variables: AcceptInviteByWorkspaceIdMutationVariables;
response: AcceptInviteByWorkspaceIdMutation;
}; };

View File

@@ -79,10 +79,9 @@ export async function addUserToWorkspace(
if (workspace == null) { if (workspace == null) {
throw new Error(`workspace ${workspaceId} not found`); throw new Error(`workspace ${workspaceId} not found`);
} }
await client.userWorkspacePermission.create({ await client.workspaceUserPermission.create({
data: { data: {
workspaceId: workspace.id, workspaceId: workspace.id,
subPageId: null,
userId, userId,
accepted: true, accepted: true,
type: permission, type: permission,