mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 21:55:02 +03:00
refactor(server): separate page visibility from workspace permission (#4836)
This commit is contained in:
parent
e8987457ab
commit
f491ff94cc
@ -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;
|
@ -9,16 +9,61 @@ 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
|
||||
public Boolean
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
users UserWorkspacePermission[]
|
||||
|
||||
pages WorkspacePage[]
|
||||
permissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
model UserWorkspacePermission {
|
||||
// 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
|
||||
@ -28,32 +73,44 @@ model UserWorkspacePermission {
|
||||
/// 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, 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[]
|
||||
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)
|
||||
/// 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 {
|
||||
|
@ -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');
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
//
|
||||
}
|
||||
}
|
@ -81,7 +81,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
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<EventResponse<{ missing: string; state?: string }>> {
|
||||
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),
|
||||
|
@ -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 {}
|
||||
|
@ -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,68 +51,62 @@ export class PermissionService {
|
||||
}
|
||||
|
||||
async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
|
||||
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;
|
||||
return this.tryCheckPage(ws, id, user);
|
||||
}
|
||||
|
||||
// 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,
|
||||
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({
|
||||
if (user) {
|
||||
// normally check if the user has the permission
|
||||
const count = await this.prisma.workspaceUserPermission.count({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: null,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
type: {
|
||||
@ -116,7 +115,12 @@ export class PermissionService {
|
||||
},
|
||||
});
|
||||
|
||||
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<string> {
|
||||
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<any>[]
|
||||
);
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
@ -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!
|
||||
|
@ -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(),
|
||||
|
@ -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<string[]> {
|
||||
) {
|
||||
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<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(
|
||||
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<boolean | string> {
|
||||
) {
|
||||
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<boolean | string> {
|
||||
) {
|
||||
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,
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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 [];
|
||||
},
|
||||
|
@ -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' })
|
||||
|
@ -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)
|
||||
}`,
|
||||
};
|
||||
|
@ -1,3 +0,0 @@
|
||||
mutation acceptInviteByWorkspaceId($workspaceId: String!) {
|
||||
acceptInvite(workspaceId: $workspaceId)
|
||||
}
|
@ -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;
|
||||
};
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user