refactor(server): permission (#10449)
This commit is contained in:
@@ -24,8 +24,8 @@ model User {
|
|||||||
|
|
||||||
features UserFeature[]
|
features UserFeature[]
|
||||||
userStripeCustomer UserStripeCustomer?
|
userStripeCustomer UserStripeCustomer?
|
||||||
workspacePermissions WorkspaceUserPermission[]
|
workspacePermissions WorkspaceUserRole[]
|
||||||
docPermissions WorkspaceDocUserPermission[]
|
docPermissions WorkspaceDocUserRole[]
|
||||||
connectedAccounts ConnectedAccount[]
|
connectedAccounts ConnectedAccount[]
|
||||||
sessions UserSession[]
|
sessions UserSession[]
|
||||||
aiSessions AiSession[]
|
aiSessions AiSession[]
|
||||||
@@ -104,8 +104,8 @@ model Workspace {
|
|||||||
|
|
||||||
features WorkspaceFeature[]
|
features WorkspaceFeature[]
|
||||||
docs WorkspaceDoc[]
|
docs WorkspaceDoc[]
|
||||||
permissions WorkspaceUserPermission[]
|
permissions WorkspaceUserRole[]
|
||||||
docPermissions WorkspaceDocUserPermission[]
|
docPermissions WorkspaceDocUserRole[]
|
||||||
blobs Blob[]
|
blobs Blob[]
|
||||||
|
|
||||||
@@map("workspaces")
|
@@map("workspaces")
|
||||||
@@ -139,7 +139,7 @@ enum WorkspaceMemberStatus {
|
|||||||
Accepted // 4. old state accepted = true
|
Accepted // 4. old state accepted = true
|
||||||
}
|
}
|
||||||
|
|
||||||
model WorkspaceUserPermission {
|
model WorkspaceUserRole {
|
||||||
id String @id @default(uuid()) @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
userId String @map("user_id") @db.VarChar
|
userId String @map("user_id") @db.VarChar
|
||||||
@@ -162,7 +162,7 @@ model WorkspaceUserPermission {
|
|||||||
@@map("workspace_user_permissions")
|
@@map("workspace_user_permissions")
|
||||||
}
|
}
|
||||||
|
|
||||||
model WorkspaceDocUserPermission {
|
model WorkspaceDocUserRole {
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
docId String @map("page_id") @db.VarChar
|
docId String @map("page_id") @db.VarChar
|
||||||
userId String @map("user_id") @db.VarChar
|
userId String @map("user_id") @db.VarChar
|
||||||
|
|||||||
160
packages/backend/server/src/__tests__/models/doc-user.spec.ts
Normal file
160
packages/backend/server/src/__tests__/models/doc-user.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import test from 'ava';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
|
import { EventBus } from '../../base';
|
||||||
|
import { DocRole, Models } from '../../models';
|
||||||
|
import { createTestingModule, TestingModule } from '../utils';
|
||||||
|
|
||||||
|
let db: PrismaClient;
|
||||||
|
let models: Models;
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
module = await createTestingModule({
|
||||||
|
tapModule: m => {
|
||||||
|
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
models = module.get(Models);
|
||||||
|
db = module.get(PrismaClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await module.initTestingDB();
|
||||||
|
Sinon.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await module.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
return db.workspace.create({
|
||||||
|
data: { public: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('should set doc owner', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
const docId = 'fake-doc-id';
|
||||||
|
|
||||||
|
await models.docUser.setOwner(workspace.id, docId, user.id);
|
||||||
|
const role = await models.docUser.get(workspace.id, docId, user.id);
|
||||||
|
|
||||||
|
t.is(role?.type, DocRole.Owner);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should transfer doc owner', async t => {
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
const user2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||||
|
const workspace = await create();
|
||||||
|
const docId = 'fake-doc-id';
|
||||||
|
|
||||||
|
await models.docUser.setOwner(workspace.id, docId, user.id);
|
||||||
|
await models.docUser.setOwner(workspace.id, docId, user2.id);
|
||||||
|
|
||||||
|
const oldOwnerRole = await models.docUser.get(workspace.id, docId, user.id);
|
||||||
|
const newOwnerRole = await models.docUser.get(workspace.id, docId, user2.id);
|
||||||
|
|
||||||
|
t.is(oldOwnerRole?.type, DocRole.Manager);
|
||||||
|
t.is(newOwnerRole?.type, DocRole.Owner);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set doc user role', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
const docId = 'fake-doc-id';
|
||||||
|
|
||||||
|
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
|
||||||
|
const role = await models.docUser.get(workspace.id, docId, user.id);
|
||||||
|
|
||||||
|
t.is(role?.type, DocRole.Manager);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not allow setting doc owner through setDocUserRole', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
const docId = 'fake-doc-id';
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
models.docUser.set(workspace.id, docId, user.id, DocRole.Owner),
|
||||||
|
{ message: 'Cannot set Owner role of a doc to a user.' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete doc user role', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
const docId = 'fake-doc-id';
|
||||||
|
|
||||||
|
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
|
||||||
|
await models.docUser.delete(workspace.id, docId, user.id);
|
||||||
|
|
||||||
|
const role = await models.docUser.get(workspace.id, docId, user.id);
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should paginate doc user roles', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const docId = 'fake-doc-id';
|
||||||
|
await db.user.createMany({
|
||||||
|
data: Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
name: `u${i}`,
|
||||||
|
email: `${i}@affine.pro`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.workspaceDocUserRole.createMany({
|
||||||
|
data: Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
userId: String(i),
|
||||||
|
type: DocRole.Editor,
|
||||||
|
createdAt: new Date(Date.now() + i * 1000),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [roles, total] = await models.docUser.paginate(workspace.id, docId, {
|
||||||
|
first: 10,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(roles.length, 10);
|
||||||
|
t.is(total, 200);
|
||||||
|
|
||||||
|
const [roles2] = await models.docUser.paginate(workspace.id, docId, {
|
||||||
|
after: roles.at(-1)?.createdAt.toISOString(),
|
||||||
|
first: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(roles2.length, 50);
|
||||||
|
t.not(roles2[0].type, DocRole.Owner);
|
||||||
|
t.deepEqual(
|
||||||
|
roles2.map(r => r.userId),
|
||||||
|
roles2
|
||||||
|
.toSorted((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
|
.map(r => r.userId)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should count doc user roles', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const docId = 'fake-doc-id';
|
||||||
|
const users = await Promise.all([
|
||||||
|
models.user.create({ email: 'u1@affine.pro' }),
|
||||||
|
models.user.create({ email: 'u2@affine.pro' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
users.map(user =>
|
||||||
|
models.docUser.set(workspace.id, docId, user.id, DocRole.Manager)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const count = await models.docUser.count(workspace.id, docId);
|
||||||
|
t.is(count, 2);
|
||||||
|
});
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import ava, { TestFn } from 'ava';
|
|
||||||
|
|
||||||
import { Config } from '../../base/config';
|
|
||||||
import { WorkspaceRole } from '../../core/permission';
|
|
||||||
import { PublicPageMode } from '../../models/common';
|
|
||||||
import { PageModel } from '../../models/page';
|
|
||||||
import { type User, UserModel } from '../../models/user';
|
|
||||||
import { type Workspace, WorkspaceModel } from '../../models/workspace';
|
|
||||||
import { createTestingModule, type TestingModule } from '../utils';
|
|
||||||
|
|
||||||
interface Context {
|
|
||||||
config: Config;
|
|
||||||
module: TestingModule;
|
|
||||||
db: PrismaClient;
|
|
||||||
user: UserModel;
|
|
||||||
workspace: WorkspaceModel;
|
|
||||||
page: PageModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const test = ava as TestFn<Context>;
|
|
||||||
|
|
||||||
test.before(async t => {
|
|
||||||
const module = await createTestingModule();
|
|
||||||
|
|
||||||
t.context.user = module.get(UserModel);
|
|
||||||
t.context.workspace = module.get(WorkspaceModel);
|
|
||||||
t.context.page = module.get(PageModel);
|
|
||||||
t.context.db = module.get(PrismaClient);
|
|
||||||
t.context.config = module.get(Config);
|
|
||||||
t.context.module = module;
|
|
||||||
});
|
|
||||||
|
|
||||||
let user: User;
|
|
||||||
let workspace: Workspace;
|
|
||||||
|
|
||||||
test.beforeEach(async t => {
|
|
||||||
await t.context.module.initTestingDB();
|
|
||||||
user = await t.context.user.create({
|
|
||||||
email: 'test@affine.pro',
|
|
||||||
});
|
|
||||||
workspace = await t.context.workspace.create(user.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.after(async t => {
|
|
||||||
await t.context.module.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create page with default mode and public false', async t => {
|
|
||||||
const page = await t.context.page.upsert(workspace.id, 'page1');
|
|
||||||
t.is(page.workspaceId, workspace.id);
|
|
||||||
t.is(page.docId, 'page1');
|
|
||||||
t.is(page.mode, PublicPageMode.Page);
|
|
||||||
t.is(page.public, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should update page', async t => {
|
|
||||||
const page = await t.context.page.upsert(workspace.id, 'page1');
|
|
||||||
const data = {
|
|
||||||
mode: PublicPageMode.Edgeless,
|
|
||||||
public: true,
|
|
||||||
};
|
|
||||||
await t.context.page.upsert(workspace.id, 'page1', data);
|
|
||||||
const page1 = await t.context.page.get(workspace.id, 'page1');
|
|
||||||
t.deepEqual(page1, {
|
|
||||||
...page,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should get null when page not exists', async t => {
|
|
||||||
const page = await t.context.page.get(workspace.id, 'page1');
|
|
||||||
t.is(page, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should get page by id and public flag', async t => {
|
|
||||||
await t.context.page.upsert(workspace.id, 'page1');
|
|
||||||
await t.context.page.upsert(workspace.id, 'page2', {
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
let page1 = await t.context.page.get(workspace.id, 'page1');
|
|
||||||
t.is(page1!.public, false);
|
|
||||||
page1 = await t.context.page.get(workspace.id, 'page1', true);
|
|
||||||
t.is(page1, null);
|
|
||||||
let page2 = await t.context.page.get(workspace.id, 'page2', true);
|
|
||||||
t.is(page2!.public, true);
|
|
||||||
page2 = await t.context.page.get(workspace.id, 'page2', false);
|
|
||||||
t.is(page2, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should get public page count', async t => {
|
|
||||||
await t.context.page.upsert(workspace.id, 'page1', {
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
await t.context.page.upsert(workspace.id, 'page2', {
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
await t.context.page.upsert(workspace.id, 'page3');
|
|
||||||
const count = await t.context.page.getPublicsCount(workspace.id);
|
|
||||||
t.is(count, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should get public pages of a workspace', async t => {
|
|
||||||
await t.context.page.upsert(workspace.id, 'page1', {
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
await t.context.page.upsert(workspace.id, 'page2', {
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
await t.context.page.upsert(workspace.id, 'page3');
|
|
||||||
const pages = await t.context.page.findPublics(workspace.id);
|
|
||||||
t.is(pages.length, 2);
|
|
||||||
t.deepEqual(pages.map(p => p.docId).sort(), ['page1', 'page2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should grant a member to access a page', async t => {
|
|
||||||
await t.context.page.upsert(workspace.id, 'page1', {
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
const member = await t.context.user.create({
|
|
||||||
email: 'test1@affine.pro',
|
|
||||||
});
|
|
||||||
await t.context.page.grantMember(workspace.id, 'page1', member.id);
|
|
||||||
let hasAccess = await t.context.page.isMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
member.id
|
|
||||||
);
|
|
||||||
t.true(hasAccess);
|
|
||||||
hasAccess = await t.context.page.isMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Collaborator
|
|
||||||
);
|
|
||||||
t.false(hasAccess);
|
|
||||||
// grant write permission
|
|
||||||
await t.context.page.grantMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Collaborator
|
|
||||||
);
|
|
||||||
hasAccess = await t.context.page.isMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Collaborator
|
|
||||||
);
|
|
||||||
t.true(hasAccess);
|
|
||||||
hasAccess = await t.context.page.isMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Collaborator
|
|
||||||
);
|
|
||||||
t.true(hasAccess);
|
|
||||||
// delete member
|
|
||||||
const count = await t.context.page.deleteMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
t.is(count, 1);
|
|
||||||
hasAccess = await t.context.page.isMember(workspace.id, 'page1', user.id);
|
|
||||||
t.false(hasAccess);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should change the page owner', async t => {
|
|
||||||
await t.context.page.upsert(workspace.id, 'page1', {
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
await t.context.page.grantMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Owner
|
|
||||||
);
|
|
||||||
t.true(
|
|
||||||
await t.context.page.isMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Owner
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// change owner
|
|
||||||
const otherUser = await t.context.user.create({
|
|
||||||
email: 'test1@affine.pro',
|
|
||||||
});
|
|
||||||
await t.context.page.grantMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
otherUser.id,
|
|
||||||
WorkspaceRole.Owner
|
|
||||||
);
|
|
||||||
t.true(
|
|
||||||
await t.context.page.isMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
otherUser.id,
|
|
||||||
WorkspaceRole.Owner
|
|
||||||
)
|
|
||||||
);
|
|
||||||
t.false(
|
|
||||||
await t.context.page.isMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Owner
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not delete owner from page', async t => {
|
|
||||||
await t.context.page.upsert(workspace.id, 'page1', {
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
await t.context.page.grantMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Owner
|
|
||||||
);
|
|
||||||
const count = await t.context.page.deleteMember(
|
|
||||||
workspace.id,
|
|
||||||
'page1',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
t.is(count, 0);
|
|
||||||
});
|
|
||||||
@@ -2,13 +2,13 @@ import ava, { TestFn } from 'ava';
|
|||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { EmailAlreadyUsed, EventBus } from '../../base';
|
import { EmailAlreadyUsed, EventBus } from '../../base';
|
||||||
import { WorkspaceRole } from '../../core/permission';
|
import { Models } from '../../models';
|
||||||
import { UserModel } from '../../models/user';
|
import { UserModel } from '../../models/user';
|
||||||
import { WorkspaceMemberStatus } from '../../models/workspace';
|
|
||||||
import { createTestingModule, sleep, type TestingModule } from '../utils';
|
import { createTestingModule, sleep, type TestingModule } from '../utils';
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
module: TestingModule;
|
module: TestingModule;
|
||||||
|
models: Models;
|
||||||
user: UserModel;
|
user: UserModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ test.before(async t => {
|
|||||||
const module = await createTestingModule({});
|
const module = await createTestingModule({});
|
||||||
|
|
||||||
t.context.user = module.get(UserModel);
|
t.context.user = module.get(UserModel);
|
||||||
|
t.context.models = module.get(Models);
|
||||||
t.context.module = module;
|
t.context.module = module;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,24 +254,13 @@ test('should trigger user.deleted event', async t => {
|
|||||||
|
|
||||||
const user = await t.context.user.create({
|
const user = await t.context.user.create({
|
||||||
email: 'test@affine.pro',
|
email: 'test@affine.pro',
|
||||||
workspacePermissions: {
|
|
||||||
create: {
|
|
||||||
workspace: {
|
|
||||||
create: {
|
|
||||||
id: 'test-workspace',
|
|
||||||
public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const workspace = await t.context.models.workspace.create(user.id);
|
||||||
|
|
||||||
await t.context.user.delete(user.id);
|
await t.context.user.delete(user.id);
|
||||||
|
|
||||||
t.true(
|
t.true(
|
||||||
spy.calledOnceWithExactly({ ...user, ownedWorkspaces: ['test-workspace'] })
|
spy.calledOnceWithExactly({ ...user, ownedWorkspaces: [workspace.id] })
|
||||||
);
|
);
|
||||||
// await for 'user.deleted' event to be emitted and executed
|
// await for 'user.deleted' event to be emitted and executed
|
||||||
// avoid race condition cause database dead lock
|
// avoid race condition cause database dead lock
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import test from 'ava';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
|
import { EventBus } from '../../base';
|
||||||
|
import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models';
|
||||||
|
import { createTestingModule, TestingModule } from '../utils';
|
||||||
|
|
||||||
|
let db: PrismaClient;
|
||||||
|
let models: Models;
|
||||||
|
let module: TestingModule;
|
||||||
|
let event: Sinon.SinonStubbedInstance<EventBus>;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
module = await createTestingModule({
|
||||||
|
tapModule: m => {
|
||||||
|
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
models = module.get(Models);
|
||||||
|
event = module.get(EventBus);
|
||||||
|
db = module.get(PrismaClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await module.initTestingDB();
|
||||||
|
Sinon.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await module.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
return db.workspace.create({
|
||||||
|
data: { public: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('should set workspace owner', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
await models.workspaceUser.setOwner(workspace.id, user.id);
|
||||||
|
const owner = await models.workspaceUser.getOwner(workspace.id);
|
||||||
|
|
||||||
|
t.is(owner.id, user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should transfer workespace owner', async t => {
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
const user2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||||
|
const workspace = await models.workspace.create(user.id);
|
||||||
|
|
||||||
|
await models.workspaceUser.setOwner(workspace.id, user2.id);
|
||||||
|
|
||||||
|
t.true(
|
||||||
|
event.emit.lastCall.calledWith('workspace.owner.changed', {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
from: user.id,
|
||||||
|
to: user2.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const owner2 = await models.workspaceUser.getOwner(workspace.id);
|
||||||
|
t.is(owner2.id, user2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get user role', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin);
|
||||||
|
|
||||||
|
const role = await models.workspaceUser.get(workspace.id, user.id);
|
||||||
|
|
||||||
|
t.is(role!.type, WorkspaceRole.Admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get active workspace role', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
await models.workspaceUser.set(
|
||||||
|
workspace.id,
|
||||||
|
user.id,
|
||||||
|
WorkspaceRole.Admin,
|
||||||
|
WorkspaceMemberStatus.Accepted
|
||||||
|
);
|
||||||
|
|
||||||
|
const role = await models.workspaceUser.getActive(workspace.id, user.id);
|
||||||
|
|
||||||
|
t.is(role!.type, WorkspaceRole.Admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not get inactive workspace role', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
|
||||||
|
const u1 = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
|
||||||
|
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
|
||||||
|
|
||||||
|
let role = await models.workspaceUser.getActive(workspace.id, u1.id);
|
||||||
|
t.is(role, null);
|
||||||
|
|
||||||
|
await models.workspaceUser.setStatus(
|
||||||
|
workspace.id,
|
||||||
|
u1.id,
|
||||||
|
WorkspaceMemberStatus.UnderReview
|
||||||
|
);
|
||||||
|
|
||||||
|
role = await models.workspaceUser.getActive(workspace.id, u1.id);
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update user role', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
await models.workspaceUser.set(
|
||||||
|
workspace.id,
|
||||||
|
user.id,
|
||||||
|
WorkspaceRole.Admin,
|
||||||
|
WorkspaceMemberStatus.Accepted
|
||||||
|
);
|
||||||
|
const role = await models.workspaceUser.get(workspace.id, user.id);
|
||||||
|
|
||||||
|
t.is(role!.type, WorkspaceRole.Admin);
|
||||||
|
|
||||||
|
await models.workspaceUser.set(
|
||||||
|
workspace.id,
|
||||||
|
user.id,
|
||||||
|
WorkspaceRole.Collaborator
|
||||||
|
);
|
||||||
|
|
||||||
|
const role2 = await models.workspaceUser.get(workspace.id, user.id);
|
||||||
|
|
||||||
|
t.is(role2!.type, WorkspaceRole.Collaborator);
|
||||||
|
t.deepEqual(event.emit.lastCall.args, [
|
||||||
|
'workspace.members.roleChanged',
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
role: WorkspaceRole.Collaborator,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return workspace role if status is Accepted', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const u1 = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
|
||||||
|
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
|
||||||
|
await models.workspaceUser.setStatus(
|
||||||
|
workspace.id,
|
||||||
|
u1.id,
|
||||||
|
WorkspaceMemberStatus.Accepted
|
||||||
|
);
|
||||||
|
const role = await models.workspaceUser.get(workspace.id, u1.id);
|
||||||
|
|
||||||
|
t.is(role!.type, WorkspaceRole.Admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete workspace user role', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
const u1 = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
|
||||||
|
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
|
||||||
|
await models.workspaceUser.setStatus(
|
||||||
|
workspace.id,
|
||||||
|
u1.id,
|
||||||
|
WorkspaceMemberStatus.Accepted
|
||||||
|
);
|
||||||
|
|
||||||
|
let role = await models.workspaceUser.get(workspace.id, u1.id);
|
||||||
|
t.is(role!.type, WorkspaceRole.Admin);
|
||||||
|
|
||||||
|
await models.workspaceUser.delete(workspace.id, u1.id);
|
||||||
|
|
||||||
|
role = await models.workspaceUser.get(workspace.id, u1.id);
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get user workspace roles with filter', async t => {
|
||||||
|
const ws1 = await create();
|
||||||
|
const ws2 = await create();
|
||||||
|
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
|
||||||
|
await db.workspaceUserRole.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
workspaceId: ws1.id,
|
||||||
|
userId: user.id,
|
||||||
|
type: WorkspaceRole.Admin,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workspaceId: ws2.id,
|
||||||
|
userId: user.id,
|
||||||
|
type: WorkspaceRole.Collaborator,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let roles = await models.workspaceUser.getUserActiveRoles(user.id, {
|
||||||
|
role: WorkspaceRole.Admin,
|
||||||
|
});
|
||||||
|
t.is(roles.length, 1);
|
||||||
|
t.is(roles[0].type, WorkspaceRole.Admin);
|
||||||
|
|
||||||
|
roles = await models.workspaceUser.getUserActiveRoles(user.id);
|
||||||
|
t.is(roles.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should paginate workspace user roles', async t => {
|
||||||
|
const workspace = await create();
|
||||||
|
await db.user.createMany({
|
||||||
|
data: Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
name: `u${i}`,
|
||||||
|
email: `${i}@affine.pro`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.workspaceUserRole.createMany({
|
||||||
|
data: Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: String(i),
|
||||||
|
type: WorkspaceRole.Collaborator,
|
||||||
|
status: Object.values(WorkspaceMemberStatus)[
|
||||||
|
Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length)
|
||||||
|
],
|
||||||
|
createdAt: new Date(Date.now() + i * 1000),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [roles, total] = await models.workspaceUser.paginate(workspace.id, {
|
||||||
|
first: 10,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(roles.length, 10);
|
||||||
|
t.is(total, 200);
|
||||||
|
|
||||||
|
const [roles2] = await models.workspaceUser.paginate(workspace.id, {
|
||||||
|
after: roles.at(-1)?.createdAt.toISOString(),
|
||||||
|
first: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(roles2.length, 50);
|
||||||
|
t.deepEqual(
|
||||||
|
roles2.map(r => r.id),
|
||||||
|
roles2
|
||||||
|
.toSorted((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
|
.map(r => r.id)
|
||||||
|
);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import { AppModule } from '../app.module';
|
|||||||
import { EventBus } from '../base';
|
import { EventBus } from '../base';
|
||||||
import { AuthService } from '../core/auth';
|
import { AuthService } from '../core/auth';
|
||||||
import { DocReader } from '../core/doc';
|
import { DocReader } from '../core/doc';
|
||||||
import { DocRole, PermissionService, WorkspaceRole } from '../core/permission';
|
import { DocRole, WorkspaceRole } from '../core/permission';
|
||||||
import { WorkspaceType } from '../core/workspaces';
|
import { WorkspaceType } from '../core/workspaces';
|
||||||
import { Models } from '../models';
|
import { Models } from '../models';
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +43,6 @@ const test = ava as TestFn<{
|
|||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
event: Sinon.SinonStubbedInstance<EventBus>;
|
event: Sinon.SinonStubbedInstance<EventBus>;
|
||||||
models: Models;
|
models: Models;
|
||||||
permissions: PermissionService;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
@@ -68,7 +67,6 @@ test.before(async t => {
|
|||||||
t.context.auth = app.get(AuthService);
|
t.context.auth = app.get(AuthService);
|
||||||
t.context.event = app.get(EventBus);
|
t.context.event = app.get(EventBus);
|
||||||
t.context.models = app.get(Models);
|
t.context.models = app.get(Models);
|
||||||
t.context.permissions = app.get(PermissionService);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async t => {
|
test.beforeEach(async t => {
|
||||||
@@ -256,7 +254,7 @@ test('should be able to invite multiple users', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to check seat limit', async t => {
|
test('should be able to check seat limit', async t => {
|
||||||
const { app, permissions, models } = t.context;
|
const { app, models } = t.context;
|
||||||
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 5);
|
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 5);
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -284,10 +282,8 @@ test('should be able to check seat limit', async t => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(
|
(await models.workspaceUser.get(ws.id, (await members1)[0][0].id))
|
||||||
ws.id,
|
?.status,
|
||||||
(await members1)[0][0].id
|
|
||||||
),
|
|
||||||
WorkspaceMemberStatus.NeedMoreSeat,
|
WorkspaceMemberStatus.NeedMoreSeat,
|
||||||
'should be able to check member status'
|
'should be able to check member status'
|
||||||
);
|
);
|
||||||
@@ -295,18 +291,16 @@ test('should be able to check seat limit', async t => {
|
|||||||
// refresh seat, fifo
|
// refresh seat, fifo
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
const [[members2]] = await inviteBatch(['member6@affine.pro']);
|
const [[members2]] = await inviteBatch(['member6@affine.pro']);
|
||||||
await permissions.refreshSeatStatus(ws.id, 7);
|
await models.workspaceUser.refresh(ws.id, 7);
|
||||||
|
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(
|
(await models.workspaceUser.get(ws.id, (await members1)[0][0].id))
|
||||||
ws.id,
|
?.status,
|
||||||
(await members1)[0][0].id
|
|
||||||
),
|
|
||||||
WorkspaceMemberStatus.Pending,
|
WorkspaceMemberStatus.Pending,
|
||||||
'should become accepted after refresh'
|
'should become accepted after refresh'
|
||||||
);
|
);
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(ws.id, members2.id),
|
(await models.workspaceUser.get(ws.id, members2.id))?.status,
|
||||||
WorkspaceMemberStatus.NeedMoreSeat,
|
WorkspaceMemberStatus.NeedMoreSeat,
|
||||||
'should not change status'
|
'should not change status'
|
||||||
);
|
);
|
||||||
@@ -314,7 +308,7 @@ test('should be able to check seat limit', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to grant team member permission', async t => {
|
test('should be able to grant team member permission', async t => {
|
||||||
const { app, permissions } = t.context;
|
const { app, models } = t.context;
|
||||||
const { owner, teamWorkspace: ws, write, read } = await init(app);
|
const { owner, teamWorkspace: ws, write, read } = await init(app);
|
||||||
|
|
||||||
app.switchUser(read);
|
app.switchUser(read);
|
||||||
@@ -335,11 +329,8 @@ test('should be able to grant team member permission', async t => {
|
|||||||
// owner should be able to grant permission
|
// owner should be able to grant permission
|
||||||
app.switchUser(owner);
|
app.switchUser(owner);
|
||||||
t.true(
|
t.true(
|
||||||
await permissions.tryCheckWorkspaceIs(
|
(await models.workspaceUser.get(ws.id, read.id))?.type ===
|
||||||
ws.id,
|
WorkspaceRole.Collaborator,
|
||||||
read.id,
|
|
||||||
WorkspaceRole.Collaborator
|
|
||||||
),
|
|
||||||
'should be able to check permission'
|
'should be able to check permission'
|
||||||
);
|
);
|
||||||
t.truthy(
|
t.truthy(
|
||||||
@@ -347,11 +338,8 @@ test('should be able to grant team member permission', async t => {
|
|||||||
'should be able to grant permission'
|
'should be able to grant permission'
|
||||||
);
|
);
|
||||||
t.true(
|
t.true(
|
||||||
await permissions.tryCheckWorkspaceIs(
|
(await models.workspaceUser.get(ws.id, read.id))?.type ===
|
||||||
ws.id,
|
WorkspaceRole.Admin,
|
||||||
read.id,
|
|
||||||
WorkspaceRole.Admin
|
|
||||||
),
|
|
||||||
'should be able to check permission'
|
'should be able to check permission'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -362,10 +350,9 @@ test('should be able to leave workspace', async t => {
|
|||||||
const { owner, teamWorkspace: ws, admin, write, read } = await init(app);
|
const { owner, teamWorkspace: ws, admin, write, read } = await init(app);
|
||||||
|
|
||||||
app.switchUser(owner);
|
app.switchUser(owner);
|
||||||
t.false(
|
await t.throwsAsync(leaveWorkspace(app, ws.id), {
|
||||||
await leaveWorkspace(app, ws.id),
|
message: 'Owner can not leave the workspace.',
|
||||||
'owner should not be able to leave workspace'
|
});
|
||||||
);
|
|
||||||
|
|
||||||
app.switchUser(admin);
|
app.switchUser(admin);
|
||||||
t.true(
|
t.true(
|
||||||
@@ -425,10 +412,9 @@ test('should be able to revoke team member', async t => {
|
|||||||
'owner should be able to revoke member'
|
'owner should be able to revoke member'
|
||||||
);
|
);
|
||||||
|
|
||||||
t.false(
|
await t.throwsAsync(revokeUser(app, ws.id, owner.id), {
|
||||||
await revokeUser(app, ws.id, owner.id),
|
message: 'You can not revoke your own permission.',
|
||||||
'should not be able to revoke themselves'
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await revokeUser(app, ws.id, admin.id);
|
await revokeUser(app, ws.id, admin.id);
|
||||||
app.switchUser(admin);
|
app.switchUser(admin);
|
||||||
@@ -508,7 +494,7 @@ test('should be able to approve team member', async t => {
|
|||||||
const memberInvite = members.find(m => m.id === member.id)!;
|
const memberInvite = members.find(m => m.id === member.id)!;
|
||||||
t.is(memberInvite.status, 'UnderReview', 'should be under review');
|
t.is(memberInvite.status, 'UnderReview', 'should be under review');
|
||||||
|
|
||||||
t.is(await approveMember(app, tws.id, member.id), memberInvite.inviteId);
|
t.true(await approveMember(app, tws.id, member.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -536,7 +522,7 @@ test('should be able to approve team member', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to invite by link', async t => {
|
test('should be able to invite by link', async t => {
|
||||||
const { app, permissions, models } = t.context;
|
const { app, models } = t.context;
|
||||||
const {
|
const {
|
||||||
createInviteLink,
|
createInviteLink,
|
||||||
owner,
|
owner,
|
||||||
@@ -562,10 +548,10 @@ test('should be able to invite by link', async t => {
|
|||||||
// invite link
|
// invite link
|
||||||
for (const [i] of Array.from({ length: 5 }).entries()) {
|
for (const [i] of Array.from({ length: 5 }).entries()) {
|
||||||
const user = await invite(`test${i}@affine.pro`);
|
const user = await invite(`test${i}@affine.pro`);
|
||||||
const status = await permissions.getWorkspaceMemberStatus(ws.id, user.id);
|
const status = (await models.workspaceUser.get(ws.id, user.id))?.status;
|
||||||
t.is(
|
t.is(
|
||||||
status,
|
status,
|
||||||
WorkspaceMemberStatus.Accepted,
|
WorkspaceMemberStatus.UnderReview,
|
||||||
'should be able to check status'
|
'should be able to check status'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -587,12 +573,12 @@ test('should be able to invite by link', async t => {
|
|||||||
const [m3, m4] = members;
|
const [m3, m4] = members;
|
||||||
|
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(tws.id, m3.id),
|
(await models.workspaceUser.get(tws.id, m3.id))?.status,
|
||||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||||
'should not change status'
|
'should not change status'
|
||||||
);
|
);
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(tws.id, m4.id),
|
(await models.workspaceUser.get(tws.id, m4.id))?.status,
|
||||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||||
'should not change status'
|
'should not change status'
|
||||||
);
|
);
|
||||||
@@ -600,14 +586,14 @@ test('should be able to invite by link', async t => {
|
|||||||
models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
|
models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
|
||||||
memberLimit: 6,
|
memberLimit: 6,
|
||||||
});
|
});
|
||||||
await permissions.refreshSeatStatus(tws.id, 6);
|
await models.workspaceUser.refresh(tws.id, 6);
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(tws.id, m3.id),
|
(await models.workspaceUser.get(tws.id, m3.id))?.status,
|
||||||
WorkspaceMemberStatus.UnderReview,
|
WorkspaceMemberStatus.UnderReview,
|
||||||
'should not change status'
|
'should not change status'
|
||||||
);
|
);
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(tws.id, m4.id),
|
(await models.workspaceUser.get(tws.id, m4.id))?.status,
|
||||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||||
'should not change status'
|
'should not change status'
|
||||||
);
|
);
|
||||||
@@ -615,9 +601,9 @@ test('should be able to invite by link', async t => {
|
|||||||
models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
|
models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
|
||||||
memberLimit: 7,
|
memberLimit: 7,
|
||||||
});
|
});
|
||||||
await permissions.refreshSeatStatus(tws.id, 7);
|
await models.workspaceUser.refresh(tws.id, 7);
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(tws.id, m4.id),
|
(await models.workspaceUser.get(tws.id, m4.id))?.status,
|
||||||
WorkspaceMemberStatus.UnderReview,
|
WorkspaceMemberStatus.UnderReview,
|
||||||
'should not change status'
|
'should not change status'
|
||||||
);
|
);
|
||||||
@@ -665,6 +651,7 @@ test('should be able to emit events', async t => {
|
|||||||
const { teamWorkspace: tws, owner, createInviteLink } = await init(app);
|
const { teamWorkspace: tws, owner, createInviteLink } = await init(app);
|
||||||
const [, invite] = await createInviteLink(tws);
|
const [, invite] = await createInviteLink(tws);
|
||||||
const user = await invite('m3@affine.pro');
|
const user = await invite('m3@affine.pro');
|
||||||
|
app.switchUser(owner);
|
||||||
const { members } = await getWorkspace(app, tws.id);
|
const { members } = await getWorkspace(app, tws.id);
|
||||||
const memberInvite = members.find(m => m.id === user.id)!;
|
const memberInvite = members.find(m => m.id === user.id)!;
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
@@ -698,7 +685,7 @@ test('should be able to emit events', async t => {
|
|||||||
{
|
{
|
||||||
userId: read.id,
|
userId: read.id,
|
||||||
workspaceId: tws.id,
|
workspaceId: tws.id,
|
||||||
permission: WorkspaceRole.Admin,
|
role: WorkspaceRole.Admin,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'should emit role changed event'
|
'should emit role changed event'
|
||||||
@@ -712,7 +699,7 @@ test('should be able to emit events', async t => {
|
|||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
ownershipTransferred,
|
ownershipTransferred,
|
||||||
[
|
[
|
||||||
'workspace.members.ownershipTransferred',
|
'workspace.owner.changed',
|
||||||
{ from: owner.id, to: read.id, workspaceId: tws.id },
|
{ from: owner.id, to: read.id, workspaceId: tws.id },
|
||||||
],
|
],
|
||||||
'should emit owner transferred event'
|
'should emit owner transferred event'
|
||||||
@@ -880,20 +867,15 @@ test('should be able to grant and revoke doc user role', async t => {
|
|||||||
grantDocUserRoles: true,
|
grantDocUserRoles: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// external user now can manage the page
|
// external user can never be able to manage the page
|
||||||
{
|
{
|
||||||
app.switchUser(external);
|
app.switchUser(external);
|
||||||
const externalRes = await grantDocUserRoles(
|
await t.throwsAsync(
|
||||||
app,
|
grantDocUserRoles(app, ws.id, docId, [read.id], DocRole.Manager),
|
||||||
ws.id,
|
{
|
||||||
docId,
|
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
|
||||||
[read.id],
|
}
|
||||||
DocRole.Manager
|
|
||||||
);
|
);
|
||||||
|
|
||||||
t.deepEqual(externalRes, {
|
|
||||||
grantDocUserRoles: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// revoke the role of the external user
|
// revoke the role of the external user
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
|
import { type Blob } from '@prisma/client';
|
||||||
|
|
||||||
import { TestingApp } from './testing-app';
|
import { TestingApp } from './testing-app';
|
||||||
|
|
||||||
export async function listBlobs(
|
export async function listBlobs(
|
||||||
app: TestingApp,
|
app: TestingApp,
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
): Promise<string[]> {
|
): Promise<Blob[]> {
|
||||||
const res = await app.gql(`
|
const res = await app.gql(`
|
||||||
query {
|
query {
|
||||||
listBlobs(workspaceId: "${workspaceId}")
|
workspace(id: "${workspaceId}") {
|
||||||
|
blobs {
|
||||||
|
key
|
||||||
|
mime
|
||||||
|
size
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
return res.listBlobs;
|
return res.workspace.blobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkspaceBlobsSize(
|
export async function getWorkspaceBlobsSize(
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ test('should visit public page', async t => {
|
|||||||
const docs2 = await getWorkspacePublicDocs(app, workspace.id);
|
const docs2 = await getWorkspacePublicDocs(app, workspace.id);
|
||||||
t.is(docs2.length, 0, 'failed to get shared docs');
|
t.is(docs2.length, 0, 'failed to get shared docs');
|
||||||
await t.throwsAsync(revokePublicDoc(app, workspace.id, 'doc3'), {
|
await t.throwsAsync(revokePublicDoc(app, workspace.id, 'doc3'), {
|
||||||
message: 'Doc is not public',
|
message: 'Doc is not public.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const docs3 = await getWorkspacePublicDocs(app, workspace.id);
|
const docs3 = await getWorkspacePublicDocs(app, workspace.id);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ test('should list blobs', async t => {
|
|||||||
const ret = await listBlobs(app, workspace.id);
|
const ret = await listBlobs(app, workspace.id);
|
||||||
t.is(ret.length, 2, 'failed to list blobs');
|
t.is(ret.length, 2, 'failed to list blobs');
|
||||||
// list blob result is not ordered
|
// list blob result is not ordered
|
||||||
t.deepEqual(ret.sort(), [hash1, hash2].sort());
|
t.deepEqual(ret.map(x => x.key).sort(), [hash1, hash2].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should auto delete blobs when workspace is deleted', async t => {
|
test('should auto delete blobs when workspace is deleted', async t => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Sinon from 'sinon';
|
|||||||
|
|
||||||
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||||
import { WorkspaceBlobStorage } from '../../core/storage';
|
import { WorkspaceBlobStorage } from '../../core/storage';
|
||||||
|
import { Models, WorkspaceRole } from '../../models';
|
||||||
import { createTestingApp, TestingApp, TestUser } from '../utils';
|
import { createTestingApp, TestingApp, TestUser } from '../utils';
|
||||||
|
|
||||||
const test = ava as TestFn<{
|
const test = ava as TestFn<{
|
||||||
@@ -15,6 +16,7 @@ const test = ava as TestFn<{
|
|||||||
u1: TestUser;
|
u1: TestUser;
|
||||||
storage: Sinon.SinonStubbedInstance<WorkspaceBlobStorage>;
|
storage: Sinon.SinonStubbedInstance<WorkspaceBlobStorage>;
|
||||||
workspace: Sinon.SinonStubbedInstance<PgWorkspaceDocStorageAdapter>;
|
workspace: Sinon.SinonStubbedInstance<PgWorkspaceDocStorageAdapter>;
|
||||||
|
models: Models;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
@@ -34,6 +36,7 @@ test.before(async t => {
|
|||||||
t.context.app = app;
|
t.context.app = app;
|
||||||
t.context.storage = app.get(WorkspaceBlobStorage);
|
t.context.storage = app.get(WorkspaceBlobStorage);
|
||||||
t.context.workspace = app.get(PgWorkspaceDocStorageAdapter);
|
t.context.workspace = app.get(PgWorkspaceDocStorageAdapter);
|
||||||
|
t.context.models = app.get(Models);
|
||||||
|
|
||||||
await db.workspaceDoc.create({
|
await db.workspaceDoc.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -155,17 +158,14 @@ test('should not be able to get private workspace with no public pages', async t
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to get permission granted workspace', async t => {
|
test('should be able to get permission granted workspace', async t => {
|
||||||
const { app, db, storage } = t.context;
|
const { app, storage } = t.context;
|
||||||
|
|
||||||
await db.workspaceUserPermission.create({
|
await t.context.models.workspaceUser.set(
|
||||||
data: {
|
'totally-private',
|
||||||
workspaceId: 'totally-private',
|
t.context.u1.id,
|
||||||
userId: t.context.u1.id,
|
WorkspaceRole.Collaborator,
|
||||||
type: 1,
|
WorkspaceMemberStatus.Accepted
|
||||||
accepted: true,
|
);
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
storage.get.resolves(blob());
|
storage.get.resolves(blob());
|
||||||
await app.login(t.context.u1);
|
await app.login(t.context.u1);
|
||||||
|
|||||||
@@ -419,6 +419,14 @@ export const USER_FRIENDLY_ERRORS = {
|
|||||||
args: { spaceId: 'string' },
|
args: { spaceId: 'string' },
|
||||||
message: 'Space should have only one owner.',
|
message: 'Space should have only one owner.',
|
||||||
},
|
},
|
||||||
|
owner_can_not_leave_workspace: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: 'Owner can not leave the workspace.',
|
||||||
|
},
|
||||||
|
can_not_revoke_yourself: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: 'You can not revoke your own permission.',
|
||||||
|
},
|
||||||
doc_not_found: {
|
doc_not_found: {
|
||||||
type: 'resource_not_found',
|
type: 'resource_not_found',
|
||||||
args: { spaceId: 'string', docId: 'string' },
|
args: { spaceId: 'string', docId: 'string' },
|
||||||
|
|||||||
@@ -298,6 +298,18 @@ export class SpaceShouldHaveOnlyOneOwner extends UserFriendlyError {
|
|||||||
super('invalid_input', 'space_should_have_only_one_owner', message, args);
|
super('invalid_input', 'space_should_have_only_one_owner', message, args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OwnerCanNotLeaveWorkspace extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'owner_can_not_leave_workspace', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CanNotRevokeYourself extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'can_not_revoke_yourself', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
class DocNotFoundDataType {
|
class DocNotFoundDataType {
|
||||||
@Field() spaceId!: string
|
@Field() spaceId!: string
|
||||||
@@ -855,6 +867,8 @@ export enum ErrorNames {
|
|||||||
SPACE_ACCESS_DENIED,
|
SPACE_ACCESS_DENIED,
|
||||||
SPACE_OWNER_NOT_FOUND,
|
SPACE_OWNER_NOT_FOUND,
|
||||||
SPACE_SHOULD_HAVE_ONLY_ONE_OWNER,
|
SPACE_SHOULD_HAVE_ONLY_ONE_OWNER,
|
||||||
|
OWNER_CAN_NOT_LEAVE_WORKSPACE,
|
||||||
|
CAN_NOT_REVOKE_YOURSELF,
|
||||||
DOC_NOT_FOUND,
|
DOC_NOT_FOUND,
|
||||||
DOC_ACTION_DENIED,
|
DOC_ACTION_DENIED,
|
||||||
VERSION_REJECTED,
|
VERSION_REJECTED,
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ import { Config } from '../../../base';
|
|||||||
import { ConfigModule } from '../../../base/config';
|
import { ConfigModule } from '../../../base/config';
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
import { PgWorkspaceDocStorageAdapter } from '../../doc';
|
import { PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||||
import { PermissionService } from '../../permission';
|
|
||||||
|
|
||||||
const test = ava as TestFn<{
|
const test = ava as TestFn<{
|
||||||
models: Models;
|
models: Models;
|
||||||
app: TestingApp;
|
app: TestingApp;
|
||||||
config: Config;
|
config: Config;
|
||||||
adapter: PgWorkspaceDocStorageAdapter;
|
adapter: PgWorkspaceDocStorageAdapter;
|
||||||
permission: PermissionService;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
@@ -38,7 +36,6 @@ test.before(async t => {
|
|||||||
t.context.models = app.get(Models);
|
t.context.models = app.get(Models);
|
||||||
t.context.config = app.get(Config);
|
t.context.config = app.get(Config);
|
||||||
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
|
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
|
||||||
t.context.permission = app.get(PermissionService);
|
|
||||||
t.context.app = app;
|
t.context.app = app;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +57,7 @@ test.after.always(async t => {
|
|||||||
|
|
||||||
test('should render page success', async t => {
|
test('should render page success', async t => {
|
||||||
const docId = randomUUID();
|
const docId = randomUUID();
|
||||||
const { app, adapter, permission } = t.context;
|
const { app, adapter, models } = t.context;
|
||||||
|
|
||||||
const doc = new YDoc();
|
const doc = new YDoc();
|
||||||
const text = doc.getText('content');
|
const text = doc.getText('content');
|
||||||
@@ -75,7 +72,7 @@ test('should render page success', async t => {
|
|||||||
text.insert(5, ' ');
|
text.insert(5, ' ');
|
||||||
|
|
||||||
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
|
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
|
||||||
await permission.publishPage(workspace.id, docId);
|
await models.workspace.publishDoc(workspace.id, docId);
|
||||||
|
|
||||||
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
|
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
|
||||||
t.pass();
|
t.pass();
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import type { Request, Response } from 'express';
|
|||||||
import isMobile from 'is-mobile';
|
import isMobile from 'is-mobile';
|
||||||
|
|
||||||
import { Config, metrics } from '../../base';
|
import { Config, metrics } from '../../base';
|
||||||
|
import { Models } from '../../models';
|
||||||
import { htmlSanitize } from '../../native';
|
import { htmlSanitize } from '../../native';
|
||||||
import { Public } from '../auth';
|
import { Public } from '../auth';
|
||||||
import { DocReader } from '../doc';
|
import { DocReader } from '../doc';
|
||||||
import { PermissionService } from '../permission';
|
|
||||||
|
|
||||||
interface RenderOptions {
|
interface RenderOptions {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -51,8 +51,8 @@ export class DocRendererController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly doc: DocReader,
|
private readonly doc: DocReader,
|
||||||
private readonly permission: PermissionService,
|
private readonly config: Config,
|
||||||
private readonly config: Config
|
private readonly models: Models
|
||||||
) {
|
) {
|
||||||
this.webAssets = this.readHtmlAssets(
|
this.webAssets = this.readHtmlAssets(
|
||||||
join(this.config.projectRoot, 'static')
|
join(this.config.projectRoot, 'static')
|
||||||
@@ -102,14 +102,15 @@ export class DocRendererController {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
docId: string
|
docId: string
|
||||||
): Promise<RenderOptions | null> {
|
): Promise<RenderOptions | null> {
|
||||||
let allowUrlPreview = await this.permission.isPublicPage(
|
let allowUrlPreview = await this.models.workspace.isPublicPage(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
docId
|
docId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!allowUrlPreview) {
|
if (!allowUrlPreview) {
|
||||||
// if page is private, but workspace url preview is on
|
// if page is private, but workspace url preview is on
|
||||||
allowUrlPreview = await this.permission.allowUrlPreview(workspaceId);
|
allowUrlPreview =
|
||||||
|
await this.models.workspace.allowUrlPreview(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowUrlPreview) {
|
if (allowUrlPreview) {
|
||||||
@@ -122,7 +123,8 @@ export class DocRendererController {
|
|||||||
private async getWorkspaceContent(
|
private async getWorkspaceContent(
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
): Promise<RenderOptions | null> {
|
): Promise<RenderOptions | null> {
|
||||||
const allowUrlPreview = await this.permission.allowUrlPreview(workspaceId);
|
const allowUrlPreview =
|
||||||
|
await this.models.workspace.allowUrlPreview(workspaceId);
|
||||||
|
|
||||||
if (allowUrlPreview) {
|
if (allowUrlPreview) {
|
||||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
metrics,
|
metrics,
|
||||||
Runtime,
|
Runtime,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
import { PermissionService } from '../permission';
|
|
||||||
import { QuotaService } from '../quota';
|
import { QuotaService } from '../quota';
|
||||||
import { DocStorageOptions as IDocStorageOptions } from './storage';
|
import { DocStorageOptions as IDocStorageOptions } from './storage';
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ export class DocStorageOptions implements IDocStorageOptions {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
private readonly runtime: Runtime,
|
private readonly runtime: Runtime,
|
||||||
private readonly permission: PermissionService,
|
|
||||||
private readonly quota: QuotaService
|
private readonly quota: QuotaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -87,8 +85,7 @@ export class DocStorageOptions implements IDocStorageOptions {
|
|||||||
};
|
};
|
||||||
|
|
||||||
historyMaxAge = async (spaceId: string) => {
|
historyMaxAge = async (spaceId: string) => {
|
||||||
const owner = await this.permission.getWorkspaceOwner(spaceId);
|
const quota = await this.quota.getWorkspaceQuota(spaceId);
|
||||||
const quota = await this.quota.getUserQuota(owner.id);
|
|
||||||
return quota.historyPeriod;
|
return quota.historyPeriod;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -91,13 +91,20 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
> WorkspaceRole: External
|
> WorkspaceRole: External
|
||||||
|
|
||||||
{
|
{
|
||||||
|
'Workspace.Adminitrators.Manage': false,
|
||||||
|
'Workspace.Blobs.List': false,
|
||||||
|
'Workspace.Blobs.Read': true,
|
||||||
|
'Workspace.Blobs.Write': false,
|
||||||
|
'Workspace.Copilot': false,
|
||||||
'Workspace.CreateDoc': false,
|
'Workspace.CreateDoc': false,
|
||||||
'Workspace.Delete': false,
|
'Workspace.Delete': false,
|
||||||
'Workspace.Organize.Read': true,
|
'Workspace.Organize.Read': true,
|
||||||
|
'Workspace.Payment.Manage': false,
|
||||||
'Workspace.Properties.Create': false,
|
'Workspace.Properties.Create': false,
|
||||||
'Workspace.Properties.Delete': false,
|
'Workspace.Properties.Delete': false,
|
||||||
'Workspace.Properties.Read': false,
|
'Workspace.Properties.Read': true,
|
||||||
'Workspace.Properties.Update': false,
|
'Workspace.Properties.Update': false,
|
||||||
|
'Workspace.Read': true,
|
||||||
'Workspace.Settings.Read': false,
|
'Workspace.Settings.Read': false,
|
||||||
'Workspace.Settings.Update': false,
|
'Workspace.Settings.Update': false,
|
||||||
'Workspace.Sync': false,
|
'Workspace.Sync': false,
|
||||||
@@ -109,13 +116,20 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
> WorkspaceRole: Collaborator
|
> WorkspaceRole: Collaborator
|
||||||
|
|
||||||
{
|
{
|
||||||
|
'Workspace.Adminitrators.Manage': false,
|
||||||
|
'Workspace.Blobs.List': true,
|
||||||
|
'Workspace.Blobs.Read': true,
|
||||||
|
'Workspace.Blobs.Write': true,
|
||||||
|
'Workspace.Copilot': true,
|
||||||
'Workspace.CreateDoc': true,
|
'Workspace.CreateDoc': true,
|
||||||
'Workspace.Delete': false,
|
'Workspace.Delete': false,
|
||||||
'Workspace.Organize.Read': true,
|
'Workspace.Organize.Read': true,
|
||||||
|
'Workspace.Payment.Manage': false,
|
||||||
'Workspace.Properties.Create': false,
|
'Workspace.Properties.Create': false,
|
||||||
'Workspace.Properties.Delete': false,
|
'Workspace.Properties.Delete': false,
|
||||||
'Workspace.Properties.Read': true,
|
'Workspace.Properties.Read': true,
|
||||||
'Workspace.Properties.Update': false,
|
'Workspace.Properties.Update': false,
|
||||||
|
'Workspace.Read': true,
|
||||||
'Workspace.Settings.Read': true,
|
'Workspace.Settings.Read': true,
|
||||||
'Workspace.Settings.Update': false,
|
'Workspace.Settings.Update': false,
|
||||||
'Workspace.Sync': true,
|
'Workspace.Sync': true,
|
||||||
@@ -127,13 +141,20 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
> WorkspaceRole: Admin
|
> WorkspaceRole: Admin
|
||||||
|
|
||||||
{
|
{
|
||||||
|
'Workspace.Adminitrators.Manage': false,
|
||||||
|
'Workspace.Blobs.List': true,
|
||||||
|
'Workspace.Blobs.Read': true,
|
||||||
|
'Workspace.Blobs.Write': true,
|
||||||
|
'Workspace.Copilot': true,
|
||||||
'Workspace.CreateDoc': true,
|
'Workspace.CreateDoc': true,
|
||||||
'Workspace.Delete': false,
|
'Workspace.Delete': false,
|
||||||
'Workspace.Organize.Read': true,
|
'Workspace.Organize.Read': true,
|
||||||
|
'Workspace.Payment.Manage': false,
|
||||||
'Workspace.Properties.Create': true,
|
'Workspace.Properties.Create': true,
|
||||||
'Workspace.Properties.Delete': true,
|
'Workspace.Properties.Delete': true,
|
||||||
'Workspace.Properties.Read': true,
|
'Workspace.Properties.Read': true,
|
||||||
'Workspace.Properties.Update': true,
|
'Workspace.Properties.Update': true,
|
||||||
|
'Workspace.Read': true,
|
||||||
'Workspace.Settings.Read': true,
|
'Workspace.Settings.Read': true,
|
||||||
'Workspace.Settings.Update': true,
|
'Workspace.Settings.Update': true,
|
||||||
'Workspace.Sync': true,
|
'Workspace.Sync': true,
|
||||||
@@ -145,13 +166,20 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
> WorkspaceRole: Owner
|
> WorkspaceRole: Owner
|
||||||
|
|
||||||
{
|
{
|
||||||
|
'Workspace.Adminitrators.Manage': true,
|
||||||
|
'Workspace.Blobs.List': true,
|
||||||
|
'Workspace.Blobs.Read': true,
|
||||||
|
'Workspace.Blobs.Write': true,
|
||||||
|
'Workspace.Copilot': true,
|
||||||
'Workspace.CreateDoc': true,
|
'Workspace.CreateDoc': true,
|
||||||
'Workspace.Delete': true,
|
'Workspace.Delete': true,
|
||||||
'Workspace.Organize.Read': true,
|
'Workspace.Organize.Read': true,
|
||||||
|
'Workspace.Payment.Manage': true,
|
||||||
'Workspace.Properties.Create': true,
|
'Workspace.Properties.Create': true,
|
||||||
'Workspace.Properties.Delete': true,
|
'Workspace.Properties.Delete': true,
|
||||||
'Workspace.Properties.Read': true,
|
'Workspace.Properties.Read': true,
|
||||||
'Workspace.Properties.Update': true,
|
'Workspace.Properties.Update': true,
|
||||||
|
'Workspace.Read': true,
|
||||||
'Workspace.Settings.Read': true,
|
'Workspace.Settings.Read': true,
|
||||||
'Workspace.Settings.Update': true,
|
'Workspace.Settings.Update': true,
|
||||||
'Workspace.Sync': true,
|
'Workspace.Sync': true,
|
||||||
@@ -257,13 +285,20 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
> Snapshot 1
|
> Snapshot 1
|
||||||
|
|
||||||
{
|
{
|
||||||
|
'Workspace.Adminitrators.Manage': 'Owner',
|
||||||
|
'Workspace.Blobs.List': 'Collaborator',
|
||||||
|
'Workspace.Blobs.Read': 'External',
|
||||||
|
'Workspace.Blobs.Write': 'Collaborator',
|
||||||
|
'Workspace.Copilot': 'Collaborator',
|
||||||
'Workspace.CreateDoc': 'Collaborator',
|
'Workspace.CreateDoc': 'Collaborator',
|
||||||
'Workspace.Delete': 'Owner',
|
'Workspace.Delete': 'Owner',
|
||||||
'Workspace.Organize.Read': 'External',
|
'Workspace.Organize.Read': 'External',
|
||||||
|
'Workspace.Payment.Manage': 'Owner',
|
||||||
'Workspace.Properties.Create': 'Admin',
|
'Workspace.Properties.Create': 'Admin',
|
||||||
'Workspace.Properties.Delete': 'Admin',
|
'Workspace.Properties.Delete': 'Admin',
|
||||||
'Workspace.Properties.Read': 'Collaborator',
|
'Workspace.Properties.Read': 'External',
|
||||||
'Workspace.Properties.Update': 'Admin',
|
'Workspace.Properties.Update': 'Admin',
|
||||||
|
'Workspace.Read': 'External',
|
||||||
'Workspace.Settings.Read': 'Collaborator',
|
'Workspace.Settings.Read': 'Collaborator',
|
||||||
'Workspace.Settings.Update': 'Admin',
|
'Workspace.Settings.Update': 'Admin',
|
||||||
'Workspace.Sync': 'Collaborator',
|
'Workspace.Sync': 'Collaborator',
|
||||||
|
|||||||
Binary file not shown.
@@ -37,7 +37,7 @@ test(`should be able to fixup doc role from workspace role and doc role`, t => {
|
|||||||
for (const workspaceRole of workspaceRoles) {
|
for (const workspaceRole of workspaceRoles) {
|
||||||
for (const docRole of docRoles) {
|
for (const docRole of docRoles) {
|
||||||
t.snapshot(
|
t.snapshot(
|
||||||
DocRole[fixupDocRole(workspaceRole, docRole)],
|
DocRole[fixupDocRole(workspaceRole, docRole)!],
|
||||||
`WorkspaceRole: ${WorkspaceRole[workspaceRole]}, DocRole: ${DocRole[docRole]}`
|
`WorkspaceRole: ${WorkspaceRole[workspaceRole]}, DocRole: ${DocRole[docRole]}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import test from 'ava';
|
||||||
|
|
||||||
|
import { DocID } from '../../utils/doc';
|
||||||
|
import { AccessControllerBuilder } from '../builder';
|
||||||
|
|
||||||
|
let builder: AccessControllerBuilder;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
builder = new AccessControllerBuilder();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should build correct workspace resource', t => {
|
||||||
|
t.deepEqual(builder.user('u1').workspace('ws1').data, {
|
||||||
|
userId: 'u1',
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.deepEqual(builder.user('u1').workspace('ws1').allowLocal().data, {
|
||||||
|
allowLocal: true,
|
||||||
|
userId: 'u1',
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should build correct doc resource', t => {
|
||||||
|
const resources = [
|
||||||
|
builder.user('u1').workspace('ws1').doc('doc1').data,
|
||||||
|
builder.user('u1').doc('ws1', 'doc1').data,
|
||||||
|
builder.user('u1').doc({ workspaceId: 'ws1', docId: 'doc1' }).data,
|
||||||
|
builder.user('u1').doc(new DocID('ws1:space:doc1', 'ws1')).data,
|
||||||
|
];
|
||||||
|
|
||||||
|
t.deepEqual(
|
||||||
|
resources,
|
||||||
|
Array.from({ length: 4 }, () => ({
|
||||||
|
userId: 'u1',
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
docId: 'doc1',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
t.deepEqual(builder.user('u1').doc('ws1', 'doc1').allowLocal().data, {
|
||||||
|
allowLocal: true,
|
||||||
|
docId: 'doc1',
|
||||||
|
userId: 'u1',
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import test from 'ava';
|
||||||
|
|
||||||
|
import { createTestingModule, TestingModule } from '../../../__tests__/utils';
|
||||||
|
import {
|
||||||
|
Models,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMemberStatus,
|
||||||
|
WorkspaceRole,
|
||||||
|
} from '../../../models';
|
||||||
|
import { PermissionModule } from '..';
|
||||||
|
import { DocAccessController } from '../doc';
|
||||||
|
import { DocRole, mapDocRoleToPermissions } from '../types';
|
||||||
|
|
||||||
|
let module: TestingModule;
|
||||||
|
let models: Models;
|
||||||
|
let ac: DocAccessController;
|
||||||
|
let user: User;
|
||||||
|
let ws: Workspace;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
module = await createTestingModule({ imports: [PermissionModule] });
|
||||||
|
models = module.get<Models>(Models);
|
||||||
|
ac = new DocAccessController(models);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await module.initTestingDB();
|
||||||
|
user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
ws = await models.workspace.create(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after.always(async () => {
|
||||||
|
await module.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get null role', async t => {
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
docId: 'doc1',
|
||||||
|
userId: 'u1',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return null if workspace role is not accepted', async t => {
|
||||||
|
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||||
|
await models.workspaceUser.set(
|
||||||
|
ws.id,
|
||||||
|
u2.id,
|
||||||
|
WorkspaceRole.Collaborator,
|
||||||
|
WorkspaceMemberStatus.UnderReview
|
||||||
|
);
|
||||||
|
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
docId: 'doc1',
|
||||||
|
userId: u2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return [Owner] role if workspace is not found but local is allowed', async t => {
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
docId: 'doc1',
|
||||||
|
userId: 'u1',
|
||||||
|
allowLocal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, DocRole.Owner);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fallback to [External] if workspace is public', async t => {
|
||||||
|
await models.workspace.update(ws.id, {
|
||||||
|
public: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
docId: 'doc1',
|
||||||
|
userId: 'random-user-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, DocRole.External);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return null even if workspace has other public doc', async t => {
|
||||||
|
await models.workspace.publishDoc(ws.id, 'doc1');
|
||||||
|
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
docId: 'doc2',
|
||||||
|
userId: 'random-user-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return [External] if doc is public', async t => {
|
||||||
|
await models.workspace.publishDoc(ws.id, 'doc1');
|
||||||
|
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
docId: 'doc1',
|
||||||
|
userId: 'random-user-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, DocRole.External);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return mapped permissions', async t => {
|
||||||
|
const { permissions } = await ac.role({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
docId: 'doc1',
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should assert action', async t => {
|
||||||
|
await t.notThrowsAsync(
|
||||||
|
ac.assert(
|
||||||
|
{
|
||||||
|
workspaceId: ws.id,
|
||||||
|
docId: 'doc1',
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
'Doc.Update'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
ac.assert(
|
||||||
|
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
|
||||||
|
'Doc.Update'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await models.workspaceUser.set(
|
||||||
|
ws.id,
|
||||||
|
u2.id,
|
||||||
|
WorkspaceRole.Collaborator,
|
||||||
|
WorkspaceMemberStatus.Accepted
|
||||||
|
);
|
||||||
|
|
||||||
|
await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager);
|
||||||
|
|
||||||
|
await t.notThrowsAsync(
|
||||||
|
ac.assert(
|
||||||
|
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
|
||||||
|
'Doc.Delete'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import test from 'ava';
|
||||||
|
|
||||||
|
import { createTestingModule, TestingModule } from '../../../__tests__/utils';
|
||||||
|
import {
|
||||||
|
Models,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMemberStatus,
|
||||||
|
WorkspaceRole,
|
||||||
|
} from '../../../models';
|
||||||
|
import { PermissionModule } from '..';
|
||||||
|
import { mapWorkspaceRoleToPermissions } from '../types';
|
||||||
|
import { WorkspaceAccessController } from '../workspace';
|
||||||
|
|
||||||
|
let module: TestingModule;
|
||||||
|
let models: Models;
|
||||||
|
let ac: WorkspaceAccessController;
|
||||||
|
let user: User;
|
||||||
|
let ws: Workspace;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
module = await createTestingModule({ imports: [PermissionModule] });
|
||||||
|
models = module.get<Models>(Models);
|
||||||
|
ac = new WorkspaceAccessController(models);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await module.initTestingDB();
|
||||||
|
user = await models.user.create({ email: 'u1@affine.pro' });
|
||||||
|
ws = await models.workspace.create(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after.always(async () => {
|
||||||
|
await module.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get null role', async t => {
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
userId: 'u1',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return null if role is not accepted', async t => {
|
||||||
|
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||||
|
await models.workspaceUser.set(
|
||||||
|
ws.id,
|
||||||
|
u2.id,
|
||||||
|
WorkspaceRole.Collaborator,
|
||||||
|
WorkspaceMemberStatus.UnderReview
|
||||||
|
);
|
||||||
|
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
userId: u2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return [Owner] role if workspace is not found but local is allowed', async t => {
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
userId: 'u1',
|
||||||
|
allowLocal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, WorkspaceRole.Owner);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fallback to [External] if workspace is public', async t => {
|
||||||
|
await models.workspace.update(ws.id, {
|
||||||
|
public: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
userId: 'random-user-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, WorkspaceRole.External);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return null even workspace has public doc', async t => {
|
||||||
|
await models.workspace.publishDoc(ws.id, 'doc1');
|
||||||
|
|
||||||
|
const role = await ac.getRole({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
userId: 'random-user-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(role, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return mapped external permission for workspace has public docs', async t => {
|
||||||
|
await models.workspace.publishDoc(ws.id, 'doc1');
|
||||||
|
|
||||||
|
const { permissions } = await ac.role({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
userId: 'random-user-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.deepEqual(
|
||||||
|
permissions,
|
||||||
|
mapWorkspaceRoleToPermissions(WorkspaceRole.External)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return mapped permissions', async t => {
|
||||||
|
const { permissions } = await ac.role({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(WorkspaceRole.Owner));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should assert action', async t => {
|
||||||
|
await t.notThrowsAsync(
|
||||||
|
ac.assert(
|
||||||
|
{ workspaceId: ws.id, userId: user.id },
|
||||||
|
'Workspace.TransferOwner'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync')
|
||||||
|
);
|
||||||
|
|
||||||
|
await models.workspaceUser.set(
|
||||||
|
ws.id,
|
||||||
|
u2.id,
|
||||||
|
WorkspaceRole.Admin,
|
||||||
|
WorkspaceMemberStatus.Accepted
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.notThrowsAsync(
|
||||||
|
ac.assert(
|
||||||
|
{ workspaceId: ws.id, userId: u2.id },
|
||||||
|
'Workspace.Settings.Update'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
108
packages/backend/server/src/core/permission/builder.ts
Normal file
108
packages/backend/server/src/core/permission/builder.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DocID } from '../utils/doc';
|
||||||
|
import { getAccessController } from './controller';
|
||||||
|
import { Resource } from './resource';
|
||||||
|
import { DocAction, WorkspaceAction } from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccessControllerBuilder {
|
||||||
|
user(userId: string) {
|
||||||
|
return new UserAccessControllerBuilder(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserAccessControllerBuilder {
|
||||||
|
constructor(private readonly userId: string) {}
|
||||||
|
|
||||||
|
workspace(workspaceId: string) {
|
||||||
|
return new WorkspaceAccessControllerBuilder({
|
||||||
|
userId: this.userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doc(
|
||||||
|
docId: DocID | { workspaceId: string; docId: string }
|
||||||
|
): DocAccessControllerBuilder;
|
||||||
|
doc(workspaceId: string, docId: string): DocAccessControllerBuilder;
|
||||||
|
doc(
|
||||||
|
docIdOrWorkspaceId: string | DocID | { workspaceId: string; docId: string },
|
||||||
|
doc?: string
|
||||||
|
) {
|
||||||
|
let workspaceId: string;
|
||||||
|
let docId: string;
|
||||||
|
|
||||||
|
if (docIdOrWorkspaceId instanceof DocID) {
|
||||||
|
workspaceId = docIdOrWorkspaceId.workspace;
|
||||||
|
docId = docIdOrWorkspaceId.guid;
|
||||||
|
} else if (typeof docIdOrWorkspaceId === 'string') {
|
||||||
|
workspaceId = docIdOrWorkspaceId;
|
||||||
|
docId = doc as string;
|
||||||
|
} else {
|
||||||
|
workspaceId = docIdOrWorkspaceId.workspaceId;
|
||||||
|
docId = docIdOrWorkspaceId.docId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DocAccessControllerBuilder({
|
||||||
|
userId: this.userId,
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkspaceAccessControllerBuilder {
|
||||||
|
constructor(public readonly data: Resource<'ws'>) {}
|
||||||
|
|
||||||
|
allowLocal() {
|
||||||
|
this.data.allowLocal = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc(docId: string) {
|
||||||
|
return new DocAccessControllerBuilder({
|
||||||
|
...this.data,
|
||||||
|
docId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async assert(action: WorkspaceAction) {
|
||||||
|
const checker = getAccessController('ws');
|
||||||
|
await checker.assert(this.data, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
async can(action: WorkspaceAction) {
|
||||||
|
const checker = getAccessController('ws');
|
||||||
|
return await checker.can(this.data, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
async permissions() {
|
||||||
|
const checker = getAccessController('ws');
|
||||||
|
return await checker.role(this.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DocAccessControllerBuilder {
|
||||||
|
constructor(public readonly data: Resource<'doc'>) {}
|
||||||
|
|
||||||
|
allowLocal() {
|
||||||
|
this.data.allowLocal = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assert(action: DocAction) {
|
||||||
|
const checker = getAccessController('doc');
|
||||||
|
await checker.assert(this.data, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
async can(action: DocAction) {
|
||||||
|
const checker = getAccessController('doc');
|
||||||
|
return await checker.can(this.data, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
async permissions() {
|
||||||
|
const checker = getAccessController('doc');
|
||||||
|
return await checker.role(this.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
packages/backend/server/src/core/permission/controller.ts
Normal file
53
packages/backend/server/src/core/permission/controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Resource,
|
||||||
|
ResourceAction,
|
||||||
|
ResourceRole,
|
||||||
|
ResourceType,
|
||||||
|
} from './resource';
|
||||||
|
|
||||||
|
const ACTION_CHECKER_PROVIDERS = new Map<ResourceType, AccessController<any>>();
|
||||||
|
|
||||||
|
function registerAccessController<Type extends ResourceType>(
|
||||||
|
type: Type,
|
||||||
|
provider: AccessController<Type>
|
||||||
|
) {
|
||||||
|
ACTION_CHECKER_PROVIDERS.set(type, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessController<Type extends ResourceType>(
|
||||||
|
type: Type
|
||||||
|
): AccessController<Type> {
|
||||||
|
const provider = ACTION_CHECKER_PROVIDERS.get(type);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`No action checker provider for type ${type}`);
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class AccessController<Type extends ResourceType>
|
||||||
|
implements OnModuleInit
|
||||||
|
{
|
||||||
|
protected abstract readonly type: Type;
|
||||||
|
protected logger = new Logger(AccessController.name);
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
registerAccessController(this.type, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract assert(
|
||||||
|
resource: Resource<Type>,
|
||||||
|
action: ResourceAction<Type>
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
abstract can(
|
||||||
|
resource: Resource<Type>,
|
||||||
|
action: ResourceAction<Type>
|
||||||
|
): Promise<boolean>;
|
||||||
|
|
||||||
|
abstract role(resource: Resource<Type>): Promise<{
|
||||||
|
role: ResourceRole<Type> | null;
|
||||||
|
permissions: Record<ResourceAction<Type>, boolean>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
105
packages/backend/server/src/core/permission/doc.ts
Normal file
105
packages/backend/server/src/core/permission/doc.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DocActionDenied } from '../../base';
|
||||||
|
import { Models } from '../../models';
|
||||||
|
import { AccessController, getAccessController } from './controller';
|
||||||
|
import type { Resource } from './resource';
|
||||||
|
import {
|
||||||
|
DocAction,
|
||||||
|
docActionRequiredRole,
|
||||||
|
DocRole,
|
||||||
|
fixupDocRole,
|
||||||
|
mapDocRoleToPermissions,
|
||||||
|
WorkspaceRole,
|
||||||
|
} from './types';
|
||||||
|
import { WorkspaceAccessController } from './workspace';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocAccessController extends AccessController<'doc'> {
|
||||||
|
protected readonly type = 'doc';
|
||||||
|
|
||||||
|
constructor(private readonly models: Models) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async role(resource: Resource<'doc'>) {
|
||||||
|
const role = await this.getRole(resource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
permissions: mapDocRoleToPermissions(role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async can(resource: Resource<'doc'>, action: DocAction) {
|
||||||
|
const { permissions, role } = await this.role(resource);
|
||||||
|
const allow = permissions[action] || false;
|
||||||
|
|
||||||
|
if (!allow) {
|
||||||
|
this.logger.log('Doc access check failed', {
|
||||||
|
action,
|
||||||
|
resource,
|
||||||
|
role,
|
||||||
|
requiredRole: docActionRequiredRole(action),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return allow;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assert(resource: Resource<'doc'>, action: DocAction) {
|
||||||
|
const allow = await this.can(resource, action);
|
||||||
|
|
||||||
|
if (!allow) {
|
||||||
|
throw new DocActionDenied({
|
||||||
|
docId: resource.docId,
|
||||||
|
spaceId: resource.workspaceId,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRole(payload: Resource<'doc'>): Promise<DocRole | null> {
|
||||||
|
const workspaceController = getAccessController(
|
||||||
|
'ws'
|
||||||
|
) as WorkspaceAccessController;
|
||||||
|
const workspaceRole = await workspaceController.getRole(payload);
|
||||||
|
|
||||||
|
const userRole = await this.models.docUser.get(
|
||||||
|
payload.workspaceId,
|
||||||
|
payload.docId,
|
||||||
|
payload.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
let docRole = userRole?.type ?? (null as DocRole | null);
|
||||||
|
|
||||||
|
// fallback logic
|
||||||
|
if (docRole === null) {
|
||||||
|
const defaultDocRole = await this.defaultDocRole(
|
||||||
|
payload.workspaceId,
|
||||||
|
payload.docId
|
||||||
|
);
|
||||||
|
|
||||||
|
// if user is in workspace but doc role is not set, fallback to default doc role
|
||||||
|
if (workspaceRole && workspaceRole !== WorkspaceRole.External) {
|
||||||
|
docRole = defaultDocRole.workspace;
|
||||||
|
} else {
|
||||||
|
// else fallback to external doc role
|
||||||
|
docRole = defaultDocRole.external;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we need to fixup doc role to make sure it's not miss set
|
||||||
|
// for example: workspace owner will have doc owner role
|
||||||
|
// workspace external will not have role higher than editor
|
||||||
|
return fixupDocRole(workspaceRole, docRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async defaultDocRole(workspaceId: string, docId: string) {
|
||||||
|
const doc = await this.models.workspace.getDoc(workspaceId, docId);
|
||||||
|
return {
|
||||||
|
external: doc?.public ? DocRole.External : null,
|
||||||
|
workspace: doc?.defaultRole ?? DocRole.Manager,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/backend/server/src/core/permission/event.ts
Normal file
20
packages/backend/server/src/core/permission/event.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { OnEvent } from '../../base';
|
||||||
|
import { Models } from '../../models';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventsListener {
|
||||||
|
constructor(private readonly models: Models) {}
|
||||||
|
|
||||||
|
@OnEvent('doc.created')
|
||||||
|
async setDefaultPageOwner(payload: Events['doc.created']) {
|
||||||
|
const { workspaceId, docId, editor } = payload;
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.models.docUser.setOwner(workspaceId, docId, editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PermissionService } from './service';
|
import { AccessControllerBuilder } from './builder';
|
||||||
|
import { DocAccessController } from './doc';
|
||||||
|
import { EventsListener } from './event';
|
||||||
|
import { WorkspaceAccessController } from './workspace';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [PermissionService],
|
providers: [
|
||||||
exports: [PermissionService],
|
WorkspaceAccessController,
|
||||||
|
DocAccessController,
|
||||||
|
AccessControllerBuilder,
|
||||||
|
EventsListener,
|
||||||
|
],
|
||||||
|
exports: [AccessControllerBuilder],
|
||||||
})
|
})
|
||||||
export class PermissionModule {}
|
export class PermissionModule {}
|
||||||
|
|
||||||
export { PermissionService } from './service';
|
export { AccessControllerBuilder as AccessController } from './builder';
|
||||||
export {
|
export {
|
||||||
DOC_ACTIONS,
|
DOC_ACTIONS,
|
||||||
type DocAction,
|
type DocAction,
|
||||||
DocRole,
|
DocRole,
|
||||||
fixupDocRole,
|
|
||||||
mapDocRoleToPermissions,
|
|
||||||
mapWorkspaceRoleToPermissions,
|
|
||||||
PublicDocMode,
|
|
||||||
WORKSPACE_ACTIONS,
|
WORKSPACE_ACTIONS,
|
||||||
type WorkspaceAction,
|
type WorkspaceAction,
|
||||||
WorkspaceRole,
|
WorkspaceRole,
|
||||||
|
|||||||
42
packages/backend/server/src/core/permission/resource.ts
Normal file
42
packages/backend/server/src/core/permission/resource.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { DocAction, DocRole, WorkspaceAction, WorkspaceRole } from './types';
|
||||||
|
|
||||||
|
export type ResourceType = 'ws' | 'doc';
|
||||||
|
|
||||||
|
interface WorkspaceResource {
|
||||||
|
type: 'ws';
|
||||||
|
payload: {
|
||||||
|
allowLocal?: boolean;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
action: WorkspaceAction;
|
||||||
|
role: WorkspaceRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocResource {
|
||||||
|
type: 'doc';
|
||||||
|
payload: {
|
||||||
|
allowLocal?: boolean;
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
action: DocAction;
|
||||||
|
role: DocRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KnownResource = WorkspaceResource | DocResource;
|
||||||
|
export type Resource<Type extends ResourceType = 'ws'> = Extract<
|
||||||
|
KnownResource,
|
||||||
|
{ type: Type }
|
||||||
|
>['payload'];
|
||||||
|
|
||||||
|
export type ResourceRole<Type extends ResourceType> = Extract<
|
||||||
|
KnownResource,
|
||||||
|
{ type: Type }
|
||||||
|
>['role'];
|
||||||
|
|
||||||
|
export type ResourceAction<Type extends ResourceType> = Extract<
|
||||||
|
KnownResource,
|
||||||
|
{ type: Type }
|
||||||
|
>['action'];
|
||||||
@@ -1,798 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import type { Prisma, WorkspaceDocUserPermission } from '@prisma/client';
|
|
||||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
|
||||||
import { groupBy } from 'lodash-es';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CanNotBatchGrantDocOwnerPermissions,
|
|
||||||
DocActionDenied,
|
|
||||||
EventBus,
|
|
||||||
OnEvent,
|
|
||||||
SpaceAccessDenied,
|
|
||||||
SpaceOwnerNotFound,
|
|
||||||
WorkspacePermissionNotFound,
|
|
||||||
} from '../../base';
|
|
||||||
import {
|
|
||||||
DocAction,
|
|
||||||
docActionRequiredRole,
|
|
||||||
docActionRequiredWorkspaceRole,
|
|
||||||
DocRole,
|
|
||||||
PublicDocMode,
|
|
||||||
WorkspaceRole,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PermissionService {
|
|
||||||
private readonly logger = new Logger(PermissionService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaClient,
|
|
||||||
private readonly event: EventBus
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@OnEvent('doc.created')
|
|
||||||
async setDefaultPageOwner(payload: Events['doc.created']) {
|
|
||||||
const { workspaceId, docId, editor } = payload;
|
|
||||||
|
|
||||||
if (!editor) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.workspaceDocUserPermission.createMany({
|
|
||||||
data: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
userId: editor,
|
|
||||||
type: DocRole.Owner,
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private get acceptedCondition() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accepted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start regin: workspace permission
|
|
||||||
async get(ws: string, user: string): Promise<WorkspaceRole> {
|
|
||||||
const data = await this.prisma.workspaceUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
userId: user,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
throw new WorkspacePermissionNotFound({ spaceId: ws });
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* check whether a workspace exists and has any one can access it
|
|
||||||
* @param workspaceId workspace id
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
async hasWorkspace(workspaceId: string) {
|
|
||||||
return await this.prisma.workspaceUserPermission
|
|
||||||
.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(count => count > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOwnedWorkspaces(userId: string) {
|
|
||||||
return this.prisma.workspaceUserPermission
|
|
||||||
.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
workspaceId: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(data => data.map(({ workspaceId }) => workspaceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getWorkspaceOwner(workspaceId: string) {
|
|
||||||
const owner = await this.prisma.workspaceUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!owner) {
|
|
||||||
throw new SpaceOwnerNotFound({ spaceId: workspaceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
return owner.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getWorkspaceAdmin(workspaceId: string) {
|
|
||||||
const admin = await this.prisma.workspaceUserPermission.findMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
type: WorkspaceRole.Admin,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return admin.map(({ user }) => user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getWorkspaceMemberCount(workspaceId: string) {
|
|
||||||
return this.prisma.workspaceUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async tryGetWorkspaceOwner(workspaceId: string) {
|
|
||||||
return this.prisma.workspaceUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* check if a doc binary is accessible by a user
|
|
||||||
*/
|
|
||||||
async isPublicAccessible(
|
|
||||||
ws: string,
|
|
||||||
id: string,
|
|
||||||
user?: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (ws === id) {
|
|
||||||
// if workspace is public or have any public page, then allow to access
|
|
||||||
const [isPublicWorkspace, publicPages] = await Promise.all([
|
|
||||||
this.tryCheckWorkspace(ws, user, WorkspaceRole.Collaborator),
|
|
||||||
this.prisma.workspaceDoc.count({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
public: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
return isPublicWorkspace || publicPages > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.tryCheckPage(ws, id, 'Doc.Read', user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getWorkspaceMemberStatus(ws: string, user: string) {
|
|
||||||
return this.prisma.workspaceUserPermission
|
|
||||||
.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
userId: user,
|
|
||||||
},
|
|
||||||
select: { status: true },
|
|
||||||
})
|
|
||||||
.then(r => r?.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether a given user is a member of a workspace and has the given or higher permission.
|
|
||||||
*/
|
|
||||||
async isWorkspaceMember(
|
|
||||||
ws: string,
|
|
||||||
user: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
): Promise<boolean> {
|
|
||||||
const count = await this.prisma.workspaceUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
userId: user,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
type: {
|
|
||||||
gte: permission,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return count !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* only check permission if the workspace is a cloud workspace
|
|
||||||
* @param workspaceId workspace id
|
|
||||||
* @param userId user id, check if is a public workspace if not provided
|
|
||||||
* @param permission default is read
|
|
||||||
*/
|
|
||||||
async checkCloudWorkspace(
|
|
||||||
workspaceId: string,
|
|
||||||
userId?: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
) {
|
|
||||||
const hasWorkspace = await this.hasWorkspace(workspaceId);
|
|
||||||
if (hasWorkspace) {
|
|
||||||
await this.checkWorkspace(workspaceId, userId, permission);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkWorkspace(
|
|
||||||
ws: string,
|
|
||||||
user?: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
) {
|
|
||||||
if (!(await this.tryCheckWorkspace(ws, user, permission))) {
|
|
||||||
throw new SpaceAccessDenied({ spaceId: ws });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async tryCheckWorkspace(
|
|
||||||
ws: string,
|
|
||||||
user?: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
) {
|
|
||||||
// If the permission is read, we should check if the workspace is public
|
|
||||||
if (permission === WorkspaceRole.Collaborator) {
|
|
||||||
const count = await this.prisma.workspace.count({
|
|
||||||
where: { id: ws, public: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// workspace is public
|
|
||||||
// accessible
|
|
||||||
if (count > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
// normally check if the user has the permission
|
|
||||||
const count = await this.prisma.workspaceUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
userId: user,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
type: {
|
|
||||||
gte: permission,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
const info = {
|
|
||||||
workspaceId: ws,
|
|
||||||
userId: user,
|
|
||||||
requiredRole: WorkspaceRole[permission],
|
|
||||||
};
|
|
||||||
this.logger.log(
|
|
||||||
`User's WorkspaceRole is lower than required (${JSON.stringify(info)})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// unsigned in, workspace is not public
|
|
||||||
// unaccessible
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkWorkspaceIs(
|
|
||||||
ws: string,
|
|
||||||
user: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
) {
|
|
||||||
if (!(await this.tryCheckWorkspaceIs(ws, user, permission))) {
|
|
||||||
throw new SpaceAccessDenied({ spaceId: ws });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async tryCheckWorkspaceIs(
|
|
||||||
ws: string,
|
|
||||||
user: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
) {
|
|
||||||
const count = await this.prisma.workspaceUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
userId: user,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
type: permission,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async allowUrlPreview(ws: string) {
|
|
||||||
const count = await this.prisma.workspace.count({
|
|
||||||
where: {
|
|
||||||
id: ws,
|
|
||||||
enableUrlPreview: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAllowedStatusSource(
|
|
||||||
to: WorkspaceMemberStatus
|
|
||||||
): WorkspaceMemberStatus[] {
|
|
||||||
switch (to) {
|
|
||||||
case WorkspaceMemberStatus.NeedMoreSeat:
|
|
||||||
return [WorkspaceMemberStatus.Pending];
|
|
||||||
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
|
|
||||||
return [WorkspaceMemberStatus.UnderReview];
|
|
||||||
case WorkspaceMemberStatus.Pending:
|
|
||||||
case WorkspaceMemberStatus.UnderReview:
|
|
||||||
return [WorkspaceMemberStatus.Accepted];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async grant(
|
|
||||||
ws: string,
|
|
||||||
user: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator,
|
|
||||||
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
|
|
||||||
): Promise<string> {
|
|
||||||
const data = await this.prisma.workspaceUserPermission.findFirst({
|
|
||||||
where: { workspaceId: ws, userId: user },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
const toBeOwner = permission === WorkspaceRole.Owner;
|
|
||||||
if (data.accepted && data.status === WorkspaceMemberStatus.Accepted) {
|
|
||||||
const [p] = await this.prisma.$transaction(
|
|
||||||
[
|
|
||||||
this.prisma.workspaceUserPermission.update({
|
|
||||||
where: {
|
|
||||||
workspaceId_userId: { workspaceId: ws, userId: user },
|
|
||||||
},
|
|
||||||
data: { type: permission },
|
|
||||||
}),
|
|
||||||
|
|
||||||
// If the new permission is owner, we need to revoke old owner
|
|
||||||
toBeOwner
|
|
||||||
? this.prisma.workspaceUserPermission.updateMany({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
userId: { not: user },
|
|
||||||
},
|
|
||||||
data: { type: WorkspaceRole.Admin },
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
].filter(Boolean) as Prisma.PrismaPromise<any>[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return p.id;
|
|
||||||
}
|
|
||||||
const allowedStatus = this.getAllowedStatusSource(data.status);
|
|
||||||
if (allowedStatus.includes(status)) {
|
|
||||||
const ret = await this.prisma.workspaceUserPermission.update({
|
|
||||||
where: { workspaceId_userId: { workspaceId: ws, userId: user } },
|
|
||||||
data: { status },
|
|
||||||
});
|
|
||||||
return ret.id;
|
|
||||||
}
|
|
||||||
return data.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.workspaceUserPermission
|
|
||||||
.create({
|
|
||||||
data: {
|
|
||||||
workspaceId: ws,
|
|
||||||
userId: user,
|
|
||||||
type: permission,
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(p => p.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async acceptWorkspaceInvitation(
|
|
||||||
invitationId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted
|
|
||||||
) {
|
|
||||||
const result = await this.prisma.workspaceUserPermission.updateMany({
|
|
||||||
where: {
|
|
||||||
id: invitationId,
|
|
||||||
workspaceId: workspaceId,
|
|
||||||
AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }],
|
|
||||||
},
|
|
||||||
data: { accepted: true, status },
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshSeatStatus(workspaceId: string, memberLimit: number) {
|
|
||||||
const usedCount = await this.prisma.workspaceUserPermission.count({
|
|
||||||
where: { workspaceId, status: WorkspaceMemberStatus.Accepted },
|
|
||||||
});
|
|
||||||
|
|
||||||
const availableCount = memberLimit - usedCount;
|
|
||||||
|
|
||||||
if (availableCount <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.$transaction(async tx => {
|
|
||||||
const members = await tx.workspaceUserPermission.findMany({
|
|
||||||
select: { id: true, status: true },
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
status: {
|
|
||||||
in: [
|
|
||||||
WorkspaceMemberStatus.NeedMoreSeat,
|
|
||||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const needChange = members.slice(0, availableCount);
|
|
||||||
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
|
|
||||||
needChange,
|
|
||||||
m => m.status
|
|
||||||
);
|
|
||||||
|
|
||||||
const toPendings = NeedMoreSeat ?? [];
|
|
||||||
if (toPendings.length > 0) {
|
|
||||||
await tx.workspaceUserPermission.updateMany({
|
|
||||||
where: { id: { in: toPendings.map(m => m.id) } },
|
|
||||||
data: { status: WorkspaceMemberStatus.Pending },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const toUnderReviewUserIds = NeedMoreSeatAndReview ?? [];
|
|
||||||
if (toUnderReviewUserIds.length > 0) {
|
|
||||||
await tx.workspaceUserPermission.updateMany({
|
|
||||||
where: { id: { in: toUnderReviewUserIds.map(m => m.id) } },
|
|
||||||
data: { status: WorkspaceMemberStatus.UnderReview },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [toPendings, toUnderReviewUserIds] as const;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokeWorkspace(workspaceId: string, user: string) {
|
|
||||||
const permission = await this.prisma.workspaceUserPermission.findUnique({
|
|
||||||
where: { workspaceId_userId: { workspaceId, userId: user } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// We shouldn't revoke owner permission
|
|
||||||
// should auto deleted by workspace/user delete cascading
|
|
||||||
if (!permission || permission.type === WorkspaceRole.Owner) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.workspaceUserPermission.deleteMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
userId: user,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const count = await this.prisma.workspaceUserPermission.count({
|
|
||||||
where: { workspaceId },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.event.emit('workspace.members.updated', {
|
|
||||||
workspaceId,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
this.event.emit('workspace.members.removed', {
|
|
||||||
workspaceId,
|
|
||||||
userId: user,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
permission.status === 'UnderReview' ||
|
|
||||||
permission.status === 'NeedMoreSeatAndReview'
|
|
||||||
) {
|
|
||||||
this.event.emit('workspace.members.requestDeclined', {
|
|
||||||
userId: user,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
/// End regin: workspace permission
|
|
||||||
|
|
||||||
/// Start regin: page permission
|
|
||||||
/**
|
|
||||||
* only check permission if the workspace is a cloud workspace
|
|
||||||
* @param workspaceId workspace id
|
|
||||||
* @param pageId page id aka doc id
|
|
||||||
* @param userId user id, check if is a public page if not provided
|
|
||||||
* @param permission default is read
|
|
||||||
*/
|
|
||||||
async checkCloudPagePermission(
|
|
||||||
workspaceId: string,
|
|
||||||
pageId: string,
|
|
||||||
action: DocAction,
|
|
||||||
userId?: string
|
|
||||||
) {
|
|
||||||
const hasWorkspace = await this.hasWorkspace(workspaceId);
|
|
||||||
if (hasWorkspace) {
|
|
||||||
await this.checkPagePermission(workspaceId, pageId, action, userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkPagePermission(
|
|
||||||
ws: string,
|
|
||||||
page: string,
|
|
||||||
action: DocAction,
|
|
||||||
user?: string
|
|
||||||
) {
|
|
||||||
if (!(await this.tryCheckPage(ws, page, action, user))) {
|
|
||||||
throw new DocActionDenied({ spaceId: ws, docId: page, action });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async tryCheckPage(
|
|
||||||
ws: string,
|
|
||||||
doc: string,
|
|
||||||
action: DocAction,
|
|
||||||
user?: string
|
|
||||||
) {
|
|
||||||
const role = docActionRequiredRole(action);
|
|
||||||
// check whether page is public
|
|
||||||
if (action === 'Doc.Read') {
|
|
||||||
const count = await this.prisma.workspaceDoc.count({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
public: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// page is public
|
|
||||||
// accessible
|
|
||||||
if (count > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
const [roleEntity, pageEntity, workspaceRoleEntity] = await Promise.all([
|
|
||||||
this.prisma.workspaceDocUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
userId: user,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
type: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.workspaceDoc.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
defaultRole: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.workspaceUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
userId: user,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
type: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const defaultPageRole = pageEntity?.defaultRole ?? DocRole.Manager;
|
|
||||||
|
|
||||||
if (
|
|
||||||
// Page role exists, check it first
|
|
||||||
(roleEntity && roleEntity.type >= role) ||
|
|
||||||
// if
|
|
||||||
// - page has a default role
|
|
||||||
// - the user is in this workspace
|
|
||||||
// - the user is not an external user in this workspace
|
|
||||||
// then use the max of the two
|
|
||||||
(workspaceRoleEntity &&
|
|
||||||
workspaceRoleEntity.type !== WorkspaceRole.External &&
|
|
||||||
Math.max(
|
|
||||||
roleEntity?.type ?? Number.MIN_SAFE_INTEGER,
|
|
||||||
defaultPageRole
|
|
||||||
) >= role)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const info = {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
userId: user,
|
|
||||||
workspaceRole: workspaceRoleEntity
|
|
||||||
? WorkspaceRole[workspaceRoleEntity.type]
|
|
||||||
: undefined,
|
|
||||||
pageRole: roleEntity ? DocRole[roleEntity.type] : undefined,
|
|
||||||
pageDefaultRole: DocRole[defaultPageRole],
|
|
||||||
requiredRole: DocRole[role],
|
|
||||||
action,
|
|
||||||
};
|
|
||||||
this.logger.log(
|
|
||||||
`Page role is lower than required, continue to check workspace permission (${JSON.stringify(info)})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// check whether user has workspace related permission
|
|
||||||
return this.tryCheckWorkspace(
|
|
||||||
ws,
|
|
||||||
user,
|
|
||||||
docActionRequiredWorkspaceRole(action)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async isPublicPage(ws: string, doc: string) {
|
|
||||||
return this.prisma.workspaceDoc
|
|
||||||
.count({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
public: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(count => count > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishPage(ws: string, doc: string, mode = PublicDocMode.Page) {
|
|
||||||
return this.prisma.workspaceDoc.upsert({
|
|
||||||
where: {
|
|
||||||
workspaceId_docId: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
public: true,
|
|
||||||
mode,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
mode,
|
|
||||||
public: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokePublicPage(ws: string, doc: string) {
|
|
||||||
return this.prisma.workspaceDoc.upsert({
|
|
||||||
where: {
|
|
||||||
workspaceId_docId: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
public: false,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
public: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async grantPage(ws: string, doc: string, user: string, permission: DocRole) {
|
|
||||||
const [p] = await this.prisma.$transaction(
|
|
||||||
[
|
|
||||||
this.prisma.workspaceDocUserPermission.upsert({
|
|
||||||
where: {
|
|
||||||
workspaceId_docId_userId: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
userId: user,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
type: permission,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
userId: user,
|
|
||||||
type: permission,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// If the new permission is owner, we need to revoke old owner
|
|
||||||
permission === DocRole.Owner
|
|
||||||
? this.prisma.workspaceDocUserPermission.updateMany({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
type: DocRole.Owner,
|
|
||||||
userId: {
|
|
||||||
not: user,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: DocRole.Manager,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
].filter(Boolean) as Prisma.PrismaPromise<any>[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return p as WorkspaceDocUserPermission;
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokePage(ws: string, doc: string, user: string) {
|
|
||||||
const result = await this.prisma.workspaceDocUserPermission.deleteMany({
|
|
||||||
where: {
|
|
||||||
workspaceId: ws,
|
|
||||||
docId: doc,
|
|
||||||
userId: user,
|
|
||||||
type: {
|
|
||||||
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
|
|
||||||
not: DocRole.Owner,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async batchGrantPage(
|
|
||||||
workspaceId: string,
|
|
||||||
docId: string,
|
|
||||||
userIds: string[],
|
|
||||||
role: DocRole
|
|
||||||
) {
|
|
||||||
if (userIds.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === DocRole.Owner) {
|
|
||||||
throw new CanNotBatchGrantDocOwnerPermissions();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.prisma.workspaceDocUserPermission.createMany({
|
|
||||||
skipDuplicates: true,
|
|
||||||
data: userIds.map(id => ({
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
userId: id,
|
|
||||||
type: role,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,7 @@
|
|||||||
import { LeafPaths, LeafVisitor } from '../../base';
|
import { LeafPaths, LeafVisitor } from '../../base';
|
||||||
|
import { DocRole, WorkspaceRole } from '../../models';
|
||||||
|
|
||||||
export enum PublicDocMode {
|
export { DocRole, WorkspaceRole };
|
||||||
Page,
|
|
||||||
Edgeless,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum DocRole {
|
|
||||||
External = 0,
|
|
||||||
Reader = 10,
|
|
||||||
Editor = 20,
|
|
||||||
Manager = 30,
|
|
||||||
Owner = 99,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum WorkspaceRole {
|
|
||||||
External = -99,
|
|
||||||
Collaborator = 1,
|
|
||||||
Admin = 10,
|
|
||||||
Owner = 99,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Definitions of all possible actions
|
* Definitions of all possible actions
|
||||||
*
|
*
|
||||||
@@ -28,6 +10,7 @@ export enum WorkspaceRole {
|
|||||||
export const Actions = {
|
export const Actions = {
|
||||||
// Workspace Actions
|
// Workspace Actions
|
||||||
Workspace: {
|
Workspace: {
|
||||||
|
Read: '',
|
||||||
Sync: '',
|
Sync: '',
|
||||||
CreateDoc: '',
|
CreateDoc: '',
|
||||||
Delete: '',
|
Delete: '',
|
||||||
@@ -39,6 +22,9 @@ export const Actions = {
|
|||||||
Read: '',
|
Read: '',
|
||||||
Manage: '',
|
Manage: '',
|
||||||
},
|
},
|
||||||
|
Adminitrators: {
|
||||||
|
Manage: '',
|
||||||
|
},
|
||||||
Properties: {
|
Properties: {
|
||||||
Read: '',
|
Read: '',
|
||||||
Create: '',
|
Create: '',
|
||||||
@@ -49,6 +35,15 @@ export const Actions = {
|
|||||||
Read: '',
|
Read: '',
|
||||||
Update: '',
|
Update: '',
|
||||||
},
|
},
|
||||||
|
Blobs: {
|
||||||
|
Read: '',
|
||||||
|
List: '',
|
||||||
|
Write: '',
|
||||||
|
},
|
||||||
|
Copilot: '',
|
||||||
|
Payment: {
|
||||||
|
Manage: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Doc Actions
|
// Doc Actions
|
||||||
@@ -76,7 +71,12 @@ export const Actions = {
|
|||||||
export const RoleActionsMap = {
|
export const RoleActionsMap = {
|
||||||
WorkspaceRole: {
|
WorkspaceRole: {
|
||||||
get [WorkspaceRole.External]() {
|
get [WorkspaceRole.External]() {
|
||||||
return [Action.Workspace.Organize.Read];
|
return [
|
||||||
|
Action.Workspace.Read,
|
||||||
|
Action.Workspace.Organize.Read,
|
||||||
|
Action.Workspace.Properties.Read,
|
||||||
|
Action.Workspace.Blobs.Read,
|
||||||
|
];
|
||||||
},
|
},
|
||||||
get [WorkspaceRole.Collaborator]() {
|
get [WorkspaceRole.Collaborator]() {
|
||||||
return [
|
return [
|
||||||
@@ -84,8 +84,10 @@ export const RoleActionsMap = {
|
|||||||
Action.Workspace.Sync,
|
Action.Workspace.Sync,
|
||||||
Action.Workspace.CreateDoc,
|
Action.Workspace.CreateDoc,
|
||||||
Action.Workspace.Users.Read,
|
Action.Workspace.Users.Read,
|
||||||
Action.Workspace.Properties.Read,
|
|
||||||
Action.Workspace.Settings.Read,
|
Action.Workspace.Settings.Read,
|
||||||
|
Action.Workspace.Blobs.Write,
|
||||||
|
Action.Workspace.Blobs.List,
|
||||||
|
Action.Workspace.Copilot,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
get [WorkspaceRole.Admin]() {
|
get [WorkspaceRole.Admin]() {
|
||||||
@@ -102,7 +104,9 @@ export const RoleActionsMap = {
|
|||||||
return [
|
return [
|
||||||
...this[WorkspaceRole.Admin],
|
...this[WorkspaceRole.Admin],
|
||||||
Action.Workspace.Delete,
|
Action.Workspace.Delete,
|
||||||
|
Action.Workspace.Adminitrators.Manage,
|
||||||
Action.Workspace.TransferOwner,
|
Action.Workspace.TransferOwner,
|
||||||
|
Action.Workspace.Payment.Manage,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -187,7 +191,9 @@ export const WORKSPACE_ACTIONS =
|
|||||||
RoleActionsMap.WorkspaceRole[WorkspaceRole.Owner];
|
RoleActionsMap.WorkspaceRole[WorkspaceRole.Owner];
|
||||||
export const DOC_ACTIONS = RoleActionsMap.DocRole[DocRole.Owner];
|
export const DOC_ACTIONS = RoleActionsMap.DocRole[DocRole.Owner];
|
||||||
|
|
||||||
export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) {
|
export function mapWorkspaceRoleToPermissions(
|
||||||
|
workspaceRole: WorkspaceRole | null
|
||||||
|
) {
|
||||||
const permissions = WORKSPACE_ACTIONS.reduce(
|
const permissions = WORKSPACE_ACTIONS.reduce(
|
||||||
(map, action) => {
|
(map, action) => {
|
||||||
map[action] = false;
|
map[action] = false;
|
||||||
@@ -196,6 +202,10 @@ export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) {
|
|||||||
{} as Record<WorkspaceAction, boolean>
|
{} as Record<WorkspaceAction, boolean>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (workspaceRole === null) {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
RoleActionsMap.WorkspaceRole[workspaceRole].forEach(action => {
|
RoleActionsMap.WorkspaceRole[workspaceRole].forEach(action => {
|
||||||
permissions[action] = true;
|
permissions[action] = true;
|
||||||
});
|
});
|
||||||
@@ -203,7 +213,7 @@ export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) {
|
|||||||
return permissions;
|
return permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapDocRoleToPermissions(docRole: DocRole) {
|
export function mapDocRoleToPermissions(docRole: DocRole | null) {
|
||||||
const permissions = DOC_ACTIONS.reduce(
|
const permissions = DOC_ACTIONS.reduce(
|
||||||
(map, action) => {
|
(map, action) => {
|
||||||
map[action] = false;
|
map[action] = false;
|
||||||
@@ -212,6 +222,10 @@ export function mapDocRoleToPermissions(docRole: DocRole) {
|
|||||||
{} as Record<DocAction, boolean>
|
{} as Record<DocAction, boolean>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (docRole === null) {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
RoleActionsMap.DocRole[docRole].forEach(action => {
|
RoleActionsMap.DocRole[docRole].forEach(action => {
|
||||||
permissions[action] = true;
|
permissions[action] = true;
|
||||||
});
|
});
|
||||||
@@ -232,9 +246,16 @@ export function mapDocRoleToPermissions(docRole: DocRole) {
|
|||||||
* fixupDocRole(WorkspaceRole.Owner, DocRole.External) // returns DocRole.Manager
|
* fixupDocRole(WorkspaceRole.Owner, DocRole.External) // returns DocRole.Manager
|
||||||
*/
|
*/
|
||||||
export function fixupDocRole(
|
export function fixupDocRole(
|
||||||
workspaceRole: WorkspaceRole = WorkspaceRole.External,
|
workspaceRole: WorkspaceRole | null,
|
||||||
docRole: DocRole = DocRole.External
|
docRole: DocRole | null
|
||||||
): DocRole {
|
): DocRole | null {
|
||||||
|
if (workspaceRole === null && docRole === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceRole = workspaceRole ?? WorkspaceRole.External;
|
||||||
|
docRole = docRole ?? DocRole.External;
|
||||||
|
|
||||||
switch (workspaceRole) {
|
switch (workspaceRole) {
|
||||||
case WorkspaceRole.External:
|
case WorkspaceRole.External:
|
||||||
// Workspace External user won't be able to have any high permission doc role
|
// Workspace External user won't be able to have any high permission doc role
|
||||||
|
|||||||
101
packages/backend/server/src/core/permission/workspace.ts
Normal file
101
packages/backend/server/src/core/permission/workspace.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { SpaceAccessDenied } from '../../base';
|
||||||
|
import { Models } from '../../models';
|
||||||
|
import { AccessController } from './controller';
|
||||||
|
import type { Resource } from './resource';
|
||||||
|
import {
|
||||||
|
mapWorkspaceRoleToPermissions,
|
||||||
|
WorkspaceAction,
|
||||||
|
workspaceActionRequiredRole,
|
||||||
|
WorkspaceRole,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||||
|
protected readonly type = 'ws';
|
||||||
|
|
||||||
|
constructor(private readonly models: Models) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async role(resource: Resource<'ws'>) {
|
||||||
|
let role = await this.getRole(resource);
|
||||||
|
|
||||||
|
// NOTE(@forehalo): special case for public page
|
||||||
|
// Currently, we can not only load binary of a public Doc to render in a shared page,
|
||||||
|
// so we need to ensure anyone has basic 'read' permission to a workspace that has public pages.
|
||||||
|
if (
|
||||||
|
!role &&
|
||||||
|
(await this.models.workspace.hasPublicDoc(resource.workspaceId))
|
||||||
|
) {
|
||||||
|
role = WorkspaceRole.External;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
permissions: mapWorkspaceRoleToPermissions(role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async can(resource: Resource<'ws'>, action: WorkspaceAction) {
|
||||||
|
const { permissions, role } = await this.role(resource);
|
||||||
|
const allow = permissions[action] || false;
|
||||||
|
|
||||||
|
if (!allow) {
|
||||||
|
this.logger.log('Workspace access check failed', {
|
||||||
|
action,
|
||||||
|
resource,
|
||||||
|
role,
|
||||||
|
requiredRole: workspaceActionRequiredRole(action),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return allow;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assert(resource: Resource<'ws'>, action: WorkspaceAction) {
|
||||||
|
const allow = await this.can(resource, action);
|
||||||
|
|
||||||
|
if (!allow) {
|
||||||
|
throw new SpaceAccessDenied({ spaceId: resource.workspaceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRole(payload: Resource<'ws'>) {
|
||||||
|
const userRole = await this.models.workspaceUser.getActive(
|
||||||
|
payload.workspaceId,
|
||||||
|
payload.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
let role = userRole?.type as WorkspaceRole | null;
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
role = await this.defaultWorkspaceRole(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async defaultWorkspaceRole(payload: Resource<'ws'>) {
|
||||||
|
const ws = await this.models.workspace.get(payload.workspaceId);
|
||||||
|
|
||||||
|
// NOTE(@forehalo):
|
||||||
|
// we allow user to use online service with local workspace
|
||||||
|
// so we always return owner role for local workspace
|
||||||
|
// copilot session for local workspace is an example
|
||||||
|
if (!ws) {
|
||||||
|
if (payload.allowLocal) {
|
||||||
|
return WorkspaceRole.Owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws.public) {
|
||||||
|
return WorkspaceRole.External;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
Models,
|
Models,
|
||||||
type UserQuota,
|
type UserQuota,
|
||||||
WorkspaceQuota as BaseWorkspaceQuota,
|
WorkspaceQuota as BaseWorkspaceQuota,
|
||||||
|
WorkspaceRole,
|
||||||
} from '../../models';
|
} from '../../models';
|
||||||
import { PermissionService } from '../permission';
|
|
||||||
import { WorkspaceBlobStorage } from '../storage';
|
import { WorkspaceBlobStorage } from '../storage';
|
||||||
import {
|
import {
|
||||||
UserQuotaHumanReadableType,
|
UserQuotaHumanReadableType,
|
||||||
@@ -28,7 +28,6 @@ export class QuotaService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly permissions: PermissionService,
|
|
||||||
private readonly storage: WorkspaceBlobStorage
|
private readonly storage: WorkspaceBlobStorage
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -73,12 +72,20 @@ export class QuotaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUserStorageUsage(userId: string) {
|
async getUserStorageUsage(userId: string) {
|
||||||
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
|
const workspaces = await this.models.workspaceUser.getUserActiveRoles(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
role: WorkspaceRole.Owner,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const ids = workspaces.map(w => w.workspaceId);
|
||||||
|
|
||||||
const workspacesWithQuota =
|
const workspacesWithQuota =
|
||||||
await this.models.workspaceFeature.batchHasQuota(workspaces);
|
await this.models.workspaceFeature.batchHasQuota(ids);
|
||||||
|
|
||||||
const sizes = await Promise.allSettled(
|
const sizes = await Promise.allSettled(
|
||||||
workspaces
|
ids
|
||||||
.filter(w => !workspacesWithQuota.includes(w))
|
.filter(w => !workspacesWithQuota.includes(w))
|
||||||
.map(workspace => this.storage.totalSize(workspace))
|
.map(workspace => this.storage.totalSize(workspace))
|
||||||
);
|
);
|
||||||
@@ -116,8 +123,7 @@ export class QuotaService {
|
|||||||
|
|
||||||
if (!quota) {
|
if (!quota) {
|
||||||
// get and convert to workspace quota from owner's quota
|
// get and convert to workspace quota from owner's quota
|
||||||
// TODO(@forehalo): replace it with `WorkspaceRoleModel` when it's ready
|
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
|
||||||
const ownerQuota = await this.getUserQuota(owner.id);
|
const ownerQuota = await this.getUserQuota(owner.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -136,8 +142,7 @@ export class QuotaService {
|
|||||||
const usedStorageQuota = quota.ownerQuota
|
const usedStorageQuota = quota.ownerQuota
|
||||||
? await this.getUserStorageUsage(quota.ownerQuota)
|
? await this.getUserStorageUsage(quota.ownerQuota)
|
||||||
: await this.getWorkspaceStorageUsage(workspaceId);
|
: await this.getWorkspaceStorageUsage(workspaceId);
|
||||||
const memberCount =
|
const memberCount = await this.models.workspaceUser.count(workspaceId);
|
||||||
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...quota,
|
...quota,
|
||||||
@@ -165,8 +170,7 @@ export class QuotaService {
|
|||||||
|
|
||||||
async getWorkspaceSeatQuota(workspaceId: string) {
|
async getWorkspaceSeatQuota(workspaceId: string) {
|
||||||
const quota = await this.getWorkspaceQuota(workspaceId);
|
const quota = await this.getWorkspaceQuota(workspaceId);
|
||||||
const memberCount =
|
const memberCount = await this.models.workspaceUser.count(workspaceId);
|
||||||
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
memberCount,
|
memberCount,
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ import {
|
|||||||
StorageProviderFactory,
|
StorageProviderFactory,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Events {
|
||||||
|
'workspace.blob.sync': {
|
||||||
|
workspaceId: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
'workspace.blob.delete': {
|
||||||
|
workspaceId: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceBlobStorage {
|
export class WorkspaceBlobStorage {
|
||||||
private readonly logger = new Logger(WorkspaceBlobStorage.name);
|
private readonly logger = new Logger(WorkspaceBlobStorage.name);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
PgUserspaceDocStorageAdapter,
|
PgUserspaceDocStorageAdapter,
|
||||||
PgWorkspaceDocStorageAdapter,
|
PgWorkspaceDocStorageAdapter,
|
||||||
} from '../doc';
|
} from '../doc';
|
||||||
import { PermissionService, WorkspaceRole } from '../permission';
|
import { AccessController, WorkspaceAction } from '../permission';
|
||||||
import { DocID } from '../utils/doc';
|
import { DocID } from '../utils/doc';
|
||||||
|
|
||||||
const SubscribeMessage = (event: string) =>
|
const SubscribeMessage = (event: string) =>
|
||||||
@@ -144,7 +144,7 @@ export class SpaceSyncGateway
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly runtime: Runtime,
|
private readonly runtime: Runtime,
|
||||||
private readonly permissions: PermissionService,
|
private readonly ac: AccessController,
|
||||||
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
||||||
private readonly userspace: PgUserspaceDocStorageAdapter,
|
private readonly userspace: PgUserspaceDocStorageAdapter,
|
||||||
private readonly docReader: DocReader
|
private readonly docReader: DocReader
|
||||||
@@ -170,7 +170,7 @@ export class SpaceSyncGateway
|
|||||||
const workspace = new WorkspaceSyncAdapter(
|
const workspace = new WorkspaceSyncAdapter(
|
||||||
client,
|
client,
|
||||||
this.workspace,
|
this.workspace,
|
||||||
this.permissions,
|
this.ac,
|
||||||
this.docReader
|
this.docReader
|
||||||
);
|
);
|
||||||
const userspace = new UserspaceSyncAdapter(client, this.userspace);
|
const userspace = new UserspaceSyncAdapter(client, this.userspace);
|
||||||
@@ -248,12 +248,13 @@ export class SpaceSyncGateway
|
|||||||
): Promise<
|
): Promise<
|
||||||
EventResponse<{ missing: string; state: string; timestamp: number }>
|
EventResponse<{ missing: string; state: string; timestamp: number }>
|
||||||
> {
|
> {
|
||||||
|
const id = new DocID(docId, spaceId);
|
||||||
const adapter = this.selectAdapter(client, spaceType);
|
const adapter = this.selectAdapter(client, spaceType);
|
||||||
adapter.assertIn(spaceId);
|
adapter.assertIn(spaceId);
|
||||||
|
|
||||||
const doc = await adapter.diff(
|
const doc = await adapter.diff(
|
||||||
spaceId,
|
spaceId,
|
||||||
docId,
|
id.guid,
|
||||||
stateVector ? Buffer.from(stateVector, 'base64') : undefined
|
stateVector ? Buffer.from(stateVector, 'base64') : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,11 +294,12 @@ export class SpaceSyncGateway
|
|||||||
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
|
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
|
||||||
const { spaceType, spaceId, docId, updates } = message;
|
const { spaceType, spaceId, docId, updates } = message;
|
||||||
const adapter = this.selectAdapter(client, spaceType);
|
const adapter = this.selectAdapter(client, spaceType);
|
||||||
|
const id = new DocID(docId, spaceId);
|
||||||
|
|
||||||
// TODO(@forehalo): we might need to check write permission before push updates
|
await this.ac.user(user.id).doc(spaceId, id.guid).assert('Doc.Update');
|
||||||
const timestamp = await adapter.push(
|
const timestamp = await adapter.push(
|
||||||
spaceId,
|
spaceId,
|
||||||
docId,
|
id.guid,
|
||||||
updates.map(update => Buffer.from(update, 'base64')),
|
updates.map(update => Buffer.from(update, 'base64')),
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
@@ -334,7 +336,7 @@ export class SpaceSyncGateway
|
|||||||
const { spaceType, spaceId, docId, update } = message;
|
const { spaceType, spaceId, docId, update } = message;
|
||||||
const adapter = this.selectAdapter(client, spaceType);
|
const adapter = this.selectAdapter(client, spaceType);
|
||||||
|
|
||||||
// TODO(@forehalo): we might need to check write permission before push updates
|
await this.ac.user(user.id).doc(spaceId, docId).assert('Doc.Update');
|
||||||
const timestamp = await adapter.push(
|
const timestamp = await adapter.push(
|
||||||
spaceId,
|
spaceId,
|
||||||
docId,
|
docId,
|
||||||
@@ -472,7 +474,7 @@ abstract class SyncSocketAdapter {
|
|||||||
if (this.in(spaceId, roomType)) {
|
if (this.in(spaceId, roomType)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.assertAccessible(spaceId, userId, WorkspaceRole.Collaborator);
|
await this.assertAccessible(spaceId, userId, 'Workspace.Sync');
|
||||||
return this.client.join(this.room(spaceId, roomType));
|
return this.client.join(this.room(spaceId, roomType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +498,7 @@ abstract class SyncSocketAdapter {
|
|||||||
abstract assertAccessible(
|
abstract assertAccessible(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
permission?: WorkspaceRole
|
action: WorkspaceAction
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
push(spaceId: string, docId: string, updates: Buffer[], editorId: string) {
|
push(spaceId: string, docId: string, updates: Buffer[], editorId: string) {
|
||||||
@@ -525,7 +527,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
|
|||||||
constructor(
|
constructor(
|
||||||
client: Socket,
|
client: Socket,
|
||||||
storage: DocStorageAdapter,
|
storage: DocStorageAdapter,
|
||||||
private readonly permission: PermissionService,
|
private readonly ac: AccessController,
|
||||||
private readonly docReader: DocReader
|
private readonly docReader: DocReader
|
||||||
) {
|
) {
|
||||||
super(SpaceType.Workspace, client, storage);
|
super(SpaceType.Workspace, client, storage);
|
||||||
@@ -537,8 +539,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
|
|||||||
updates: Buffer[],
|
updates: Buffer[],
|
||||||
editorId: string
|
editorId: string
|
||||||
) {
|
) {
|
||||||
const id = new DocID(docId, spaceId);
|
return super.push(spaceId, docId, updates, editorId);
|
||||||
return super.push(spaceId, id.guid, updates, editorId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override async diff(
|
override async diff(
|
||||||
@@ -546,20 +547,15 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
|
|||||||
docId: string,
|
docId: string,
|
||||||
stateVector?: Uint8Array
|
stateVector?: Uint8Array
|
||||||
) {
|
) {
|
||||||
const id = new DocID(docId, spaceId);
|
return await this.docReader.getDocDiff(spaceId, docId, stateVector);
|
||||||
return await this.docReader.getDocDiff(spaceId, id.guid, stateVector);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async assertAccessible(
|
async assertAccessible(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
action: WorkspaceAction
|
||||||
) {
|
) {
|
||||||
if (
|
await this.ac.user(userId).workspace(spaceId).assert(action);
|
||||||
!(await this.permission.isWorkspaceMember(spaceId, userId, permission))
|
|
||||||
) {
|
|
||||||
throw new SpaceAccessDenied({ spaceId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,7 +567,7 @@ class UserspaceSyncAdapter extends SyncSocketAdapter {
|
|||||||
async assertAccessible(
|
async assertAccessible(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
_permission: WorkspaceRole = WorkspaceRole.Collaborator
|
_action: WorkspaceAction
|
||||||
) {
|
) {
|
||||||
if (spaceId !== userId) {
|
if (spaceId !== userId) {
|
||||||
throw new SpaceAccessDenied({ spaceId });
|
throw new SpaceAccessDenied({ spaceId });
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
|
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AccessDenied,
|
|
||||||
ActionForbidden,
|
|
||||||
BlobNotFound,
|
BlobNotFound,
|
||||||
CallMetric,
|
CallMetric,
|
||||||
DocHistoryNotFound,
|
DocHistoryNotFound,
|
||||||
DocNotFound,
|
DocNotFound,
|
||||||
InvalidHistoryTimestamp,
|
InvalidHistoryTimestamp,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
|
import { Models, PublicDocMode } from '../../models';
|
||||||
import { CurrentUser, Public } from '../auth';
|
import { CurrentUser, Public } from '../auth';
|
||||||
import { PgWorkspaceDocStorageAdapter } from '../doc';
|
import { PgWorkspaceDocStorageAdapter } from '../doc';
|
||||||
import { DocReader } from '../doc/reader';
|
import { DocReader } from '../doc/reader';
|
||||||
import { PermissionService, PublicDocMode } from '../permission';
|
import { AccessController } from '../permission';
|
||||||
import { WorkspaceBlobStorage } from '../storage';
|
import { WorkspaceBlobStorage } from '../storage';
|
||||||
import { DocID } from '../utils/doc';
|
import { DocID } from '../utils/doc';
|
||||||
|
|
||||||
@@ -23,10 +21,10 @@ export class WorkspacesController {
|
|||||||
logger = new Logger(WorkspacesController.name);
|
logger = new Logger(WorkspacesController.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly storage: WorkspaceBlobStorage,
|
private readonly storage: WorkspaceBlobStorage,
|
||||||
private readonly permission: PermissionService,
|
private readonly ac: AccessController,
|
||||||
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
||||||
private readonly docReader: DocReader,
|
private readonly docReader: DocReader,
|
||||||
private readonly prisma: PrismaClient
|
private readonly models: Models
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// get workspace blob
|
// get workspace blob
|
||||||
@@ -41,18 +39,10 @@ export class WorkspacesController {
|
|||||||
@Param('name') name: string,
|
@Param('name') name: string,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
) {
|
) {
|
||||||
// if workspace is public or have any public page, then allow to access
|
await this.ac
|
||||||
// otherwise, check permission
|
.user(user?.id ?? 'anonymous')
|
||||||
if (
|
.workspace(workspaceId)
|
||||||
!(await this.permission.isPublicAccessible(
|
.assert('Workspace.Read');
|
||||||
workspaceId,
|
|
||||||
workspaceId,
|
|
||||||
user?.id
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
throw new ActionForbidden();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { body, metadata } = await this.storage.get(workspaceId, name);
|
const { body, metadata } = await this.storage.get(workspaceId, name);
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
@@ -86,17 +76,17 @@ export class WorkspacesController {
|
|||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
) {
|
) {
|
||||||
const docId = new DocID(guid, ws);
|
const docId = new DocID(guid, ws);
|
||||||
if (
|
if (docId.isWorkspace) {
|
||||||
// if a user has the permission
|
await this.ac
|
||||||
!(await this.permission.isPublicAccessible(
|
.user(user?.id ?? 'anonymous')
|
||||||
docId.workspace,
|
.workspace(ws)
|
||||||
docId.guid,
|
.assert('Workspace.Read');
|
||||||
user?.id
|
} else {
|
||||||
))
|
await this.ac
|
||||||
) {
|
.user(user?.id ?? 'anonymous')
|
||||||
throw new AccessDenied();
|
.doc(ws, guid)
|
||||||
|
.assert('Doc.Read');
|
||||||
}
|
}
|
||||||
|
|
||||||
const binResponse = await this.docReader.getDoc(
|
const binResponse = await this.docReader.getDoc(
|
||||||
docId.workspace,
|
docId.workspace,
|
||||||
docId.guid
|
docId.guid
|
||||||
@@ -111,16 +101,12 @@ export class WorkspacesController {
|
|||||||
|
|
||||||
if (!docId.isWorkspace) {
|
if (!docId.isWorkspace) {
|
||||||
// fetch the publish page mode for publish page
|
// fetch the publish page mode for publish page
|
||||||
const publishPage = await this.prisma.workspaceDoc.findUnique({
|
const doc = await this.models.workspace.getDoc(
|
||||||
where: {
|
docId.workspace,
|
||||||
workspaceId_docId: {
|
docId.guid
|
||||||
workspaceId: docId.workspace,
|
);
|
||||||
docId: docId.guid,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const publishPageMode =
|
const publishPageMode =
|
||||||
publishPage?.mode === PublicDocMode.Edgeless ? 'edgeless' : 'page';
|
doc?.mode === PublicDocMode.Edgeless ? 'edgeless' : 'page';
|
||||||
|
|
||||||
res.setHeader('publish-mode', publishPageMode);
|
res.setHeader('publish-mode', publishPageMode);
|
||||||
}
|
}
|
||||||
@@ -146,12 +132,7 @@ export class WorkspacesController {
|
|||||||
throw new InvalidHistoryTimestamp({ timestamp });
|
throw new InvalidHistoryTimestamp({ timestamp });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkPagePermission(
|
await this.ac.user(user.id).doc(ws, guid).assert('Doc.Read');
|
||||||
docId.workspace,
|
|
||||||
docId.guid,
|
|
||||||
'Doc.Read',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const history = await this.workspace.getDocHistory(
|
const history = await this.workspace.getDocHistory(
|
||||||
docId.workspace,
|
docId.workspace,
|
||||||
|
|||||||
@@ -44,21 +44,21 @@ export class WorkspaceEvents {
|
|||||||
async onRoleChanged({
|
async onRoleChanged({
|
||||||
userId,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
permission,
|
role,
|
||||||
}: Events['workspace.members.roleChanged']) {
|
}: Events['workspace.members.roleChanged']) {
|
||||||
// send role changed mail
|
// send role changed mail
|
||||||
await this.workspaceService.sendRoleChangedEmail(userId, {
|
await this.workspaceService.sendRoleChangedEmail(userId, {
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
role: permission,
|
role,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('workspace.members.ownershipTransferred')
|
@OnEvent('workspace.owner.changed')
|
||||||
async onOwnerTransferred({
|
async onOwnerTransferred({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
}: Events['workspace.members.ownershipTransferred']) {
|
}: Events['workspace.owner.changed']) {
|
||||||
// send ownership transferred mail
|
// send ownership transferred mail
|
||||||
const fromUser = await this.models.user.getPublicUser(from);
|
const fromUser = await this.models.user.getPublicUser(from);
|
||||||
const toUser = await this.models.user.getPublicUser(to);
|
const toUser = await this.models.user.getPublicUser(to);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
|||||||
import type { FileUpload } from '../../../base';
|
import type { FileUpload } from '../../../base';
|
||||||
import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base';
|
import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base';
|
||||||
import { CurrentUser } from '../../auth';
|
import { CurrentUser } from '../../auth';
|
||||||
import { PermissionService, WorkspaceRole } from '../../permission';
|
import { AccessController } from '../../permission';
|
||||||
import { QuotaService } from '../../quota';
|
import { QuotaService } from '../../quota';
|
||||||
import { WorkspaceBlobStorage } from '../../storage';
|
import { WorkspaceBlobStorage } from '../../storage';
|
||||||
import { WorkspaceBlobSizes, WorkspaceType } from '../types';
|
import { WorkspaceBlobSizes, WorkspaceType } from '../types';
|
||||||
@@ -40,7 +40,7 @@ class ListedBlob {
|
|||||||
export class WorkspaceBlobResolver {
|
export class WorkspaceBlobResolver {
|
||||||
logger = new Logger(WorkspaceBlobResolver.name);
|
logger = new Logger(WorkspaceBlobResolver.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly permissions: PermissionService,
|
private readonly ac: AccessController,
|
||||||
private readonly quota: QuotaService,
|
private readonly quota: QuotaService,
|
||||||
private readonly storage: WorkspaceBlobStorage
|
private readonly storage: WorkspaceBlobStorage
|
||||||
) {}
|
) {}
|
||||||
@@ -53,7 +53,10 @@ export class WorkspaceBlobResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Parent() workspace: WorkspaceType
|
@Parent() workspace: WorkspaceType
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(workspace.id, user.id);
|
await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(workspace.id)
|
||||||
|
.assert('Workspace.Blobs.List');
|
||||||
|
|
||||||
return this.storage.list(workspace.id);
|
return this.storage.list(workspace.id);
|
||||||
}
|
}
|
||||||
@@ -66,24 +69,6 @@ export class WorkspaceBlobResolver {
|
|||||||
return this.storage.totalSize(workspace.id);
|
return this.storage.totalSize(workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated use `workspace.blobs` instead
|
|
||||||
*/
|
|
||||||
@Query(() => [String], {
|
|
||||||
description: 'List blobs of workspace',
|
|
||||||
deprecationReason: 'use `workspace.blobs` instead',
|
|
||||||
})
|
|
||||||
async listBlobs(
|
|
||||||
@CurrentUser() user: CurrentUser,
|
|
||||||
@Args('workspaceId') workspaceId: string
|
|
||||||
) {
|
|
||||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
|
||||||
|
|
||||||
return this.storage
|
|
||||||
.list(workspaceId)
|
|
||||||
.then(list => list.map(item => item.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(() => WorkspaceBlobSizes, {
|
@Query(() => WorkspaceBlobSizes, {
|
||||||
deprecationReason: 'use `user.quotaUsage` instead',
|
deprecationReason: 'use `user.quotaUsage` instead',
|
||||||
})
|
})
|
||||||
@@ -99,11 +84,10 @@ export class WorkspaceBlobResolver {
|
|||||||
@Args({ name: 'blob', type: () => GraphQLUpload })
|
@Args({ name: 'blob', type: () => GraphQLUpload })
|
||||||
blob: FileUpload
|
blob: FileUpload
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Collaborator
|
.assert('Workspace.Blobs.Write');
|
||||||
);
|
|
||||||
|
|
||||||
const checkExceeded =
|
const checkExceeded =
|
||||||
await this.quota.getWorkspaceQuotaCalculator(workspaceId);
|
await this.quota.getWorkspaceQuotaCalculator(workspaceId);
|
||||||
@@ -159,7 +143,10 @@ export class WorkspaceBlobResolver {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(workspaceId)
|
||||||
|
.assert('Workspace.Blobs.Write');
|
||||||
|
|
||||||
await this.storage.delete(workspaceId, key, permanently);
|
await this.storage.delete(workspaceId, key, permanently);
|
||||||
|
|
||||||
@@ -171,11 +158,10 @@ export class WorkspaceBlobResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string
|
@Args('workspaceId') workspaceId: string
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Collaborator
|
.assert('Workspace.Blobs.Write');
|
||||||
);
|
|
||||||
|
|
||||||
await this.storage.release(workspaceId);
|
await this.storage.release(workspaceId);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
Cache,
|
Cache,
|
||||||
DocActionDenied,
|
DocActionDenied,
|
||||||
DocDefaultRoleCanNotBeOwner,
|
DocDefaultRoleCanNotBeOwner,
|
||||||
DocIsNotPublic,
|
|
||||||
ExpectToGrantDocUserRoles,
|
ExpectToGrantDocUserRoles,
|
||||||
ExpectToPublishDoc,
|
ExpectToPublishDoc,
|
||||||
ExpectToRevokeDocUserRoles,
|
ExpectToRevokeDocUserRoles,
|
||||||
@@ -28,21 +27,20 @@ import {
|
|||||||
PaginationInput,
|
PaginationInput,
|
||||||
registerObjectType,
|
registerObjectType,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import { Models } from '../../../models';
|
import { Models, PublicDocMode } from '../../../models';
|
||||||
import { CurrentUser } from '../../auth';
|
import { CurrentUser } from '../../auth';
|
||||||
import {
|
import {
|
||||||
|
AccessController,
|
||||||
DOC_ACTIONS,
|
DOC_ACTIONS,
|
||||||
DocAction,
|
DocAction,
|
||||||
DocRole,
|
DocRole,
|
||||||
fixupDocRole,
|
|
||||||
mapDocRoleToPermissions,
|
|
||||||
PermissionService,
|
|
||||||
PublicDocMode,
|
|
||||||
} from '../../permission';
|
} from '../../permission';
|
||||||
import { PublicUserType } from '../../user';
|
import { PublicUserType } from '../../user';
|
||||||
import { DocID } from '../../utils/doc';
|
|
||||||
import { WorkspaceType } from '../types';
|
import { WorkspaceType } from '../types';
|
||||||
import { DotToUnderline, mapPermissionToGraphqlPermissions } from './workspace';
|
import {
|
||||||
|
DotToUnderline,
|
||||||
|
mapPermissionsToGraphqlPermissions,
|
||||||
|
} from './workspace';
|
||||||
|
|
||||||
registerEnumType(PublicDocMode, {
|
registerEnumType(PublicDocMode, {
|
||||||
name: 'PublicDocMode',
|
name: 'PublicDocMode',
|
||||||
@@ -155,8 +153,11 @@ export class WorkspaceDocResolver {
|
|||||||
private readonly logger = new Logger(WorkspaceDocResolver.name);
|
private readonly logger = new Logger(WorkspaceDocResolver.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
/**
|
||||||
|
* @deprecated migrate to models
|
||||||
|
*/
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly permission: PermissionService,
|
private readonly ac: AccessController,
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly cache: Cache
|
private readonly cache: Cache
|
||||||
) {}
|
) {}
|
||||||
@@ -174,12 +175,7 @@ export class WorkspaceDocResolver {
|
|||||||
complexity: 2,
|
complexity: 2,
|
||||||
})
|
})
|
||||||
async publicDocs(@Parent() workspace: WorkspaceType) {
|
async publicDocs(@Parent() workspace: WorkspaceType) {
|
||||||
return this.prisma.workspaceDoc.findMany({
|
return this.models.workspace.getPublicDocs(workspace.id);
|
||||||
where: {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
public: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => DocType, {
|
@ResolveField(() => DocType, {
|
||||||
@@ -203,14 +199,7 @@ export class WorkspaceDocResolver {
|
|||||||
@Parent() workspace: WorkspaceType,
|
@Parent() workspace: WorkspaceType,
|
||||||
@Args('docId') docId: string
|
@Args('docId') docId: string
|
||||||
): Promise<DocType> {
|
): Promise<DocType> {
|
||||||
const doc = await this.prisma.workspaceDoc.findUnique({
|
const doc = await this.models.workspace.getDoc(workspace.id, docId);
|
||||||
where: {
|
|
||||||
workspaceId_docId: {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
docId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
if (doc) {
|
||||||
return doc;
|
return doc;
|
||||||
@@ -249,7 +238,7 @@ export class WorkspaceDocResolver {
|
|||||||
async publishDoc(
|
async publishDoc(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('docId') rawDocId: string,
|
@Args('docId') docId: string,
|
||||||
@Args({
|
@Args({
|
||||||
name: 'mode',
|
name: 'mode',
|
||||||
type: () => PublicDocMode,
|
type: () => PublicDocMode,
|
||||||
@@ -258,28 +247,27 @@ export class WorkspaceDocResolver {
|
|||||||
})
|
})
|
||||||
mode: PublicDocMode
|
mode: PublicDocMode
|
||||||
) {
|
) {
|
||||||
const docId = new DocID(rawDocId, workspaceId);
|
if (workspaceId === docId) {
|
||||||
|
|
||||||
if (docId.isWorkspace) {
|
|
||||||
this.logger.error('Expect to publish doc, but it is a workspace', {
|
this.logger.error('Expect to publish doc, but it is a workspace', {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
docId: rawDocId,
|
docId,
|
||||||
});
|
});
|
||||||
throw new ExpectToPublishDoc();
|
throw new ExpectToPublishDoc();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkPagePermission(
|
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||||
docId.workspace,
|
|
||||||
docId.guid,
|
const doc = await this.models.workspace.publishDoc(
|
||||||
'Doc.Publish',
|
workspaceId,
|
||||||
user.id
|
docId,
|
||||||
|
mode
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Publish page ${rawDocId} with mode ${mode} in workspace ${workspaceId}`
|
`Publish page ${docId} with mode ${mode} in workspace ${workspaceId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.permission.publishPage(docId.workspace, docId.guid, mode);
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => DocType, {
|
@Mutation(() => DocType, {
|
||||||
@@ -297,44 +285,23 @@ export class WorkspaceDocResolver {
|
|||||||
async revokePublicDoc(
|
async revokePublicDoc(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('docId') rawDocId: string
|
@Args('docId') docId: string
|
||||||
) {
|
) {
|
||||||
const docId = new DocID(rawDocId, workspaceId);
|
if (workspaceId === docId) {
|
||||||
|
|
||||||
if (docId.isWorkspace) {
|
|
||||||
this.logger.error('Expect to revoke public doc, but it is a workspace', {
|
this.logger.error('Expect to revoke public doc, but it is a workspace', {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
docId: rawDocId,
|
docId,
|
||||||
});
|
});
|
||||||
throw new ExpectToRevokePublicDoc('Expect doc not to be workspace');
|
throw new ExpectToRevokePublicDoc('Expect doc not to be workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkPagePermission(
|
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||||
docId.workspace,
|
|
||||||
docId.guid,
|
|
||||||
'Doc.Publish',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const isPublic = await this.permission.isPublicPage(
|
const doc = await this.models.workspace.revokePublicDoc(workspaceId, docId);
|
||||||
docId.workspace,
|
|
||||||
docId.guid
|
|
||||||
);
|
|
||||||
|
|
||||||
const info = {
|
this.logger.log(`Revoke public doc ${docId} in workspace ${workspaceId}`);
|
||||||
workspaceId,
|
|
||||||
docId: rawDocId,
|
|
||||||
};
|
|
||||||
if (!isPublic) {
|
|
||||||
this.logger.log(
|
|
||||||
`Expect to revoke public doc, but it is not public (${JSON.stringify(info)})`
|
|
||||||
);
|
|
||||||
throw new DocIsNotPublic('Doc is not public');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Revoke public doc (${JSON.stringify(info)})`);
|
return doc;
|
||||||
|
|
||||||
return this.permission.revokePublicPage(docId.workspace, docId.guid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryFixDocOwner(workspaceId: string, docId: string) {
|
private async tryFixDocOwner(workspaceId: string, docId: string) {
|
||||||
@@ -357,13 +324,7 @@ export class WorkspaceDocResolver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = await this.prisma.workspaceDocUserPermission.findFirst({
|
const owner = await this.models.docUser.getOwner(workspaceId, docId);
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
type: DocRole.Owner,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// skip if owner already exists
|
// skip if owner already exists
|
||||||
if (owner) {
|
if (owner) {
|
||||||
@@ -387,18 +348,11 @@ export class WorkspaceDocResolver {
|
|||||||
|
|
||||||
// try workspace.owner
|
// try workspace.owner
|
||||||
if (!fixedOwner) {
|
if (!fixedOwner) {
|
||||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
fixedOwner = owner.id;
|
fixedOwner = owner.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.workspaceDocUserPermission.createMany({
|
await this.models.docUser.setOwner(workspaceId, docId, fixedOwner);
|
||||||
data: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
userId: fixedOwner,
|
|
||||||
type: DocRole.Owner,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Fixed doc owner for ${docId} in workspace ${workspaceId}, new owner: ${fixedOwner}`
|
`Fixed doc owner for ${docId} in workspace ${workspaceId}, new owner: ${fixedOwner}`
|
||||||
@@ -411,8 +365,7 @@ export class DocResolver {
|
|||||||
private readonly logger = new Logger(DocResolver.name);
|
private readonly logger = new Logger(DocResolver.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaClient,
|
private readonly ac: AccessController,
|
||||||
private readonly permission: PermissionService,
|
|
||||||
private readonly models: Models
|
private readonly models: Models
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -421,33 +374,9 @@ export class DocResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Parent() doc: DocType
|
@Parent() doc: DocType
|
||||||
): Promise<InstanceType<typeof DocPermissions>> {
|
): Promise<InstanceType<typeof DocPermissions>> {
|
||||||
const [permission, workspacePermission] = await this.prisma.$transaction(
|
const { permissions } = await this.ac.user(user.id).doc(doc).permissions();
|
||||||
tx =>
|
|
||||||
Promise.all([
|
|
||||||
tx.workspaceDocUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId: doc.workspaceId,
|
|
||||||
docId: doc.docId,
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
tx.workspaceUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId: doc.workspaceId,
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
return mapPermissionToGraphqlPermissions(
|
return mapPermissionsToGraphqlPermissions(permissions);
|
||||||
mapDocRoleToPermissions(
|
|
||||||
fixupDocRole(
|
|
||||||
workspacePermission?.type,
|
|
||||||
permission?.type ?? doc.defaultRole
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => PaginatedGrantedDocUserType, {
|
@ResolveField(() => PaginatedGrantedDocUserType, {
|
||||||
@@ -459,49 +388,20 @@ export class DocResolver {
|
|||||||
@Parent() doc: DocType,
|
@Parent() doc: DocType,
|
||||||
@Args('pagination', PaginationInput.decode) pagination: PaginationInput
|
@Args('pagination', PaginationInput.decode) pagination: PaginationInput
|
||||||
): Promise<PaginatedGrantedDocUserType> {
|
): Promise<PaginatedGrantedDocUserType> {
|
||||||
await this.permission.checkPagePermission(
|
await this.ac.user(user.id).doc(doc).assert('Doc.Users.Read');
|
||||||
|
|
||||||
|
const [permissions, totalCount] = await this.models.docUser.paginate(
|
||||||
doc.workspaceId,
|
doc.workspaceId,
|
||||||
doc.docId,
|
doc.docId,
|
||||||
'Doc.Users.Read',
|
pagination
|
||||||
user.id
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [permissions, totalCount] = await this.prisma.$transaction(tx => {
|
|
||||||
return Promise.all([
|
|
||||||
tx.workspaceDocUserPermission.findMany({
|
|
||||||
where: {
|
|
||||||
workspaceId: doc.workspaceId,
|
|
||||||
docId: doc.docId,
|
|
||||||
createdAt: pagination.after
|
|
||||||
? {
|
|
||||||
gt: pagination.after,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{
|
|
||||||
type: 'desc',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
take: pagination.first,
|
|
||||||
skip: pagination.offset,
|
|
||||||
}),
|
|
||||||
tx.workspaceDocUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId: doc.workspaceId,
|
|
||||||
docId: doc.docId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const publicUsers = await this.models.user.getPublicUsers(
|
const publicUsers = await this.models.user.getPublicUsers(
|
||||||
permissions.map(p => p.userId)
|
permissions.map(p => p.userId)
|
||||||
);
|
);
|
||||||
|
|
||||||
const publicUsersMap = new Map(publicUsers.map(pu => [pu.id, pu]));
|
const publicUsersMap = new Map(publicUsers.map(pu => [pu.id, pu]));
|
||||||
|
|
||||||
return paginate(
|
return paginate(
|
||||||
permissions.map(p => ({
|
permissions.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
@@ -518,12 +418,12 @@ export class DocResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('input') input: GrantDocUserRolesInput
|
@Args('input') input: GrantDocUserRolesInput
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const doc = new DocID(input.docId, input.workspaceId);
|
|
||||||
const pairs = {
|
const pairs = {
|
||||||
spaceId: input.workspaceId,
|
spaceId: input.workspaceId,
|
||||||
docId: input.docId,
|
docId: input.docId,
|
||||||
};
|
};
|
||||||
if (doc.isWorkspace) {
|
|
||||||
|
if (input.workspaceId === input.docId) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'Expect to grant doc user roles, but it is a workspace',
|
'Expect to grant doc user roles, but it is a workspace',
|
||||||
pairs
|
pairs
|
||||||
@@ -533,18 +433,16 @@ export class DocResolver {
|
|||||||
'Expect doc not to be workspace'
|
'Expect doc not to be workspace'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.permission.checkPagePermission(
|
|
||||||
doc.workspace,
|
await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
|
||||||
doc.guid,
|
|
||||||
'Doc.Users.Manage',
|
await this.models.docUser.batchSetUserRoles(
|
||||||
user.id
|
input.workspaceId,
|
||||||
);
|
input.docId,
|
||||||
await this.permission.batchGrantPage(
|
|
||||||
doc.workspace,
|
|
||||||
doc.guid,
|
|
||||||
input.userIds,
|
input.userIds,
|
||||||
input.role
|
input.role
|
||||||
);
|
);
|
||||||
|
|
||||||
const info = {
|
const info = {
|
||||||
...pairs,
|
...pairs,
|
||||||
userIds: input.userIds,
|
userIds: input.userIds,
|
||||||
@@ -559,12 +457,11 @@ export class DocResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('input') input: RevokeDocUserRoleInput
|
@Args('input') input: RevokeDocUserRoleInput
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const doc = new DocID(input.docId, input.workspaceId);
|
|
||||||
const pairs = {
|
const pairs = {
|
||||||
spaceId: input.workspaceId,
|
spaceId: input.workspaceId,
|
||||||
docId: doc.guid,
|
docId: input.docId,
|
||||||
};
|
};
|
||||||
if (doc.isWorkspace) {
|
if (input.workspaceId === input.docId) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'Expect to revoke doc user roles, but it is a workspace',
|
'Expect to revoke doc user roles, but it is a workspace',
|
||||||
pairs
|
pairs
|
||||||
@@ -574,13 +471,14 @@ export class DocResolver {
|
|||||||
'Expect doc not to be workspace'
|
'Expect doc not to be workspace'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.permission.checkPagePermission(
|
await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
|
||||||
doc.workspace,
|
|
||||||
doc.guid,
|
await this.models.docUser.delete(
|
||||||
'Doc.Users.Manage',
|
input.workspaceId,
|
||||||
user.id
|
input.docId,
|
||||||
|
input.userId
|
||||||
);
|
);
|
||||||
await this.permission.revokePage(doc.workspace, doc.guid, input.userId);
|
|
||||||
const info = {
|
const info = {
|
||||||
...pairs,
|
...pairs,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -594,12 +492,11 @@ export class DocResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('input') input: UpdateDocUserRoleInput
|
@Args('input') input: UpdateDocUserRoleInput
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const doc = new DocID(input.docId, input.workspaceId);
|
|
||||||
const pairs = {
|
const pairs = {
|
||||||
spaceId: doc.workspace,
|
spaceId: input.workspaceId,
|
||||||
docId: doc.guid,
|
docId: input.docId,
|
||||||
};
|
};
|
||||||
if (doc.isWorkspace) {
|
if (input.workspaceId === input.docId) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'Expect to update doc user role, but it is a workspace',
|
'Expect to update doc user role, but it is a workspace',
|
||||||
pairs
|
pairs
|
||||||
@@ -610,28 +507,28 @@ export class DocResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkPagePermission(
|
|
||||||
doc.workspace,
|
|
||||||
doc.guid,
|
|
||||||
input.role === DocRole.Owner ? 'Doc.TransferOwner' : 'Doc.Users.Manage',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.permission.grantPage(
|
|
||||||
doc.workspace,
|
|
||||||
doc.guid,
|
|
||||||
input.userId,
|
|
||||||
input.role
|
|
||||||
);
|
|
||||||
|
|
||||||
const info = {
|
const info = {
|
||||||
...pairs,
|
...pairs,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
role: input.role,
|
role: input.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (input.role === DocRole.Owner) {
|
if (input.role === DocRole.Owner) {
|
||||||
|
await this.ac.user(user.id).doc(input).assert('Doc.TransferOwner');
|
||||||
|
await this.models.docUser.setOwner(
|
||||||
|
input.workspaceId,
|
||||||
|
input.docId,
|
||||||
|
input.userId
|
||||||
|
);
|
||||||
this.logger.log(`Transfer doc owner (${JSON.stringify(info)})`);
|
this.logger.log(`Transfer doc owner (${JSON.stringify(info)})`);
|
||||||
} else {
|
} else {
|
||||||
|
await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
|
||||||
|
await this.models.docUser.set(
|
||||||
|
input.workspaceId,
|
||||||
|
input.docId,
|
||||||
|
input.userId,
|
||||||
|
input.role
|
||||||
|
);
|
||||||
this.logger.log(`Update doc user role (${JSON.stringify(info)})`);
|
this.logger.log(`Update doc user role (${JSON.stringify(info)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,12 +546,11 @@ export class DocResolver {
|
|||||||
);
|
);
|
||||||
throw new DocDefaultRoleCanNotBeOwner();
|
throw new DocDefaultRoleCanNotBeOwner();
|
||||||
}
|
}
|
||||||
const doc = new DocID(input.docId, input.workspaceId);
|
|
||||||
const pairs = {
|
const pairs = {
|
||||||
spaceId: doc.workspace,
|
spaceId: input.workspaceId,
|
||||||
docId: doc.guid,
|
docId: input.docId,
|
||||||
};
|
};
|
||||||
if (doc.isWorkspace) {
|
if (input.workspaceId === input.docId) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'Expect to update page default role, but it is a workspace',
|
'Expect to update page default role, but it is a workspace',
|
||||||
pairs
|
pairs
|
||||||
@@ -665,12 +561,7 @@ export class DocResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.permission.checkPagePermission(
|
await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
|
||||||
doc.workspace,
|
|
||||||
doc.guid,
|
|
||||||
'Doc.Users.Manage',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DocActionDenied) {
|
if (error instanceof DocActionDenied) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -684,22 +575,11 @@ export class DocResolver {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await this.prisma.workspaceDoc.upsert({
|
await this.models.workspace.setDocDefaultRole(
|
||||||
where: {
|
input.workspaceId,
|
||||||
workspaceId_docId: {
|
input.docId,
|
||||||
workspaceId: doc.workspace,
|
input.role
|
||||||
docId: doc.guid,
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
defaultRole: input.role,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
workspaceId: doc.workspace,
|
|
||||||
docId: doc.guid,
|
|
||||||
defaultRole: input.role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { SnapshotHistory } from '@prisma/client';
|
|||||||
|
|
||||||
import { CurrentUser } from '../../auth';
|
import { CurrentUser } from '../../auth';
|
||||||
import { PgWorkspaceDocStorageAdapter } from '../../doc';
|
import { PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||||
import { PermissionService } from '../../permission';
|
import { AccessController } from '../../permission';
|
||||||
import { DocID } from '../../utils/doc';
|
import { DocID } from '../../utils/doc';
|
||||||
import { WorkspaceType } from '../types';
|
import { WorkspaceType } from '../types';
|
||||||
import { EditorType } from './workspace';
|
import { EditorType } from './workspace';
|
||||||
@@ -37,7 +37,7 @@ class DocHistoryType implements Partial<SnapshotHistory> {
|
|||||||
export class DocHistoryResolver {
|
export class DocHistoryResolver {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
||||||
private readonly permission: PermissionService
|
private readonly ac: AccessController
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ResolveField(() => [DocHistoryType])
|
@ResolveField(() => [DocHistoryType])
|
||||||
@@ -76,12 +76,7 @@ export class DocHistoryResolver {
|
|||||||
): Promise<Date> {
|
): Promise<Date> {
|
||||||
const docId = new DocID(guid, workspaceId);
|
const docId = new DocID(guid, workspaceId);
|
||||||
|
|
||||||
await this.permission.checkPagePermission(
|
await this.ac.user(user.id).doc(docId).assert('Doc.Update');
|
||||||
docId.workspace,
|
|
||||||
docId.guid,
|
|
||||||
'Doc.Update',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.workspace.rollbackDoc(
|
await this.workspace.rollbackDoc(
|
||||||
docId.workspace,
|
docId.workspace,
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { getStreamAsBuffer } from 'get-stream';
|
import { getStreamAsBuffer } from 'get-stream';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Cache,
|
Cache,
|
||||||
MailService,
|
MailService,
|
||||||
|
NotFound,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
URLHelper,
|
URLHelper,
|
||||||
UserNotFound,
|
UserNotFound,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
import { DocReader } from '../../doc';
|
import { DocReader } from '../../doc';
|
||||||
import { PermissionService, WorkspaceRole } from '../../permission';
|
import { WorkspaceRole } from '../../permission';
|
||||||
import { WorkspaceBlobStorage } from '../../storage';
|
import { WorkspaceBlobStorage } from '../../storage';
|
||||||
|
|
||||||
export const defaultWorkspaceAvatar =
|
export const defaultWorkspaceAvatar =
|
||||||
@@ -32,8 +32,6 @@ export class WorkspaceService {
|
|||||||
private readonly cache: Cache,
|
private readonly cache: Cache,
|
||||||
private readonly doc: DocReader,
|
private readonly doc: DocReader,
|
||||||
private readonly mailer: MailService,
|
private readonly mailer: MailService,
|
||||||
private readonly permission: PermissionService,
|
|
||||||
private readonly prisma: PrismaClient,
|
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly url: URLHelper
|
private readonly url: URLHelper
|
||||||
) {}
|
) {}
|
||||||
@@ -47,20 +45,16 @@ export class WorkspaceService {
|
|||||||
return invite;
|
return invite;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.prisma.workspaceUserPermission
|
const workspaceUser = await this.models.workspaceUser.getById(inviteId);
|
||||||
.findUniqueOrThrow({
|
|
||||||
where: {
|
if (!workspaceUser) {
|
||||||
id: inviteId,
|
throw new NotFound('Invitation not found');
|
||||||
},
|
}
|
||||||
select: {
|
|
||||||
workspaceId: true,
|
return {
|
||||||
userId: true,
|
workspaceId: workspaceUser.workspaceId,
|
||||||
},
|
inviteeUserId: workspaceUser.userId,
|
||||||
})
|
};
|
||||||
.then(r => ({
|
|
||||||
workspaceId: r.workspaceId,
|
|
||||||
inviteeUserId: r.userId,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWorkspaceInfo(workspaceId: string) {
|
async getWorkspaceInfo(workspaceId: string) {
|
||||||
@@ -115,7 +109,7 @@ export class WorkspaceService {
|
|||||||
: null;
|
: null;
|
||||||
const inviter = inviterUserId
|
const inviter = inviterUserId
|
||||||
? await this.models.user.getPublicUser(inviterUserId)
|
? await this.models.user.getPublicUser(inviterUserId)
|
||||||
: await this.permission.getWorkspaceOwner(workspaceId);
|
: await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
|
|
||||||
if (!inviter || !invitee) {
|
if (!inviter || !invitee) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -138,7 +132,7 @@ export class WorkspaceService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = await this.permission.getWorkspaceOwner(target.workspace.id);
|
const owner = await this.models.workspaceUser.getOwner(target.workspace.id);
|
||||||
|
|
||||||
await this.mailer.sendMemberInviteMail(target.email, {
|
await this.mailer.sendMemberInviteMail(target.email, {
|
||||||
workspace: target.workspace,
|
workspace: target.workspace,
|
||||||
@@ -154,8 +148,8 @@ export class WorkspaceService {
|
|||||||
|
|
||||||
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
|
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
|
||||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
const admins = await this.permission.getWorkspaceAdmin(workspaceId);
|
const admins = await this.models.workspaceUser.getAdmins(workspaceId);
|
||||||
|
|
||||||
await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, {
|
await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, {
|
||||||
workspace,
|
workspace,
|
||||||
@@ -188,8 +182,8 @@ export class WorkspaceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
const admin = await this.permission.getWorkspaceAdmin(workspaceId);
|
const admin = await this.models.workspaceUser.getAdmins(workspaceId);
|
||||||
|
|
||||||
for (const user of [owner, ...admin]) {
|
for (const user of [owner, ...admin]) {
|
||||||
await this.mailer.sendLinkInvitationReviewRequestMail(user.email, {
|
await this.mailer.sendLinkInvitationReviewRequestMail(user.email, {
|
||||||
@@ -260,7 +254,7 @@ export class WorkspaceService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
}: Events['workspace.members.leave']) {
|
}: Events['workspace.members.leave']) {
|
||||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
await this.mailer.sendMemberLeaveEmail(owner.email, {
|
await this.mailer.sendMemberLeaveEmail(owner.email, {
|
||||||
workspace,
|
workspace,
|
||||||
user,
|
user,
|
||||||
@@ -271,7 +265,7 @@ export class WorkspaceService {
|
|||||||
async onMemberRemoved({
|
async onMemberRemoved({
|
||||||
userId,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
}: Events['workspace.members.requestDeclined']) {
|
}: Events['workspace.members.removed']) {
|
||||||
const user = await this.models.user.get(userId);
|
const user = await this.models.user.get(userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
ResolveField,
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,11 +17,10 @@ import {
|
|||||||
RequestMutex,
|
RequestMutex,
|
||||||
TooManyRequest,
|
TooManyRequest,
|
||||||
URLHelper,
|
URLHelper,
|
||||||
UserFriendlyError,
|
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
import { CurrentUser } from '../../auth';
|
import { CurrentUser } from '../../auth';
|
||||||
import { PermissionService, WorkspaceRole } from '../../permission';
|
import { AccessController, WorkspaceRole } from '../../permission';
|
||||||
import { QuotaService } from '../../quota';
|
import { QuotaService } from '../../quota';
|
||||||
import {
|
import {
|
||||||
InviteLink,
|
InviteLink,
|
||||||
@@ -44,8 +43,7 @@ export class TeamWorkspaceResolver {
|
|||||||
private readonly cache: Cache,
|
private readonly cache: Cache,
|
||||||
private readonly event: EventBus,
|
private readonly event: EventBus,
|
||||||
private readonly url: URLHelper,
|
private readonly url: URLHelper,
|
||||||
private readonly prisma: PrismaClient,
|
private readonly ac: AccessController,
|
||||||
private readonly permissions: PermissionService,
|
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly quota: QuotaService,
|
private readonly quota: QuotaService,
|
||||||
private readonly mutex: RequestMutex,
|
private readonly mutex: RequestMutex,
|
||||||
@@ -68,21 +66,20 @@ export class TeamWorkspaceResolver {
|
|||||||
@Args({ name: 'emails', type: () => [String] }) emails: string[],
|
@Args({ name: 'emails', type: () => [String] }) emails: string[],
|
||||||
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
|
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Admin
|
.assert('Workspace.Users.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
if (emails.length > 512) {
|
if (emails.length > 512) {
|
||||||
return new TooManyRequest();
|
throw new TooManyRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
// lock to prevent concurrent invite
|
// lock to prevent concurrent invite
|
||||||
const lockFlag = `invite:${workspaceId}`;
|
const lockFlag = `invite:${workspaceId}`;
|
||||||
await using lock = await this.mutex.acquire(lockFlag);
|
await using lock = await this.mutex.acquire(lockFlag);
|
||||||
if (!lock) {
|
if (!lock) {
|
||||||
return new TooManyRequest();
|
throw new TooManyRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
|
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
|
||||||
@@ -93,13 +90,10 @@ export class TeamWorkspaceResolver {
|
|||||||
try {
|
try {
|
||||||
let target = await this.models.user.getUserByEmail(email);
|
let target = await this.models.user.getUserByEmail(email);
|
||||||
if (target) {
|
if (target) {
|
||||||
const originRecord =
|
const originRecord = await this.models.workspaceUser.get(
|
||||||
await this.prisma.workspaceUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId: target.id,
|
target.id
|
||||||
},
|
);
|
||||||
});
|
|
||||||
// only invite if the user is not already in the workspace
|
// only invite if the user is not already in the workspace
|
||||||
if (originRecord) continue;
|
if (originRecord) continue;
|
||||||
} else {
|
} else {
|
||||||
@@ -110,7 +104,7 @@ export class TeamWorkspaceResolver {
|
|||||||
}
|
}
|
||||||
const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit;
|
const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit;
|
||||||
|
|
||||||
ret.inviteId = await this.permissions.grant(
|
const role = await this.models.workspaceUser.set(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target.id,
|
target.id,
|
||||||
WorkspaceRole.Collaborator,
|
WorkspaceRole.Collaborator,
|
||||||
@@ -118,6 +112,7 @@ export class TeamWorkspaceResolver {
|
|||||||
? WorkspaceMemberStatus.NeedMoreSeat
|
? WorkspaceMemberStatus.NeedMoreSeat
|
||||||
: WorkspaceMemberStatus.Pending
|
: WorkspaceMemberStatus.Pending
|
||||||
);
|
);
|
||||||
|
ret.inviteId = role.id;
|
||||||
// NOTE: we always send email even seat not enough
|
// NOTE: we always send email even seat not enough
|
||||||
// because at this moment we cannot know whether the seat increase charge was successful
|
// because at this moment we cannot know whether the seat increase charge was successful
|
||||||
// after user click the invite link, we can check again and reject if charge failed
|
// after user click the invite link, we can check again and reject if charge failed
|
||||||
@@ -156,11 +151,10 @@ export class TeamWorkspaceResolver {
|
|||||||
@Parent() workspace: WorkspaceType,
|
@Parent() workspace: WorkspaceType,
|
||||||
@CurrentUser() user: CurrentUser
|
@CurrentUser() user: CurrentUser
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(
|
await this.ac
|
||||||
workspace.id,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspace.id)
|
||||||
WorkspaceRole.Admin
|
.assert('Workspace.Users.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
const cacheId = `workspace:inviteLink:${workspace.id}`;
|
const cacheId = `workspace:inviteLink:${workspace.id}`;
|
||||||
const id = await this.cache.get<{ inviteId: string }>(cacheId);
|
const id = await this.cache.get<{ inviteId: string }>(cacheId);
|
||||||
@@ -183,11 +177,11 @@ export class TeamWorkspaceResolver {
|
|||||||
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
|
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
|
||||||
expireTime: WorkspaceInviteLinkExpireTime
|
expireTime: WorkspaceInviteLinkExpireTime
|
||||||
): Promise<InviteLink> {
|
): Promise<InviteLink> {
|
||||||
await this.permissions.checkWorkspace(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Admin
|
.assert('Workspace.Users.Manage');
|
||||||
);
|
|
||||||
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
|
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
|
||||||
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
|
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
|
||||||
if (typeof invite?.inviteId === 'string') {
|
if (typeof invite?.inviteId === 'string') {
|
||||||
@@ -219,134 +213,80 @@ export class TeamWorkspaceResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string
|
@Args('workspaceId') workspaceId: string
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Admin
|
.assert('Workspace.Users.Manage');
|
||||||
);
|
|
||||||
const cacheId = `workspace:inviteLink:${workspaceId}`;
|
const cacheId = `workspace:inviteLink:${workspaceId}`;
|
||||||
return await this.cache.delete(cacheId);
|
return await this.cache.delete(cacheId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => String)
|
@Mutation(() => Boolean)
|
||||||
async approveMember(
|
async approveMember(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('userId') userId: string
|
@Args('userId') userId: string
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Admin
|
.assert('Workspace.Users.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
const role = await this.models.workspaceUser.get(workspaceId, userId);
|
||||||
// lock to prevent concurrent invite and grant
|
|
||||||
const lockFlag = `invite:${workspaceId}`;
|
|
||||||
await using lock = await this.mutex.acquire(lockFlag);
|
|
||||||
if (!lock) {
|
|
||||||
return new TooManyRequest();
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await this.permissions.getWorkspaceMemberStatus(
|
if (role) {
|
||||||
workspaceId,
|
if (role.status === WorkspaceMemberStatus.UnderReview) {
|
||||||
userId
|
const result = await this.models.workspaceUser.setStatus(
|
||||||
);
|
|
||||||
if (status) {
|
|
||||||
if (status === WorkspaceMemberStatus.UnderReview) {
|
|
||||||
const result = await this.permissions.grant(
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
userId,
|
||||||
WorkspaceRole.Collaborator,
|
|
||||||
WorkspaceMemberStatus.Accepted
|
WorkspaceMemberStatus.Accepted
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result) {
|
|
||||||
this.event.emit('workspace.members.requestApproved', {
|
this.event.emit('workspace.members.requestApproved', {
|
||||||
inviteId: result,
|
inviteId: result.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return true;
|
||||||
}
|
|
||||||
return new TooManyRequest();
|
|
||||||
} else {
|
} else {
|
||||||
return new MemberNotFoundInSpace({ spaceId: workspaceId });
|
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.error('failed to invite user', e);
|
|
||||||
return new TooManyRequest();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => String)
|
@Mutation(() => Boolean)
|
||||||
async grantMember(
|
async grantMember(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('userId') userId: string,
|
@Args('userId') userId: string,
|
||||||
@Args('permission', { type: () => WorkspaceRole }) permission: WorkspaceRole
|
@Args('permission', { type: () => WorkspaceRole }) newRole: WorkspaceRole
|
||||||
) {
|
) {
|
||||||
|
await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(workspaceId)
|
||||||
|
.assert(
|
||||||
|
newRole === WorkspaceRole.Owner
|
||||||
|
? 'Workspace.TransferOwner'
|
||||||
|
: 'Workspace.Users.Manage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const role = await this.models.workspaceUser.get(workspaceId, userId);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newRole === WorkspaceRole.Owner) {
|
||||||
|
await this.models.workspaceUser.setOwner(workspaceId, userId);
|
||||||
|
} else {
|
||||||
// non-team workspace can only transfer ownership, but no detailed permission control
|
// non-team workspace can only transfer ownership, but no detailed permission control
|
||||||
if (permission !== WorkspaceRole.Owner) {
|
|
||||||
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
|
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
|
||||||
if (!isTeam) {
|
if (!isTeam) {
|
||||||
throw new ActionForbiddenOnNonTeamWorkspace();
|
throw new ActionForbiddenOnNonTeamWorkspace();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.models.workspaceUser.set(workspaceId, userId, newRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permissions.checkWorkspace(
|
return true;
|
||||||
workspaceId,
|
|
||||||
user.id,
|
|
||||||
permission >= WorkspaceRole.Admin
|
|
||||||
? WorkspaceRole.Owner
|
|
||||||
: WorkspaceRole.Admin
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// lock to prevent concurrent invite and grant
|
|
||||||
const lockFlag = `invite:${workspaceId}`;
|
|
||||||
await using lock = await this.mutex.acquire(lockFlag);
|
|
||||||
if (!lock) {
|
|
||||||
return new TooManyRequest();
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMember = await this.permissions.isWorkspaceMember(
|
|
||||||
workspaceId,
|
|
||||||
userId
|
|
||||||
);
|
|
||||||
if (isMember) {
|
|
||||||
const result = await this.permissions.grant(
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
permission
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
if (permission === WorkspaceRole.Owner) {
|
|
||||||
this.event.emit('workspace.members.ownershipTransferred', {
|
|
||||||
workspaceId,
|
|
||||||
from: user.id,
|
|
||||||
to: userId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.event.emit('workspace.members.roleChanged', {
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
permission,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
return new MemberNotFoundInSpace({ spaceId: workspaceId });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.error('failed to invite user', e);
|
|
||||||
// pass through user friendly error
|
|
||||||
if (e instanceof UserFriendlyError) {
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
return new TooManyRequest();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ResolveField,
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import { Prisma, PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||||
|
|
||||||
import type { FileUpload } from '../../../base';
|
import type { FileUpload } from '../../../base';
|
||||||
@@ -17,10 +17,13 @@ import {
|
|||||||
AFFiNELogger,
|
AFFiNELogger,
|
||||||
AlreadyInSpace,
|
AlreadyInSpace,
|
||||||
Cache,
|
Cache,
|
||||||
|
CanNotRevokeYourself,
|
||||||
DocNotFound,
|
DocNotFound,
|
||||||
EventBus,
|
EventBus,
|
||||||
InternalServerError,
|
InternalServerError,
|
||||||
|
MemberNotFoundInSpace,
|
||||||
MemberQuotaExceeded,
|
MemberQuotaExceeded,
|
||||||
|
OwnerCanNotLeaveWorkspace,
|
||||||
QueryTooLong,
|
QueryTooLong,
|
||||||
registerObjectType,
|
registerObjectType,
|
||||||
RequestMutex,
|
RequestMutex,
|
||||||
@@ -33,10 +36,9 @@ import {
|
|||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
import { CurrentUser, Public } from '../../auth';
|
import { CurrentUser, Public } from '../../auth';
|
||||||
import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc';
|
import { type Editor } from '../../doc';
|
||||||
import {
|
import {
|
||||||
mapWorkspaceRoleToPermissions,
|
AccessController,
|
||||||
PermissionService,
|
|
||||||
WORKSPACE_ACTIONS,
|
WORKSPACE_ACTIONS,
|
||||||
WorkspaceAction,
|
WorkspaceAction,
|
||||||
WorkspaceRole,
|
WorkspaceRole,
|
||||||
@@ -56,7 +58,7 @@ export type DotToUnderline<T extends string> =
|
|||||||
? `${Prefix}_${DotToUnderline<Suffix>}`
|
? `${Prefix}_${DotToUnderline<Suffix>}`
|
||||||
: T;
|
: T;
|
||||||
|
|
||||||
export function mapPermissionToGraphqlPermissions<A extends string>(
|
export function mapPermissionsToGraphqlPermissions<A extends string>(
|
||||||
permission: Record<A, boolean>
|
permission: Record<A, boolean>
|
||||||
): Record<DotToUnderline<A>, boolean> {
|
): Record<DotToUnderline<A>, boolean> {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
@@ -126,14 +128,12 @@ export class WorkspaceRolePermissions {
|
|||||||
export class WorkspaceResolver {
|
export class WorkspaceResolver {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly cache: Cache,
|
private readonly cache: Cache,
|
||||||
private readonly prisma: PrismaClient,
|
private readonly ac: AccessController,
|
||||||
private readonly permissions: PermissionService,
|
|
||||||
private readonly quota: QuotaService,
|
private readonly quota: QuotaService,
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly event: EventBus,
|
private readonly event: EventBus,
|
||||||
private readonly mutex: RequestMutex,
|
private readonly mutex: RequestMutex,
|
||||||
private readonly workspaceService: WorkspaceService,
|
private readonly workspaceService: WorkspaceService,
|
||||||
private readonly workspaceStorage: PgWorkspaceDocStorageAdapter,
|
|
||||||
private readonly logger: AFFiNELogger
|
private readonly logger: AFFiNELogger
|
||||||
) {
|
) {
|
||||||
logger.setContext(WorkspaceResolver.name);
|
logger.setContext(WorkspaceResolver.name);
|
||||||
@@ -152,13 +152,27 @@ export class WorkspaceResolver {
|
|||||||
return workspace.role;
|
return workspace.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = await this.permissions.get(workspace.id, user.id);
|
const { role } = await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(workspace.id)
|
||||||
|
.permissions();
|
||||||
|
|
||||||
if (!role) {
|
return role ?? WorkspaceRole.External;
|
||||||
throw new SpaceAccessDenied({ spaceId: workspace.id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return role;
|
@ResolveField(() => WorkspacePermissions, {
|
||||||
|
description: 'map of action permissions',
|
||||||
|
})
|
||||||
|
async permissions(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Parent() workspace: WorkspaceType
|
||||||
|
) {
|
||||||
|
const { permissions } = await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(workspace.id)
|
||||||
|
.permissions();
|
||||||
|
|
||||||
|
return mapPermissionsToGraphqlPermissions(permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => Int, {
|
@ResolveField(() => Int, {
|
||||||
@@ -166,7 +180,7 @@ export class WorkspaceResolver {
|
|||||||
complexity: 2,
|
complexity: 2,
|
||||||
})
|
})
|
||||||
memberCount(@Parent() workspace: WorkspaceType) {
|
memberCount(@Parent() workspace: WorkspaceType) {
|
||||||
return this.permissions.getWorkspaceMemberCount(workspace.id);
|
return this.models.workspaceUser.count(workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => Boolean, {
|
@ResolveField(() => Boolean, {
|
||||||
@@ -174,14 +188,7 @@ export class WorkspaceResolver {
|
|||||||
complexity: 2,
|
complexity: 2,
|
||||||
})
|
})
|
||||||
async initialized(@Parent() workspace: WorkspaceType) {
|
async initialized(@Parent() workspace: WorkspaceType) {
|
||||||
return this.prisma.snapshot
|
return this.models.doc.exists(workspace.id, workspace.id);
|
||||||
.count({
|
|
||||||
where: {
|
|
||||||
id: workspace.id,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(count => count > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => UserType, {
|
@ResolveField(() => UserType, {
|
||||||
@@ -189,7 +196,7 @@ export class WorkspaceResolver {
|
|||||||
complexity: 2,
|
complexity: 2,
|
||||||
})
|
})
|
||||||
async owner(@Parent() workspace: WorkspaceType) {
|
async owner(@Parent() workspace: WorkspaceType) {
|
||||||
return this.permissions.getWorkspaceOwner(workspace.id);
|
return this.models.workspaceUser.getOwner(workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => [InviteUserType], {
|
@ResolveField(() => [InviteUserType], {
|
||||||
@@ -197,44 +204,48 @@ export class WorkspaceResolver {
|
|||||||
complexity: 2,
|
complexity: 2,
|
||||||
})
|
})
|
||||||
async members(
|
async members(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
@Parent() workspace: WorkspaceType,
|
@Parent() workspace: WorkspaceType,
|
||||||
@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,
|
||||||
@Args('query', { type: () => String, nullable: true }) query?: string
|
@Args('query', { type: () => String, nullable: true }) query?: string
|
||||||
) {
|
) {
|
||||||
const args: Prisma.WorkspaceUserPermissionFindManyArgs = {
|
await this.ac
|
||||||
where: { workspaceId: workspace.id },
|
.user(user.id)
|
||||||
skip,
|
.workspace(workspace.id)
|
||||||
take: take || 8,
|
.assert('Workspace.Users.Read');
|
||||||
orderBy: [{ createdAt: 'asc' }, { type: 'desc' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
if (query.length > 255) {
|
if (query.length > 255) {
|
||||||
throw new QueryTooLong({ max: 255 });
|
throw new QueryTooLong({ max: 255 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error not null
|
const list = await this.models.workspaceUser.search(workspace.id, query, {
|
||||||
args.where.user = {
|
offset: skip ?? 0,
|
||||||
// TODO(@forehalo): case-insensitive search later
|
first: take ?? 8,
|
||||||
OR: [{ name: { contains: query } }, { email: { contains: query } }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
|
||||||
...args,
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data.map(({ id, accepted, status, type, user }) => ({
|
return list.map(({ id, accepted, status, type, user }) => ({
|
||||||
...user,
|
...user,
|
||||||
permission: type,
|
permission: type,
|
||||||
inviteId: id,
|
inviteId: id,
|
||||||
accepted,
|
accepted,
|
||||||
status,
|
status,
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
const [list] = await this.models.workspaceUser.paginate(workspace.id, {
|
||||||
|
offset: skip ?? 0,
|
||||||
|
first: take ?? 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return list.map(({ id, accepted, status, type, user }) => ({
|
||||||
|
...user,
|
||||||
|
permission: type,
|
||||||
|
inviteId: id,
|
||||||
|
accepted,
|
||||||
|
status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => WorkspacePageMeta, {
|
@ResolveField(() => WorkspacePageMeta, {
|
||||||
@@ -245,15 +256,7 @@ export class WorkspaceResolver {
|
|||||||
@Parent() workspace: WorkspaceType,
|
@Parent() workspace: WorkspaceType,
|
||||||
@Args('pageId') pageId: string
|
@Args('pageId') pageId: string
|
||||||
) {
|
) {
|
||||||
const metadata = await this.prisma.snapshot.findFirst({
|
const metadata = await this.models.doc.getMeta(workspace.id, pageId);
|
||||||
where: { workspaceId: workspace.id, id: pageId },
|
|
||||||
select: {
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
createdByUser: { select: { name: true, avatarUrl: true } },
|
|
||||||
updatedByUser: { select: { name: true, avatarUrl: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
|
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
|
||||||
}
|
}
|
||||||
@@ -284,29 +287,35 @@ export class WorkspaceResolver {
|
|||||||
@Query(() => Boolean, {
|
@Query(() => Boolean, {
|
||||||
description: 'Get is owner of workspace',
|
description: 'Get is owner of workspace',
|
||||||
complexity: 2,
|
complexity: 2,
|
||||||
|
deprecationReason: 'use WorkspaceType[role] instead',
|
||||||
})
|
})
|
||||||
async isOwner(
|
async isOwner(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string
|
@Args('workspaceId') workspaceId: string
|
||||||
) {
|
) {
|
||||||
const data = await this.permissions.tryGetWorkspaceOwner(workspaceId);
|
const role = await this.models.workspaceUser.getActive(
|
||||||
|
workspaceId,
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
return data?.user?.id === user.id;
|
return role?.type === WorkspaceRole.Owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(() => Boolean, {
|
@Query(() => Boolean, {
|
||||||
description: 'Get is admin of workspace',
|
description: 'Get is admin of workspace',
|
||||||
complexity: 2,
|
complexity: 2,
|
||||||
|
deprecationReason: 'use WorkspaceType[role] instead',
|
||||||
})
|
})
|
||||||
async isAdmin(
|
async isAdmin(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string
|
@Args('workspaceId') workspaceId: string
|
||||||
) {
|
) {
|
||||||
return this.permissions.tryCheckWorkspaceIs(
|
const role = await this.models.workspaceUser.getActive(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
user.id
|
||||||
WorkspaceRole.Admin
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return role?.type === WorkspaceRole.Admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(() => [WorkspaceType], {
|
@Query(() => [WorkspaceType], {
|
||||||
@@ -314,38 +323,30 @@ export class WorkspaceResolver {
|
|||||||
complexity: 2,
|
complexity: 2,
|
||||||
})
|
})
|
||||||
async workspaces(@CurrentUser() user: CurrentUser) {
|
async workspaces(@CurrentUser() user: CurrentUser) {
|
||||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
const roles = await this.models.workspaceUser.getUserActiveRoles(user.id);
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
accepted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
workspace: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return data.map(({ workspace, type }) => {
|
const map = new Map(
|
||||||
return {
|
roles.map(({ workspaceId, type }) => [workspaceId, type])
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaces = await this.models.workspace.findMany(
|
||||||
|
roles.map(({ workspaceId }) => workspaceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return workspaces.map(workspace => ({
|
||||||
...workspace,
|
...workspace,
|
||||||
permission: type,
|
permission: map.get(workspace.id),
|
||||||
role: type,
|
role: map.get(workspace.id),
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(() => WorkspaceType, {
|
@Query(() => WorkspaceType, {
|
||||||
description: 'Get workspace by id',
|
description: 'Get workspace by id',
|
||||||
})
|
})
|
||||||
async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) {
|
async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) {
|
||||||
await this.permissions.checkWorkspace(id, user.id);
|
await this.ac.user(user.id).workspace(id).assert('Workspace.Read');
|
||||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
|
||||||
|
const workspace = await this.models.workspace.get(id);
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new SpaceNotFound({ spaceId: id });
|
throw new SpaceNotFound({ spaceId: id });
|
||||||
@@ -356,22 +357,24 @@ export class WorkspaceResolver {
|
|||||||
|
|
||||||
@Query(() => WorkspaceRolePermissions, {
|
@Query(() => WorkspaceRolePermissions, {
|
||||||
description: 'Get workspace role permissions',
|
description: 'Get workspace role permissions',
|
||||||
|
deprecationReason: 'use WorkspaceType[permissions] instead',
|
||||||
})
|
})
|
||||||
async workspaceRolePermissions(
|
async workspaceRolePermissions(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('id') id: string
|
@Args('id') id: string
|
||||||
): Promise<WorkspaceRolePermissions> {
|
): Promise<WorkspaceRolePermissions> {
|
||||||
const workspace = await this.prisma.workspaceUserPermission.findFirst({
|
const { role, permissions } = await this.ac
|
||||||
where: { workspaceId: id, userId: user.id },
|
.user(user.id)
|
||||||
});
|
.workspace(id)
|
||||||
if (!workspace) {
|
.permissions();
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
throw new SpaceAccessDenied({ spaceId: id });
|
throw new SpaceAccessDenied({ spaceId: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
role: workspace.type,
|
role,
|
||||||
permissions: mapPermissionToGraphqlPermissions(
|
permissions: mapPermissionsToGraphqlPermissions(permissions),
|
||||||
mapWorkspaceRoleToPermissions(workspace.type)
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,19 +388,7 @@ export class WorkspaceResolver {
|
|||||||
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
|
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
|
||||||
init: FileUpload | null
|
init: FileUpload | null
|
||||||
) {
|
) {
|
||||||
const workspace = await this.prisma.workspace.create({
|
const workspace = await this.models.workspace.create(user.id);
|
||||||
data: {
|
|
||||||
public: false,
|
|
||||||
permissions: {
|
|
||||||
create: {
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
userId: user.id,
|
|
||||||
accepted: true,
|
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (init) {
|
if (init) {
|
||||||
// convert stream to buffer
|
// convert stream to buffer
|
||||||
@@ -413,13 +404,12 @@ export class WorkspaceResolver {
|
|||||||
const buffer = chunks.length ? Buffer.concat(chunks) : null;
|
const buffer = chunks.length ? Buffer.concat(chunks) : null;
|
||||||
|
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
await this.prisma.snapshot.create({
|
await this.models.doc.upsert({
|
||||||
data: {
|
spaceId: workspace.id,
|
||||||
id: workspace.id,
|
docId: workspace.id,
|
||||||
workspaceId: workspace.id,
|
|
||||||
blob: buffer,
|
blob: buffer,
|
||||||
updatedAt: new Date(),
|
timestamp: Date.now(),
|
||||||
},
|
editorId: user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,14 +425,11 @@ export class WorkspaceResolver {
|
|||||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||||
{ id, ...updates }: UpdateWorkspaceInput
|
{ id, ...updates }: UpdateWorkspaceInput
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Admin);
|
await this.ac
|
||||||
|
.user(user.id)
|
||||||
return this.prisma.workspace.update({
|
.workspace(id)
|
||||||
where: {
|
.assert('Workspace.Settings.Update');
|
||||||
id,
|
return this.models.workspace.update(id, updates);
|
||||||
},
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -450,16 +437,9 @@ export class WorkspaceResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('id') id: string
|
@Args('id') id: string
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Owner);
|
await this.ac.user(user.id).workspace(id).assert('Workspace.Delete');
|
||||||
|
|
||||||
await this.prisma.workspace.delete({
|
await this.models.workspace.delete(id);
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.workspaceStorage.deleteSpace(id);
|
|
||||||
|
|
||||||
this.event.emit('workspace.deleted', { id });
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -477,11 +457,10 @@ export class WorkspaceResolver {
|
|||||||
})
|
})
|
||||||
_permission?: WorkspaceRole
|
_permission?: WorkspaceRole
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Admin
|
.assert('Workspace.Users.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// lock to prevent concurrent invite and grant
|
// lock to prevent concurrent invite and grant
|
||||||
@@ -494,53 +473,40 @@ export class WorkspaceResolver {
|
|||||||
// member limit check
|
// member limit check
|
||||||
await this.quota.checkSeat(workspaceId);
|
await this.quota.checkSeat(workspaceId);
|
||||||
|
|
||||||
let target = await this.models.user.getUserByEmail(email);
|
let user = await this.models.user.getUserByEmail(email);
|
||||||
if (target) {
|
if (user) {
|
||||||
const originRecord =
|
const role = await this.models.workspaceUser.get(workspaceId, user.id);
|
||||||
await this.prisma.workspaceUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
userId: target.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// only invite if the user is not already in the workspace
|
// only invite if the user is not already in the workspace
|
||||||
if (originRecord) return originRecord.id;
|
if (role) return role.id;
|
||||||
} else {
|
} else {
|
||||||
target = await this.models.user.create({
|
user = await this.models.user.create({
|
||||||
email,
|
email,
|
||||||
registered: false,
|
registered: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteId = await this.permissions.grant(
|
const role = await this.models.workspaceUser.set(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target.id,
|
user.id,
|
||||||
WorkspaceRole.Collaborator
|
WorkspaceRole.Collaborator
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sendInviteMail) {
|
if (sendInviteMail) {
|
||||||
try {
|
try {
|
||||||
await this.workspaceService.sendInviteEmail(inviteId);
|
await this.workspaceService.sendInviteEmail(role.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const ret = await this.permissions.revokeWorkspace(
|
await this.models.workspaceUser.delete(workspaceId, user.id);
|
||||||
workspaceId,
|
|
||||||
target.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ret) {
|
|
||||||
this.logger.fatal(
|
|
||||||
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
||||||
);
|
);
|
||||||
}
|
|
||||||
throw new InternalServerError(
|
throw new InternalServerError(
|
||||||
'Failed to send invite email. Please try again.'
|
'Failed to send invite email. Please try again.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return inviteId;
|
return role.id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// pass through user friendly error
|
// pass through user friendly error
|
||||||
if (e instanceof UserFriendlyError) {
|
if (e instanceof UserFriendlyError) {
|
||||||
@@ -563,7 +529,7 @@ export class WorkspaceResolver {
|
|||||||
const { workspaceId, inviteeUserId } =
|
const { workspaceId, inviteeUserId } =
|
||||||
await this.workspaceService.getInviteInfo(inviteId);
|
await this.workspaceService.getInviteInfo(inviteId);
|
||||||
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
|
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
|
||||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
|
|
||||||
const inviteeId = inviteeUserId || user?.id;
|
const inviteeId = inviteeUserId || user?.id;
|
||||||
if (!inviteeId) throw new UserNotFound();
|
if (!inviteeId) throw new UserNotFound();
|
||||||
@@ -578,28 +544,47 @@ export class WorkspaceResolver {
|
|||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('userId') userId: string
|
@Args('userId') userId: string
|
||||||
) {
|
) {
|
||||||
const isAdmin = await this.permissions.tryCheckWorkspaceIs(
|
if (userId === user.id) {
|
||||||
workspaceId,
|
throw new CanNotRevokeYourself();
|
||||||
userId,
|
|
||||||
WorkspaceRole.Admin
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
// only owner can revoke workspace admin
|
|
||||||
await this.permissions.checkWorkspaceIs(
|
|
||||||
workspaceId,
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Owner
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await this.permissions.checkWorkspace(
|
|
||||||
workspaceId,
|
|
||||||
user.id,
|
|
||||||
WorkspaceRole.Admin
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.permissions.revokeWorkspace(workspaceId, userId);
|
const role = await this.models.workspaceUser.get(workspaceId, userId);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(workspaceId)
|
||||||
|
.assert(
|
||||||
|
role.type === WorkspaceRole.Admin
|
||||||
|
? 'Workspace.Adminitrators.Manage'
|
||||||
|
: 'Workspace.Users.Manage'
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.models.workspaceUser.delete(workspaceId, userId);
|
||||||
|
|
||||||
|
const count = await this.models.workspaceUser.count(workspaceId);
|
||||||
|
|
||||||
|
this.event.emit('workspace.members.updated', {
|
||||||
|
workspaceId,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (role.status === WorkspaceMemberStatus.UnderReview) {
|
||||||
|
this.event.emit('workspace.members.requestDeclined', {
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.event.emit('workspace.members.removed', {
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -617,11 +602,12 @@ export class WorkspaceResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const status = await this.permissions.getWorkspaceMemberStatus(
|
const role = await this.models.workspaceUser.getActive(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
if (status === WorkspaceMemberStatus.Accepted) {
|
|
||||||
|
if (role) {
|
||||||
throw new AlreadyInSpace({ spaceId: workspaceId });
|
throw new AlreadyInSpace({ spaceId: workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,42 +616,39 @@ export class WorkspaceResolver {
|
|||||||
`workspace:inviteLink:${workspaceId}`
|
`workspace:inviteLink:${workspaceId}`
|
||||||
);
|
);
|
||||||
if (invite?.inviteId === inviteId) {
|
if (invite?.inviteId === inviteId) {
|
||||||
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
|
|
||||||
const seatAvailable = await this.quota.tryCheckSeat(workspaceId);
|
const seatAvailable = await this.quota.tryCheckSeat(workspaceId);
|
||||||
if (!seatAvailable) {
|
if (seatAvailable) {
|
||||||
|
const invite = await this.models.workspaceUser.set(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
WorkspaceRole.Collaborator,
|
||||||
|
WorkspaceMemberStatus.UnderReview
|
||||||
|
);
|
||||||
|
this.event.emit('workspace.members.reviewRequested', {
|
||||||
|
inviteId: invite.id,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const isTeam =
|
||||||
|
await this.workspaceService.isTeamWorkspace(workspaceId);
|
||||||
// only team workspace allow over limit
|
// only team workspace allow over limit
|
||||||
if (isTeam) {
|
if (isTeam) {
|
||||||
await this.permissions.grant(
|
await this.models.workspaceUser.set(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
user.id,
|
||||||
WorkspaceRole.Collaborator,
|
WorkspaceRole.Collaborator,
|
||||||
WorkspaceMemberStatus.NeedMoreSeatAndReview
|
WorkspaceMemberStatus.NeedMoreSeatAndReview
|
||||||
);
|
);
|
||||||
const memberCount =
|
const memberCount =
|
||||||
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
await this.models.workspaceUser.count(workspaceId);
|
||||||
this.event.emit('workspace.members.updated', {
|
this.event.emit('workspace.members.updated', {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
count: memberCount,
|
count: memberCount,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} else if (!status) {
|
} else {
|
||||||
throw new MemberQuotaExceeded();
|
throw new MemberQuotaExceeded();
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const inviteId = await this.permissions.grant(workspaceId, user.id);
|
|
||||||
if (isTeam) {
|
|
||||||
this.event.emit('workspace.members.reviewRequested', {
|
|
||||||
inviteId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// invite by link need admin to approve
|
|
||||||
return await this.permissions.acceptWorkspaceInvitation(
|
|
||||||
inviteId,
|
|
||||||
workspaceId,
|
|
||||||
isTeam
|
|
||||||
? WorkspaceMemberStatus.UnderReview
|
|
||||||
: WorkspaceMemberStatus.Accepted
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -675,10 +658,8 @@ export class WorkspaceResolver {
|
|||||||
if (!success) throw new UserNotFound();
|
if (!success) throw new UserNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.permissions.acceptWorkspaceInvitation(
|
await this.models.workspaceUser.accept(inviteId);
|
||||||
inviteId,
|
return true;
|
||||||
workspaceId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -692,8 +673,19 @@ export class WorkspaceResolver {
|
|||||||
})
|
})
|
||||||
_workspaceName?: string
|
_workspaceName?: string
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
const role = await this.models.workspaceUser.getActive(
|
||||||
const success = this.permissions.revokeWorkspace(workspaceId, user.id);
|
workspaceId,
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
if (!role) {
|
||||||
|
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.type === WorkspaceRole.Owner) {
|
||||||
|
throw new OwnerCanNotLeaveWorkspace();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.models.workspaceUser.delete(workspaceId, user.id);
|
||||||
|
|
||||||
if (sendLeaveMail) {
|
if (sendLeaveMail) {
|
||||||
this.event.emit('workspace.members.leave', {
|
this.event.emit('workspace.members.leave', {
|
||||||
@@ -705,6 +697,6 @@ export class WorkspaceResolver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return success;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
|||||||
export class MigrateInviteStatus1732861452428 {
|
export class MigrateInviteStatus1732861452428 {
|
||||||
// do the migration
|
// do the migration
|
||||||
static async up(db: PrismaClient) {
|
static async up(db: PrismaClient) {
|
||||||
await db.workspaceUserPermission.updateMany({
|
await db.workspaceUserRole.updateMany({
|
||||||
where: {
|
where: {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,3 +12,8 @@ export interface Doc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DocEditor = Pick<User, 'id' | 'name' | 'avatarUrl'>;
|
export type DocEditor = Pick<User, 'id' | 'name' | 'avatarUrl'>;
|
||||||
|
|
||||||
|
export enum PublicDocMode {
|
||||||
|
Page,
|
||||||
|
Edgeless,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from './doc';
|
export * from './doc';
|
||||||
export * from './feature';
|
export * from './feature';
|
||||||
export * from './page';
|
export * from './role';
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export enum PublicPageMode {
|
|
||||||
Page,
|
|
||||||
Edgeless,
|
|
||||||
}
|
|
||||||
14
packages/backend/server/src/models/common/role.ts
Normal file
14
packages/backend/server/src/models/common/role.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export enum DocRole {
|
||||||
|
External = 0,
|
||||||
|
Reader = 10,
|
||||||
|
Editor = 20,
|
||||||
|
Manager = 30,
|
||||||
|
Owner = 99,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum WorkspaceRole {
|
||||||
|
External = -99,
|
||||||
|
Collaborator = 1,
|
||||||
|
Admin = 10,
|
||||||
|
Owner = 99,
|
||||||
|
}
|
||||||
203
packages/backend/server/src/models/doc-user.ts
Normal file
203
packages/backend/server/src/models/doc-user.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Transactional } from '@nestjs-cls/transactional';
|
||||||
|
import { WorkspaceDocUserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
|
||||||
|
import { BaseModel } from './base';
|
||||||
|
import { DocRole } from './common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocUserModel extends BaseModel {
|
||||||
|
/**
|
||||||
|
* Set or update the [Owner] of a doc.
|
||||||
|
* The old [Owner] will be changed to [Manager] if there is already an [Owner].
|
||||||
|
*/
|
||||||
|
@Transactional()
|
||||||
|
async setOwner(workspaceId: string, docId: string, userId: string) {
|
||||||
|
const oldOwner = await this.db.workspaceDocUserRole.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
type: DocRole.Owner,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oldOwner) {
|
||||||
|
await this.db.workspaceDocUserRole.update({
|
||||||
|
where: {
|
||||||
|
workspaceId_docId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId: oldOwner.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: DocRole.Manager,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.workspaceDocUserRole.upsert({
|
||||||
|
where: {
|
||||||
|
workspaceId_docId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
type: DocRole.Owner,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId,
|
||||||
|
type: DocRole.Owner,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oldOwner) {
|
||||||
|
this.logger.log(
|
||||||
|
`Transfer doc owner of [${workspaceId}/${docId}] from [${oldOwner.userId}] to [${userId}]`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.log(
|
||||||
|
`Set doc owner of [${workspaceId}/${docId}] to [${userId}]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or update the Role of a user in a doc.
|
||||||
|
*
|
||||||
|
* NOTE: do not use this method to set the [Owner] of a doc. Use {@link setOwner} instead.
|
||||||
|
*/
|
||||||
|
@Transactional()
|
||||||
|
async set(workspaceId: string, docId: string, userId: string, role: DocRole) {
|
||||||
|
// internal misuse, throw directly
|
||||||
|
assert(role !== DocRole.Owner, 'Cannot set Owner role of a doc to a user.');
|
||||||
|
|
||||||
|
const oldRole = await this.get(workspaceId, docId, userId);
|
||||||
|
|
||||||
|
if (oldRole && oldRole.type === role) {
|
||||||
|
return oldRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRole = await this.db.workspaceDocUserRole.upsert({
|
||||||
|
where: {
|
||||||
|
workspaceId_docId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
type: role,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId,
|
||||||
|
type: role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return newRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchSetUserRoles(
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string,
|
||||||
|
userIds: string[],
|
||||||
|
role: DocRole
|
||||||
|
) {
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === DocRole.Owner) {
|
||||||
|
throw new CanNotBatchGrantDocOwnerPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.db.workspaceDocUserRole.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
data: userIds.map(userId => ({
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId,
|
||||||
|
type: role,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(workspaceId: string, docId: string, userId: string) {
|
||||||
|
await this.db.workspaceDocUserRole.deleteMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOwner(workspaceId: string, docId: string) {
|
||||||
|
return await this.db.workspaceDocUserRole.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
type: DocRole.Owner,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(workspaceId: string, docId: string, userId: string) {
|
||||||
|
return await this.db.workspaceDocUserRole.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_docId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
count(workspaceId: string, docId: string) {
|
||||||
|
return this.db.workspaceDocUserRole.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async paginate(
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string,
|
||||||
|
pagination: PaginationInput
|
||||||
|
): Promise<[WorkspaceDocUserRole[], number]> {
|
||||||
|
return await Promise.all([
|
||||||
|
this.db.workspaceDocUserRole.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
createdAt: pagination.after
|
||||||
|
? {
|
||||||
|
gte: pagination.after,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
take: pagination.first,
|
||||||
|
skip: pagination.offset + (pagination.after ? 1 : 0),
|
||||||
|
}),
|
||||||
|
this.count(workspaceId, docId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core';
|
|||||||
|
|
||||||
import { ApplyType } from '../base';
|
import { ApplyType } from '../base';
|
||||||
import { DocModel } from './doc';
|
import { DocModel } from './doc';
|
||||||
|
import { DocUserModel } from './doc-user';
|
||||||
import { FeatureModel } from './feature';
|
import { FeatureModel } from './feature';
|
||||||
import { PageModel } from './page';
|
import { PageModel } from './page';
|
||||||
import { MODELS_SYMBOL } from './provider';
|
import { MODELS_SYMBOL } from './provider';
|
||||||
@@ -18,6 +19,7 @@ import { UserFeatureModel } from './user-feature';
|
|||||||
import { VerificationTokenModel } from './verification-token';
|
import { VerificationTokenModel } from './verification-token';
|
||||||
import { WorkspaceModel } from './workspace';
|
import { WorkspaceModel } from './workspace';
|
||||||
import { WorkspaceFeatureModel } from './workspace-feature';
|
import { WorkspaceFeatureModel } from './workspace-feature';
|
||||||
|
import { WorkspaceUserModel } from './workspace-user';
|
||||||
|
|
||||||
const MODELS = {
|
const MODELS = {
|
||||||
user: UserModel,
|
user: UserModel,
|
||||||
@@ -30,6 +32,8 @@ const MODELS = {
|
|||||||
workspaceFeature: WorkspaceFeatureModel,
|
workspaceFeature: WorkspaceFeatureModel,
|
||||||
doc: DocModel,
|
doc: DocModel,
|
||||||
userDoc: UserDocModel,
|
userDoc: UserDocModel,
|
||||||
|
workspaceUser: WorkspaceUserModel,
|
||||||
|
docUser: DocUserModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModelsType = {
|
type ModelsType = {
|
||||||
@@ -83,6 +87,7 @@ export class ModelsModule {}
|
|||||||
|
|
||||||
export * from './common';
|
export * from './common';
|
||||||
export * from './doc';
|
export * from './doc';
|
||||||
|
export * from './doc-user';
|
||||||
export * from './feature';
|
export * from './feature';
|
||||||
export * from './page';
|
export * from './page';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
@@ -92,3 +97,4 @@ export * from './user-feature';
|
|||||||
export * from './verification-token';
|
export * from './verification-token';
|
||||||
export * from './workspace';
|
export * from './workspace';
|
||||||
export * from './workspace-feature';
|
export * from './workspace-feature';
|
||||||
|
export * from './workspace-user';
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Transactional } from '@nestjs-cls/transactional';
|
import { type WorkspaceDoc as Page } from '@prisma/client';
|
||||||
import {
|
|
||||||
type WorkspaceDoc as Page,
|
|
||||||
type WorkspaceDocUserPermission as PageUserPermission,
|
|
||||||
} from '@prisma/client';
|
|
||||||
|
|
||||||
import { WorkspaceRole } from '../core/permission';
|
|
||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
import { PublicPageMode } from './common';
|
import { PublicDocMode } from './common';
|
||||||
export type { Page };
|
export type { Page };
|
||||||
export type UpdatePageInput = {
|
export type UpdatePageInput = {
|
||||||
mode?: PublicPageMode;
|
mode?: PublicDocMode;
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,118 +77,4 @@ export class PageModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region page member and permission
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grant the page member with the given permission.
|
|
||||||
*/
|
|
||||||
@Transactional()
|
|
||||||
async grantMember(
|
|
||||||
workspaceId: string,
|
|
||||||
docId: string,
|
|
||||||
userId: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
): Promise<PageUserPermission> {
|
|
||||||
let data = await this.db.workspaceDocUserPermission.findUnique({
|
|
||||||
where: {
|
|
||||||
workspaceId_docId_userId: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// If the user is already accepted and the new permission is owner, we need to revoke old owner
|
|
||||||
if (!data || data.type !== permission) {
|
|
||||||
if (data) {
|
|
||||||
// Update the permission
|
|
||||||
data = await this.db.workspaceDocUserPermission.update({
|
|
||||||
where: {
|
|
||||||
workspaceId_docId_userId: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: { type: permission },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Create a new permission
|
|
||||||
data = await this.db.workspaceDocUserPermission.create({
|
|
||||||
data: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
userId,
|
|
||||||
type: permission,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the new permission is owner, we need to revoke old owner
|
|
||||||
if (permission === WorkspaceRole.Owner) {
|
|
||||||
await this.db.workspaceDocUserPermission.updateMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
userId: { not: userId },
|
|
||||||
},
|
|
||||||
data: { type: WorkspaceRole.Admin },
|
|
||||||
});
|
|
||||||
this.logger.log(
|
|
||||||
`Change owner of workspace ${workspaceId} doc ${docId} to user ${userId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// nothing to do
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether a given user is a member of a workspace and has the given or higher permission.
|
|
||||||
* Default to read permission.
|
|
||||||
*/
|
|
||||||
async isMember(
|
|
||||||
workspaceId: string,
|
|
||||||
docId: string,
|
|
||||||
userId: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
) {
|
|
||||||
const count = await this.db.workspaceDocUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
userId,
|
|
||||||
type: {
|
|
||||||
gte: permission,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a page member
|
|
||||||
* Except the owner, the owner can't be deleted.
|
|
||||||
*/
|
|
||||||
async deleteMember(workspaceId: string, docId: string, userId: string) {
|
|
||||||
const { count } = await this.db.workspaceDocUserPermission.deleteMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
userId,
|
|
||||||
type: {
|
|
||||||
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
|
|
||||||
not: WorkspaceRole.Owner,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
WrongSignInMethod,
|
WrongSignInMethod,
|
||||||
} from '../base';
|
} from '../base';
|
||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
|
import { WorkspaceRole } from './common';
|
||||||
import type { Workspace } from './workspace';
|
import type { Workspace } from './workspace';
|
||||||
|
|
||||||
const publicUserSelect = {
|
const publicUserSelect = {
|
||||||
@@ -215,12 +216,17 @@ export class UserModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
const ownedWorkspaceIds = await this.models.workspace.findOwnedIds(id);
|
const ownedWorkspaces = await this.models.workspaceUser.getUserActiveRoles(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
role: WorkspaceRole.Owner,
|
||||||
|
}
|
||||||
|
);
|
||||||
const user = await this.db.user.delete({ where: { id } });
|
const user = await this.db.user.delete({ where: { id } });
|
||||||
|
|
||||||
this.event.emit('user.deleted', {
|
this.event.emit('user.deleted', {
|
||||||
...user,
|
...user,
|
||||||
ownedWorkspaces: ownedWorkspaceIds,
|
ownedWorkspaces: ownedWorkspaces.map(r => r.workspaceId),
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
385
packages/backend/server/src/models/workspace-user.ts
Normal file
385
packages/backend/server/src/models/workspace-user.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Transactional } from '@nestjs-cls/transactional';
|
||||||
|
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||||
|
import { groupBy } from 'lodash-es';
|
||||||
|
|
||||||
|
import { EventBus, PaginationInput } from '../base';
|
||||||
|
import { BaseModel } from './base';
|
||||||
|
import { WorkspaceRole } from './common';
|
||||||
|
|
||||||
|
export { WorkspaceMemberStatus };
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Events {
|
||||||
|
'workspace.owner.changed': {
|
||||||
|
workspaceId: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
'workspace.members.roleChanged': {
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
role: WorkspaceRole;
|
||||||
|
};
|
||||||
|
// below are business events, should be declare somewhere else
|
||||||
|
'workspace.members.updated': {
|
||||||
|
workspaceId: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
'workspace.members.reviewRequested': {
|
||||||
|
inviteId: string;
|
||||||
|
};
|
||||||
|
'workspace.members.requestApproved': {
|
||||||
|
inviteId: string;
|
||||||
|
};
|
||||||
|
'workspace.members.requestDeclined': {
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
'workspace.members.removed': {
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
'workspace.members.leave': {
|
||||||
|
workspaceId: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceUserModel extends BaseModel {
|
||||||
|
constructor(private readonly event: EventBus) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or update the [Owner] of a workspace.
|
||||||
|
* The old [Owner] will be changed to [Admin] if there is already an [Owner].
|
||||||
|
*/
|
||||||
|
@Transactional()
|
||||||
|
async setOwner(workspaceId: string, userId: string) {
|
||||||
|
const oldOwner = await this.db.workspaceUserRole.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
type: WorkspaceRole.Owner,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there is already an owner, we need to change the old owner to admin
|
||||||
|
if (oldOwner) {
|
||||||
|
await this.db.workspaceUserRole.update({
|
||||||
|
where: {
|
||||||
|
id: oldOwner.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: WorkspaceRole.Admin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.workspaceUserRole.upsert({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
type: WorkspaceRole.Owner,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
type: WorkspaceRole.Owner,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oldOwner) {
|
||||||
|
this.event.emit('workspace.owner.changed', {
|
||||||
|
workspaceId,
|
||||||
|
from: oldOwner.userId,
|
||||||
|
to: userId,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Transfer workspace owner of [${workspaceId}] from [${oldOwner.userId}] to [${userId}]`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`Set workspace owner of [${workspaceId}] to [${userId}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or update the Role of a user in a workspace.
|
||||||
|
*
|
||||||
|
* NOTE: do not use this method to set the [Owner] of a workspace. Use {@link setOwner} instead.
|
||||||
|
*/
|
||||||
|
@Transactional()
|
||||||
|
async set(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
role: WorkspaceRole,
|
||||||
|
defaultStatus: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
|
||||||
|
) {
|
||||||
|
if (role === WorkspaceRole.Owner) {
|
||||||
|
throw new Error('Cannot grant Owner role of a workspace to a user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldRole = await this.get(workspaceId, userId);
|
||||||
|
|
||||||
|
if (oldRole) {
|
||||||
|
if (oldRole.type === role) {
|
||||||
|
return oldRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRole = await this.db.workspaceUserRole.update({
|
||||||
|
where: { id: oldRole.id },
|
||||||
|
data: { type: role },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oldRole.status === WorkspaceMemberStatus.Accepted) {
|
||||||
|
this.event.emit('workspace.members.roleChanged', {
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
role: newRole.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRole;
|
||||||
|
} else {
|
||||||
|
return await this.db.workspaceUserRole.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
type: role,
|
||||||
|
status: defaultStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
status: WorkspaceMemberStatus
|
||||||
|
) {
|
||||||
|
return await this.db.workspaceUserRole.update({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async accept(id: string) {
|
||||||
|
await this.db.workspaceUserRole.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: WorkspaceMemberStatus.Accepted },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(workspaceId: string, userId: string) {
|
||||||
|
await this.db.workspaceUserRole.deleteMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(workspaceId: string, userId: string) {
|
||||||
|
return await this.db.workspaceUserRole.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string) {
|
||||||
|
return await this.db.workspaceUserRole.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the **accepted** Role of a user in a workspace.
|
||||||
|
*/
|
||||||
|
async getActive(workspaceId: string, userId: string) {
|
||||||
|
return await this.db.workspaceUserRole.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: { workspaceId, userId },
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOwner(workspaceId: string) {
|
||||||
|
const role = await this.db.workspaceUserRole.findFirst({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
type: WorkspaceRole.Owner,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new Error('Workspace owner not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return role.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdmins(workspaceId: string) {
|
||||||
|
const list = await this.db.workspaceUserRole.findMany({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
type: WorkspaceRole.Admin,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return list.map(l => l.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(workspaceId: string) {
|
||||||
|
return this.db.workspaceUserRole.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserActiveRoles(
|
||||||
|
userId: string,
|
||||||
|
filter: { role?: WorkspaceRole } = {}
|
||||||
|
) {
|
||||||
|
return await this.db.workspaceUserRole.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
type: filter.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async paginate(workspaceId: string, pagination: PaginationInput) {
|
||||||
|
return await Promise.all([
|
||||||
|
this.db.workspaceUserRole.findMany({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
createdAt: pagination.after
|
||||||
|
? {
|
||||||
|
gte: pagination.after,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
take: pagination.first,
|
||||||
|
skip: pagination.offset + (pagination.after ? 1 : 0),
|
||||||
|
}),
|
||||||
|
this.count(workspaceId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(
|
||||||
|
workspaceId: string,
|
||||||
|
query: string,
|
||||||
|
pagination: PaginationInput
|
||||||
|
) {
|
||||||
|
return await this.db.workspaceUserRole.findMany({
|
||||||
|
include: { user: true },
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
user: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
email: {
|
||||||
|
contains: query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
contains: query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: pagination.first,
|
||||||
|
skip: pagination.offset + (pagination.after ? 1 : 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional()
|
||||||
|
async refresh(workspaceId: string, memberLimit: number) {
|
||||||
|
const usedCount = await this.db.workspaceUserRole.count({
|
||||||
|
where: { workspaceId, status: WorkspaceMemberStatus.Accepted },
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableCount = memberLimit - usedCount;
|
||||||
|
|
||||||
|
if (availableCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await this.db.workspaceUserRole.findMany({
|
||||||
|
select: { id: true, status: true },
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeat,
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const needChange = members.slice(0, availableCount);
|
||||||
|
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
|
||||||
|
needChange,
|
||||||
|
m => m.status
|
||||||
|
);
|
||||||
|
|
||||||
|
const toPendings = NeedMoreSeat ?? [];
|
||||||
|
if (toPendings.length > 0) {
|
||||||
|
await this.db.workspaceUserRole.updateMany({
|
||||||
|
where: { id: { in: toPendings.map(m => m.id) } },
|
||||||
|
data: { status: WorkspaceMemberStatus.Pending },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUnderReviewUserIds = NeedMoreSeatAndReview ?? [];
|
||||||
|
if (toUnderReviewUserIds.length > 0) {
|
||||||
|
await this.db.workspaceUserRole.updateMany({
|
||||||
|
where: { id: { in: toUnderReviewUserIds.map(m => m.id) } },
|
||||||
|
data: { status: WorkspaceMemberStatus.UnderReview },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,78 +1,25 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Transactional } from '@nestjs-cls/transactional';
|
import { Transactional } from '@nestjs-cls/transactional';
|
||||||
import {
|
import { type Workspace } from '@prisma/client';
|
||||||
type Workspace,
|
|
||||||
WorkspaceMemberStatus,
|
|
||||||
type WorkspaceUserPermission,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { groupBy } from 'lodash-es';
|
|
||||||
|
|
||||||
import { EventBus } from '../base';
|
import { DocIsNotPublic, EventBus } from '../base';
|
||||||
import { WorkspaceRole } from '../core/permission';
|
|
||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
|
import { DocRole, PublicDocMode } from './common';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Events {
|
interface Events {
|
||||||
'workspace.members.reviewRequested': { inviteId: string };
|
|
||||||
'workspace.members.requestDeclined': {
|
|
||||||
userId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
};
|
|
||||||
'workspace.members.requestApproved': { inviteId: string };
|
|
||||||
'workspace.members.roleChanged': {
|
|
||||||
userId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
permission: number;
|
|
||||||
};
|
|
||||||
'workspace.members.ownershipTransferred': {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
workspaceId: string;
|
|
||||||
};
|
|
||||||
'workspace.members.updated': {
|
|
||||||
workspaceId: string;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
'workspace.members.leave': {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
workspaceId: string;
|
|
||||||
};
|
|
||||||
'workspace.members.removed': {
|
|
||||||
workspaceId: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
'workspace.deleted': {
|
'workspace.deleted': {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
'workspace.blob.delete': {
|
|
||||||
workspaceId: string;
|
|
||||||
key: string;
|
|
||||||
};
|
|
||||||
'workspace.blob.sync': {
|
|
||||||
workspaceId: string;
|
|
||||||
key: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { WorkspaceMemberStatus };
|
|
||||||
export type { Workspace };
|
export type { Workspace };
|
||||||
export type UpdateWorkspaceInput = Pick<
|
export type UpdateWorkspaceInput = Pick<
|
||||||
Partial<Workspace>,
|
Partial<Workspace>,
|
||||||
'public' | 'enableAi' | 'enableUrlPreview'
|
'public' | 'enableAi' | 'enableUrlPreview'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface FindWorkspaceMembersOptions {
|
|
||||||
skip?: number;
|
|
||||||
/**
|
|
||||||
* Default to `8`
|
|
||||||
*/
|
|
||||||
take?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceModel extends BaseModel {
|
export class WorkspaceModel extends BaseModel {
|
||||||
constructor(private readonly event: EventBus) {
|
constructor(private readonly event: EventBus) {
|
||||||
@@ -80,25 +27,16 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// #region workspace
|
// #region workspace
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new workspace for the user, default to private.
|
* Create a new workspace for the user, default to private.
|
||||||
*/
|
*/
|
||||||
|
@Transactional()
|
||||||
async create(userId: string) {
|
async create(userId: string) {
|
||||||
const workspace = await this.db.workspace.create({
|
const workspace = await this.db.workspace.create({
|
||||||
data: {
|
data: { public: false },
|
||||||
public: false,
|
|
||||||
permissions: {
|
|
||||||
create: {
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
userId: userId,
|
|
||||||
accepted: true,
|
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
this.logger.log(`Created workspace ${workspace.id} for user ${userId}`);
|
this.logger.log(`Workspace created with id ${workspace.id}`);
|
||||||
|
await this.models.workspaceUser.setOwner(workspace.id, userId);
|
||||||
return workspace;
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +44,7 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
* Update the workspace with the given data.
|
* Update the workspace with the given data.
|
||||||
*/
|
*/
|
||||||
async update(workspaceId: string, data: UpdateWorkspaceInput) {
|
async update(workspaceId: string, data: UpdateWorkspaceInput) {
|
||||||
await this.db.workspace.update({
|
const workspace = await this.db.workspace.update({
|
||||||
where: {
|
where: {
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
},
|
},
|
||||||
@@ -115,6 +53,7 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
|
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
|
||||||
);
|
);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(workspaceId: string) {
|
async get(workspaceId: string) {
|
||||||
@@ -125,422 +64,104 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findMany(ids: string[]) {
|
||||||
|
return await this.db.workspace.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async delete(workspaceId: string) {
|
async delete(workspaceId: string) {
|
||||||
await this.db.workspace.deleteMany({
|
const rawResult = await this.db.workspace.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.logger.log(`Deleted workspace ${workspaceId}`);
|
|
||||||
|
if (rawResult.count > 0) {
|
||||||
|
this.event.emit('workspace.deleted', { id: workspaceId });
|
||||||
|
this.logger.log(`Workspace [${workspaceId}] deleted`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async allowUrlPreview(workspaceId: string) {
|
||||||
* Find the workspace ids that the user is a member of owner.
|
const workspace = await this.get(workspaceId);
|
||||||
*/
|
return workspace?.enableUrlPreview ?? false;
|
||||||
async findOwnedIds(userId: string) {
|
|
||||||
const rows = await this.db.workspaceUserPermission.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
workspaceId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return rows.map(row => row.workspaceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the accessible workspaces for the user.
|
|
||||||
*/
|
|
||||||
async findAccessibleWorkspaces(userId: string) {
|
|
||||||
return await this.db.workspaceUserPermission.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
workspace: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region workspace member and permission
|
// #region doc
|
||||||
|
async getDoc(workspaceId: string, docId: string) {
|
||||||
/**
|
return await this.db.workspaceDoc.findUnique({
|
||||||
* Grant the workspace member with the given permission and status.
|
|
||||||
*/
|
|
||||||
@Transactional()
|
|
||||||
async grantMember(
|
|
||||||
workspaceId: string,
|
|
||||||
userId: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator,
|
|
||||||
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
|
|
||||||
): Promise<WorkspaceUserPermission> {
|
|
||||||
const data = await this.db.workspaceUserPermission.findUnique({
|
|
||||||
where: {
|
where: {
|
||||||
workspaceId_userId: {
|
workspaceId_docId: { workspaceId, docId },
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
// Create a new permission
|
|
||||||
const created = await this.db.workspaceUserPermission.create({
|
|
||||||
data: {
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
type: permission,
|
|
||||||
status:
|
|
||||||
permission === WorkspaceRole.Owner
|
|
||||||
? WorkspaceMemberStatus.Accepted
|
|
||||||
: status,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log(
|
|
||||||
`Granted workspace ${workspaceId} member ${userId} with permission ${WorkspaceRole[permission]}`
|
|
||||||
);
|
|
||||||
await this.notifyMembersUpdated(workspaceId);
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user is already accepted and the new permission is owner, we need to revoke old owner
|
|
||||||
if (data.status === WorkspaceMemberStatus.Accepted || data.accepted) {
|
|
||||||
const updated = await this.db.workspaceUserPermission.update({
|
|
||||||
where: {
|
|
||||||
workspaceId_userId: { workspaceId, userId },
|
|
||||||
},
|
|
||||||
data: { type: permission },
|
|
||||||
});
|
|
||||||
// If the new permission is owner, we need to revoke old owner
|
|
||||||
if (permission === WorkspaceRole.Owner) {
|
|
||||||
await this.db.workspaceUserPermission.updateMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
userId: { not: userId },
|
|
||||||
},
|
|
||||||
data: { type: WorkspaceRole.Admin },
|
|
||||||
});
|
|
||||||
this.logger.log(
|
|
||||||
`Change owner of workspace ${workspaceId} to ${userId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user is not accepted, we can update the status directly
|
|
||||||
const allowedStatus = this.getAllowedStatusSource(data.status);
|
|
||||||
if (allowedStatus.includes(status)) {
|
|
||||||
const updated = await this.db.workspaceUserPermission.update({
|
|
||||||
where: { workspaceId_userId: { workspaceId, userId } },
|
|
||||||
data: {
|
|
||||||
status,
|
|
||||||
// TODO(fengmk2): should we update the permission here?
|
|
||||||
// type: permission,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// nothing to do
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the workspace member invitation.
|
|
||||||
*/
|
|
||||||
async getMemberInvitation(invitationId: string) {
|
|
||||||
return await this.db.workspaceUserPermission.findUnique({
|
|
||||||
where: {
|
|
||||||
id: invitationId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async isPublicPage(workspaceId: string, docId: string) {
|
||||||
* Accept the workspace member invitation.
|
const doc = await this.getDoc(workspaceId, docId);
|
||||||
* @param status: the status to update to, default to `Accepted`. Can be `Accepted` or `UnderReview`.
|
if (doc?.public) {
|
||||||
*/
|
|
||||||
async acceptMemberInvitation(
|
|
||||||
invitationId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted
|
|
||||||
) {
|
|
||||||
const { count } = await this.db.workspaceUserPermission.updateMany({
|
|
||||||
where: {
|
|
||||||
id: invitationId,
|
|
||||||
workspaceId: workspaceId,
|
|
||||||
// TODO(fengmk2): should we check the status here?
|
|
||||||
AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }],
|
|
||||||
},
|
|
||||||
data: { accepted: true, status },
|
|
||||||
});
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a workspace member in accepted status by workspace id and user id.
|
|
||||||
*/
|
|
||||||
async getMember(workspaceId: string, userId: string) {
|
|
||||||
return await this.db.workspaceUserPermission.findUnique({
|
|
||||||
where: {
|
|
||||||
workspaceId_userId: {
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a workspace member in any status by workspace id and user id.
|
|
||||||
*/
|
|
||||||
async getMemberInAnyStatus(workspaceId: string, userId: string) {
|
|
||||||
return await this.db.workspaceUserPermission.findUnique({
|
|
||||||
where: {
|
|
||||||
workspaceId_userId: {
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether a given user is a member of a workspace and has the given or higher permission.
|
|
||||||
* Default to read permission.
|
|
||||||
*/
|
|
||||||
async isMember(
|
|
||||||
workspaceId: string,
|
|
||||||
userId: string,
|
|
||||||
permission: WorkspaceRole = WorkspaceRole.Collaborator
|
|
||||||
) {
|
|
||||||
const count = await this.db.workspaceUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
type: {
|
|
||||||
gte: permission,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the workspace owner.
|
|
||||||
*/
|
|
||||||
async getOwner(workspaceId: string) {
|
|
||||||
return await this.db.workspaceUserPermission.findFirst({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
type: WorkspaceRole.Owner,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the workspace admins.
|
|
||||||
*/
|
|
||||||
async findAdmins(workspaceId: string) {
|
|
||||||
return await this.db.workspaceUserPermission.findMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
type: WorkspaceRole.Admin,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the workspace members.
|
|
||||||
*/
|
|
||||||
async findMembers(
|
|
||||||
workspaceId: string,
|
|
||||||
options: FindWorkspaceMembersOptions = {}
|
|
||||||
) {
|
|
||||||
return await this.db.workspaceUserPermission.findMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
skip: options.skip,
|
|
||||||
take: options.take || 8,
|
|
||||||
orderBy: [{ createdAt: 'asc' }, { type: 'desc' }],
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a workspace member by workspace id and user id.
|
|
||||||
* Except the owner, the owner can't be deleted.
|
|
||||||
*/
|
|
||||||
async deleteMember(workspaceId: string, userId: string) {
|
|
||||||
const member = await this.getMemberInAnyStatus(workspaceId, userId);
|
|
||||||
|
|
||||||
// We shouldn't revoke owner permission
|
|
||||||
// should auto deleted by workspace/user delete cascading
|
|
||||||
if (!member || member.type === WorkspaceRole.Owner) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.workspaceUserPermission.deleteMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log(
|
|
||||||
`Deleted workspace member ${userId} from workspace ${workspaceId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.notifyMembersUpdated(workspaceId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
member.status === WorkspaceMemberStatus.UnderReview ||
|
|
||||||
member.status === WorkspaceMemberStatus.NeedMoreSeatAndReview
|
|
||||||
) {
|
|
||||||
this.event.emit('workspace.members.requestDeclined', {
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyMembersUpdated(workspaceId: string) {
|
const workspace = await this.get(workspaceId);
|
||||||
const count = await this.getMemberTotalCount(workspaceId);
|
return workspace?.public ?? false;
|
||||||
this.event.emit('workspace.members.updated', {
|
}
|
||||||
workspaceId,
|
|
||||||
count,
|
async publishDoc(
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string,
|
||||||
|
mode: PublicDocMode = PublicDocMode.Page
|
||||||
|
) {
|
||||||
|
return await this.db.workspaceDoc.upsert({
|
||||||
|
where: { workspaceId_docId: { workspaceId, docId } },
|
||||||
|
update: { public: true, mode },
|
||||||
|
create: { workspaceId, docId, public: true, mode },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the workspace member total count, including pending and accepted.
|
|
||||||
*/
|
|
||||||
async getMemberTotalCount(workspaceId: string) {
|
|
||||||
return await this.db.workspaceUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the workspace member used count, only count the accepted member
|
|
||||||
*/
|
|
||||||
async getMemberUsedCount(workspaceId: string) {
|
|
||||||
return await this.db.workspaceUserPermission.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
OR: this.acceptedCondition,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh the workspace member seat status.
|
|
||||||
*/
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async refreshMemberSeatStatus(workspaceId: string, memberLimit: number) {
|
async revokePublicDoc(workspaceId: string, docId: string) {
|
||||||
const usedCount = await this.getMemberUsedCount(workspaceId);
|
const doc = await this.getDoc(workspaceId, docId);
|
||||||
const availableCount = memberLimit - usedCount;
|
|
||||||
if (availableCount <= 0) {
|
if (!doc?.public) {
|
||||||
return;
|
throw new DocIsNotPublic();
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await this.db.workspaceUserPermission.findMany({
|
return await this.db.workspaceDoc.update({
|
||||||
select: { id: true, status: true },
|
where: { workspaceId_docId: { workspaceId, docId } },
|
||||||
|
data: { public: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasPublicDoc(workspaceId: string) {
|
||||||
|
const count = await this.db.workspaceDoc.count({
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
status: {
|
public: true,
|
||||||
in: [
|
|
||||||
WorkspaceMemberStatus.NeedMoreSeat,
|
|
||||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
// find the oldest members first
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const needChange = members.slice(0, availableCount);
|
return count > 0;
|
||||||
const groups = groupBy(needChange, m => m.status);
|
}
|
||||||
|
|
||||||
const toPendings = groups.NeedMoreSeat;
|
async getPublicDocs(workspaceId: string) {
|
||||||
if (toPendings) {
|
return await this.db.workspaceDoc.findMany({
|
||||||
// NeedMoreSeat => Pending
|
where: {
|
||||||
await this.db.workspaceUserPermission.updateMany({
|
workspaceId,
|
||||||
where: { id: { in: toPendings.map(m => m.id) } },
|
public: true,
|
||||||
data: { status: WorkspaceMemberStatus.Pending },
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const toUnderReviews = groups.NeedMoreSeatAndReview;
|
async setDocDefaultRole(workspaceId: string, docId: string, role: DocRole) {
|
||||||
if (toUnderReviews) {
|
await this.db.workspaceDoc.upsert({
|
||||||
// NeedMoreSeatAndReview => UnderReview
|
where: { workspaceId_docId: { workspaceId, docId } },
|
||||||
await this.db.workspaceUserPermission.updateMany({
|
update: { defaultRole: role },
|
||||||
where: { id: { in: toUnderReviews.map(m => m.id) } },
|
create: { workspaceId, docId, defaultRole: role },
|
||||||
data: { status: WorkspaceMemberStatus.UnderReview },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accepted condition for workspace member.
|
|
||||||
*/
|
|
||||||
private get acceptedCondition() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
// keep compatibility with old data
|
|
||||||
accepted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NeedMoreSeat => Pending
|
|
||||||
*
|
|
||||||
* NeedMoreSeatAndReview => UnderReview
|
|
||||||
*
|
|
||||||
* Pending | UnderReview => Accepted
|
|
||||||
*/
|
|
||||||
private getAllowedStatusSource(
|
|
||||||
to: WorkspaceMemberStatus
|
|
||||||
): WorkspaceMemberStatus[] {
|
|
||||||
switch (to) {
|
|
||||||
case WorkspaceMemberStatus.NeedMoreSeat:
|
|
||||||
return [WorkspaceMemberStatus.Pending];
|
|
||||||
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
|
|
||||||
return [WorkspaceMemberStatus.UnderReview];
|
|
||||||
case WorkspaceMemberStatus.Pending:
|
|
||||||
case WorkspaceMemberStatus.UnderReview: // need admin to review in team workspace
|
|
||||||
return [WorkspaceMemberStatus.Accepted];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
} from '../../base';
|
} from '../../base';
|
||||||
import { CurrentUser } from '../../core/auth';
|
import { CurrentUser } from '../../core/auth';
|
||||||
import { Admin } from '../../core/common';
|
import { Admin } from '../../core/common';
|
||||||
import { PermissionService } from '../../core/permission';
|
import { AccessController } from '../../core/permission';
|
||||||
import { UserType } from '../../core/user';
|
import { UserType } from '../../core/user';
|
||||||
import { PromptService } from './prompt';
|
import { PromptService } from './prompt';
|
||||||
import { ChatSessionService } from './session';
|
import { ChatSessionService } from './session';
|
||||||
@@ -299,7 +299,7 @@ export class CopilotType {
|
|||||||
@Resolver(() => CopilotType)
|
@Resolver(() => CopilotType)
|
||||||
export class CopilotResolver {
|
export class CopilotResolver {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly permissions: PermissionService,
|
private readonly ac: AccessController,
|
||||||
private readonly mutex: RequestMutex,
|
private readonly mutex: RequestMutex,
|
||||||
private readonly chatSession: ChatSessionService,
|
private readonly chatSession: ChatSessionService,
|
||||||
private readonly storage: CopilotStorage
|
private readonly storage: CopilotStorage
|
||||||
@@ -339,7 +339,11 @@ export class CopilotResolver {
|
|||||||
@Args('options', { nullable: true }) options?: QueryChatSessionsInput
|
@Args('options', { nullable: true }) options?: QueryChatSessionsInput
|
||||||
) {
|
) {
|
||||||
if (!copilot.workspaceId) return [];
|
if (!copilot.workspaceId) return [];
|
||||||
await this.permissions.checkCloudWorkspace(copilot.workspaceId, user.id);
|
await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(copilot.workspaceId)
|
||||||
|
.allowLocal()
|
||||||
|
.assert('Workspace.Copilot');
|
||||||
return await this.chatSession.listSessions(
|
return await this.chatSession.listSessions(
|
||||||
user.id,
|
user.id,
|
||||||
copilot.workspaceId,
|
copilot.workspaceId,
|
||||||
@@ -360,14 +364,17 @@ export class CopilotResolver {
|
|||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return [];
|
return [];
|
||||||
} else if (docId) {
|
} else if (docId) {
|
||||||
await this.permissions.checkCloudPagePermission(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
docId,
|
.doc({ workspaceId, docId })
|
||||||
'Doc.Read',
|
.allowLocal()
|
||||||
user.id
|
.assert('Doc.Read');
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
await this.permissions.checkCloudWorkspace(workspaceId, user.id);
|
await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(workspaceId)
|
||||||
|
.allowLocal()
|
||||||
|
.assert('Workspace.Copilot');
|
||||||
}
|
}
|
||||||
|
|
||||||
const histories = await this.chatSession.listHistories(
|
const histories = await this.chatSession.listHistories(
|
||||||
@@ -393,12 +400,7 @@ export class CopilotResolver {
|
|||||||
@Args({ name: 'options', type: () => CreateChatSessionInput })
|
@Args({ name: 'options', type: () => CreateChatSessionInput })
|
||||||
options: CreateChatSessionInput
|
options: CreateChatSessionInput
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkCloudPagePermission(
|
await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
|
||||||
options.workspaceId,
|
|
||||||
options.docId,
|
|
||||||
'Doc.Update',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
|
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
|
||||||
await using lock = await this.mutex.acquire(lockFlag);
|
await using lock = await this.mutex.acquire(lockFlag);
|
||||||
if (!lock) {
|
if (!lock) {
|
||||||
@@ -432,12 +434,11 @@ export class CopilotResolver {
|
|||||||
throw new CopilotSessionNotFound();
|
throw new CopilotSessionNotFound();
|
||||||
}
|
}
|
||||||
const { workspaceId, docId } = session.config;
|
const { workspaceId, docId } = session.config;
|
||||||
await this.permissions.checkCloudPagePermission(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
docId,
|
.doc(workspaceId, docId)
|
||||||
'Doc.Update',
|
.allowLocal()
|
||||||
user.id
|
.assert('Doc.Update');
|
||||||
);
|
|
||||||
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`;
|
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`;
|
||||||
await using lock = await this.mutex.acquire(lockFlag);
|
await using lock = await this.mutex.acquire(lockFlag);
|
||||||
if (!lock) {
|
if (!lock) {
|
||||||
@@ -460,12 +461,7 @@ export class CopilotResolver {
|
|||||||
@Args({ name: 'options', type: () => ForkChatSessionInput })
|
@Args({ name: 'options', type: () => ForkChatSessionInput })
|
||||||
options: ForkChatSessionInput
|
options: ForkChatSessionInput
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkCloudPagePermission(
|
await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
|
||||||
options.workspaceId,
|
|
||||||
options.docId,
|
|
||||||
'Doc.Update',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
|
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
|
||||||
await using lock = await this.mutex.acquire(lockFlag);
|
await using lock = await this.mutex.acquire(lockFlag);
|
||||||
if (!lock) {
|
if (!lock) {
|
||||||
@@ -494,12 +490,7 @@ export class CopilotResolver {
|
|||||||
@Args({ name: 'options', type: () => DeleteSessionInput })
|
@Args({ name: 'options', type: () => DeleteSessionInput })
|
||||||
options: DeleteSessionInput
|
options: DeleteSessionInput
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkCloudPagePermission(
|
await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
|
||||||
options.workspaceId,
|
|
||||||
options.docId,
|
|
||||||
'Doc.Update',
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
if (!options.sessionIds.length) {
|
if (!options.sessionIds.length) {
|
||||||
return new NotFoundException('Session not found');
|
return new NotFoundException('Session not found');
|
||||||
}
|
}
|
||||||
@@ -567,7 +558,7 @@ export class CopilotResolver {
|
|||||||
@Throttle()
|
@Throttle()
|
||||||
@Resolver(() => UserType)
|
@Resolver(() => UserType)
|
||||||
export class UserCopilotResolver {
|
export class UserCopilotResolver {
|
||||||
constructor(private readonly permissions: PermissionService) {}
|
constructor(private readonly ac: AccessController) {}
|
||||||
|
|
||||||
@ResolveField(() => CopilotType)
|
@ResolveField(() => CopilotType)
|
||||||
async copilot(
|
async copilot(
|
||||||
@@ -575,7 +566,11 @@ export class UserCopilotResolver {
|
|||||||
@Args('workspaceId', { nullable: true }) workspaceId?: string
|
@Args('workspaceId', { nullable: true }) workspaceId?: string
|
||||||
) {
|
) {
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
await this.permissions.checkCloudWorkspace(workspaceId, user.id);
|
await this.ac
|
||||||
|
.user(user.id)
|
||||||
|
.workspace(workspaceId)
|
||||||
|
.allowLocal()
|
||||||
|
.assert('Workspace.Copilot');
|
||||||
}
|
}
|
||||||
return { workspaceId };
|
return { workspaceId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
|
|
||||||
import { ActionForbidden, Config } from '../../base';
|
import { ActionForbidden, Config } from '../../base';
|
||||||
import { CurrentUser } from '../../core/auth';
|
import { CurrentUser } from '../../core/auth';
|
||||||
import { PermissionService, WorkspaceRole } from '../../core/permission';
|
import { AccessController } from '../../core/permission';
|
||||||
import { WorkspaceType } from '../../core/workspaces';
|
import { WorkspaceType } from '../../core/workspaces';
|
||||||
import { SubscriptionRecurring } from '../payment/types';
|
import { SubscriptionRecurring } from '../payment/types';
|
||||||
import { LicenseService } from './service';
|
import { LicenseService } from './service';
|
||||||
@@ -39,7 +39,7 @@ export class LicenseResolver {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
private readonly service: LicenseService,
|
private readonly service: LicenseService,
|
||||||
private readonly permission: PermissionService
|
private readonly ac: AccessController
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ResolveField(() => License, {
|
@ResolveField(() => License, {
|
||||||
@@ -58,12 +58,10 @@ export class LicenseResolver {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkWorkspaceIs(
|
await this.ac
|
||||||
workspace.id,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspace.id)
|
||||||
WorkspaceRole.Owner
|
.assert('Workspace.Payment.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
return this.service.getLicense(workspace.id);
|
return this.service.getLicense(workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +75,10 @@ export class LicenseResolver {
|
|||||||
throw new ActionForbidden();
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkWorkspaceIs(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Owner
|
.assert('Workspace.Payment.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
return this.service.activateTeamLicense(workspaceId, license);
|
return this.service.activateTeamLicense(workspaceId, license);
|
||||||
}
|
}
|
||||||
@@ -95,11 +92,10 @@ export class LicenseResolver {
|
|||||||
throw new ActionForbidden();
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkWorkspaceIs(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Owner
|
.assert('Workspace.Payment.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
return this.service.deactivateTeamLicense(workspaceId);
|
return this.service.deactivateTeamLicense(workspaceId);
|
||||||
}
|
}
|
||||||
@@ -113,11 +109,10 @@ export class LicenseResolver {
|
|||||||
throw new ActionForbidden();
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkWorkspaceIs(
|
await this.ac
|
||||||
workspaceId,
|
.user(user.id)
|
||||||
user.id,
|
.workspace(workspaceId)
|
||||||
WorkspaceRole.Owner
|
.assert('Workspace.Payment.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
const { url } = await this.service.createCustomerPortal(workspaceId);
|
const { url } = await this.service.createCustomerPortal(workspaceId);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
UserFriendlyError,
|
UserFriendlyError,
|
||||||
WorkspaceLicenseAlreadyExists,
|
WorkspaceLicenseAlreadyExists,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
import { PermissionService } from '../../core/permission';
|
|
||||||
import { Models } from '../../models';
|
import { Models } from '../../models';
|
||||||
import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types';
|
import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types';
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ export class LicenseService implements OnModuleInit {
|
|||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
private readonly db: PrismaClient,
|
private readonly db: PrismaClient,
|
||||||
private readonly event: EventBus,
|
private readonly event: EventBus,
|
||||||
private readonly permission: PermissionService,
|
|
||||||
private readonly models: Models
|
private readonly models: Models
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -63,7 +61,7 @@ export class LicenseService implements OnModuleInit {
|
|||||||
memberLimit: quantity,
|
memberLimit: quantity,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await this.permission.refreshSeatStatus(workspaceId, quantity);
|
await this.models.workspaceUser.refresh(workspaceId, quantity);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SubscriptionPlanNotFound,
|
SubscriptionPlanNotFound,
|
||||||
URLHelper,
|
URLHelper,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
|
import { Models } from '../../../models';
|
||||||
import {
|
import {
|
||||||
KnownStripeInvoice,
|
KnownStripeInvoice,
|
||||||
KnownStripePrice,
|
KnownStripePrice,
|
||||||
@@ -48,7 +49,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
stripe: Stripe,
|
stripe: Stripe,
|
||||||
db: PrismaClient,
|
db: PrismaClient,
|
||||||
private readonly url: URLHelper,
|
private readonly url: URLHelper,
|
||||||
private readonly event: EventBus
|
private readonly event: EventBus,
|
||||||
|
private readonly models: Models
|
||||||
) {
|
) {
|
||||||
super(stripe, db);
|
super(stripe, db);
|
||||||
}
|
}
|
||||||
@@ -101,11 +103,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
return { allow_promotion_codes: true };
|
return { allow_promotion_codes: true };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const count = await this.db.workspaceUserPermission.count({
|
const count = await this.models.workspaceUser.count(args.workspaceId);
|
||||||
where: {
|
|
||||||
workspaceId: args.workspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.stripe.checkout.sessions.create({
|
return this.stripe.checkout.sessions.create({
|
||||||
customer: customer.stripeCustomerId,
|
customer: customer.stripeCustomerId,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { OnEvent } from '../../base';
|
import { OnEvent } from '../../base';
|
||||||
import { PermissionService } from '../../core/permission';
|
|
||||||
import { WorkspaceService } from '../../core/workspaces/resolvers';
|
import { WorkspaceService } from '../../core/workspaces/resolvers';
|
||||||
import { Models } from '../../models';
|
import { Models } from '../../models';
|
||||||
import { SubscriptionPlan } from './types';
|
import { SubscriptionPlan } from './types';
|
||||||
@@ -9,7 +8,6 @@ import { SubscriptionPlan } from './types';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class QuotaOverride {
|
export class QuotaOverride {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly permission: PermissionService,
|
|
||||||
private readonly workspace: WorkspaceService,
|
private readonly workspace: WorkspaceService,
|
||||||
private readonly models: Models
|
private readonly models: Models
|
||||||
) {}
|
) {}
|
||||||
@@ -32,7 +30,7 @@ export class QuotaOverride {
|
|||||||
memberLimit: quantity,
|
memberLimit: quantity,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await this.permission.refreshSeatStatus(workspaceId, quantity);
|
await this.models.workspaceUser.refresh(workspaceId, quantity);
|
||||||
if (!isTeam) {
|
if (!isTeam) {
|
||||||
// this event will triggered when subscription is activated or changed
|
// this event will triggered when subscription is activated or changed
|
||||||
// we only send emails when the team workspace is activated
|
// we only send emails when the team workspace is activated
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
WorkspaceIdRequiredToUpdateTeamSubscription,
|
WorkspaceIdRequiredToUpdateTeamSubscription,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
import { CurrentUser, Public } from '../../core/auth';
|
import { CurrentUser, Public } from '../../core/auth';
|
||||||
import { PermissionService, WorkspaceRole } from '../../core/permission';
|
import { AccessController } from '../../core/permission';
|
||||||
import { UserType } from '../../core/user';
|
import { UserType } from '../../core/user';
|
||||||
import { WorkspaceType } from '../../core/workspaces';
|
import { WorkspaceType } from '../../core/workspaces';
|
||||||
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
||||||
@@ -520,7 +520,7 @@ export class WorkspaceSubscriptionResolver {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly service: WorkspaceSubscriptionManager,
|
private readonly service: WorkspaceSubscriptionManager,
|
||||||
private readonly db: PrismaClient,
|
private readonly db: PrismaClient,
|
||||||
private readonly permission: PermissionService
|
private readonly ac: AccessController
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ResolveField(() => SubscriptionType, {
|
@ResolveField(() => SubscriptionType, {
|
||||||
@@ -542,11 +542,11 @@ export class WorkspaceSubscriptionResolver {
|
|||||||
@CurrentUser() me: CurrentUser,
|
@CurrentUser() me: CurrentUser,
|
||||||
@Parent() workspace: WorkspaceType
|
@Parent() workspace: WorkspaceType
|
||||||
) {
|
) {
|
||||||
await this.permission.checkWorkspace(
|
await this.ac
|
||||||
workspace.id,
|
.user(me.id)
|
||||||
me.id,
|
.workspace(workspace.id)
|
||||||
WorkspaceRole.Owner
|
.assert('Workspace.Payment.Manage');
|
||||||
);
|
|
||||||
return this.db.invoice.count({
|
return this.db.invoice.count({
|
||||||
where: {
|
where: {
|
||||||
targetId: workspace.id,
|
targetId: workspace.id,
|
||||||
@@ -562,11 +562,10 @@ export class WorkspaceSubscriptionResolver {
|
|||||||
take: number,
|
take: number,
|
||||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
||||||
) {
|
) {
|
||||||
await this.permission.checkWorkspace(
|
await this.ac
|
||||||
workspace.id,
|
.user(me.id)
|
||||||
me.id,
|
.workspace(workspace.id)
|
||||||
WorkspaceRole.Owner
|
.assert('Workspace.Payment.Manage');
|
||||||
);
|
|
||||||
|
|
||||||
return this.db.invoice.findMany({
|
return this.db.invoice.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ enum ErrorNames {
|
|||||||
CANNOT_DELETE_OWN_ACCOUNT
|
CANNOT_DELETE_OWN_ACCOUNT
|
||||||
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION
|
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION
|
||||||
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS
|
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS
|
||||||
|
CAN_NOT_REVOKE_YOURSELF
|
||||||
CAPTCHA_VERIFICATION_FAILED
|
CAPTCHA_VERIFICATION_FAILED
|
||||||
COPILOT_ACTION_TAKEN
|
COPILOT_ACTION_TAKEN
|
||||||
COPILOT_CONTEXT_FILE_NOT_SUPPORTED
|
COPILOT_CONTEXT_FILE_NOT_SUPPORTED
|
||||||
@@ -399,6 +400,7 @@ enum ErrorNames {
|
|||||||
NO_COPILOT_PROVIDER_AVAILABLE
|
NO_COPILOT_PROVIDER_AVAILABLE
|
||||||
OAUTH_ACCOUNT_ALREADY_CONNECTED
|
OAUTH_ACCOUNT_ALREADY_CONNECTED
|
||||||
OAUTH_STATE_EXPIRED
|
OAUTH_STATE_EXPIRED
|
||||||
|
OWNER_CAN_NOT_LEAVE_WORKSPACE
|
||||||
PASSWORD_REQUIRED
|
PASSWORD_REQUIRED
|
||||||
QUERY_TOO_LONG
|
QUERY_TOO_LONG
|
||||||
RUNTIME_CONFIG_NOT_FOUND
|
RUNTIME_CONFIG_NOT_FOUND
|
||||||
@@ -677,7 +679,7 @@ type Mutation {
|
|||||||
"""add a doc to context"""
|
"""add a doc to context"""
|
||||||
addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]!
|
addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]!
|
||||||
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
||||||
approveMember(userId: String!, workspaceId: String!): String!
|
approveMember(userId: String!, workspaceId: String!): Boolean!
|
||||||
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||||
changeEmail(email: String!, token: String!): UserType!
|
changeEmail(email: String!, token: String!): UserType!
|
||||||
changePassword(newPassword: String!, token: String!, userId: String): Boolean!
|
changePassword(newPassword: String!, token: String!, userId: String): Boolean!
|
||||||
@@ -725,7 +727,7 @@ type Mutation {
|
|||||||
forkCopilotSession(options: ForkChatSessionInput!): String!
|
forkCopilotSession(options: ForkChatSessionInput!): String!
|
||||||
generateLicenseKey(sessionId: String!): String!
|
generateLicenseKey(sessionId: String!): String!
|
||||||
grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean!
|
grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean!
|
||||||
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
|
grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean!
|
||||||
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String!
|
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String!
|
||||||
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
|
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
|
||||||
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
|
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
|
||||||
@@ -854,13 +856,10 @@ type Query {
|
|||||||
getInviteInfo(inviteId: String!): InvitationType!
|
getInviteInfo(inviteId: String!): InvitationType!
|
||||||
|
|
||||||
"""Get is admin of workspace"""
|
"""Get is admin of workspace"""
|
||||||
isAdmin(workspaceId: String!): Boolean!
|
isAdmin(workspaceId: String!): Boolean! @deprecated(reason: "use WorkspaceType[role] instead")
|
||||||
|
|
||||||
"""Get is owner of workspace"""
|
"""Get is owner of workspace"""
|
||||||
isOwner(workspaceId: String!): Boolean!
|
isOwner(workspaceId: String!): Boolean! @deprecated(reason: "use WorkspaceType[role] instead")
|
||||||
|
|
||||||
"""List blobs of workspace"""
|
|
||||||
listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead")
|
|
||||||
|
|
||||||
"""List all copilot prompts"""
|
"""List all copilot prompts"""
|
||||||
listCopilotPrompts: [CopilotPromptType!]!
|
listCopilotPrompts: [CopilotPromptType!]!
|
||||||
@@ -892,7 +891,7 @@ type Query {
|
|||||||
workspace(id: String!): WorkspaceType!
|
workspace(id: String!): WorkspaceType!
|
||||||
|
|
||||||
"""Get workspace role permissions"""
|
"""Get workspace role permissions"""
|
||||||
workspaceRolePermissions(id: String!): WorkspaceRolePermissions!
|
workspaceRolePermissions(id: String!): WorkspaceRolePermissions! @deprecated(reason: "use WorkspaceType[permissions] instead")
|
||||||
|
|
||||||
"""Get all accessible workspaces for current user"""
|
"""Get all accessible workspaces for current user"""
|
||||||
workspaces: [WorkspaceType!]!
|
workspaces: [WorkspaceType!]!
|
||||||
@@ -1266,13 +1265,20 @@ type WorkspacePermissionNotFoundDataType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WorkspacePermissions {
|
type WorkspacePermissions {
|
||||||
|
Workspace_Adminitrators_Manage: Boolean!
|
||||||
|
Workspace_Blobs_List: Boolean!
|
||||||
|
Workspace_Blobs_Read: Boolean!
|
||||||
|
Workspace_Blobs_Write: Boolean!
|
||||||
|
Workspace_Copilot: Boolean!
|
||||||
Workspace_CreateDoc: Boolean!
|
Workspace_CreateDoc: Boolean!
|
||||||
Workspace_Delete: Boolean!
|
Workspace_Delete: Boolean!
|
||||||
Workspace_Organize_Read: Boolean!
|
Workspace_Organize_Read: Boolean!
|
||||||
|
Workspace_Payment_Manage: Boolean!
|
||||||
Workspace_Properties_Create: Boolean!
|
Workspace_Properties_Create: Boolean!
|
||||||
Workspace_Properties_Delete: Boolean!
|
Workspace_Properties_Delete: Boolean!
|
||||||
Workspace_Properties_Read: Boolean!
|
Workspace_Properties_Read: Boolean!
|
||||||
Workspace_Properties_Update: Boolean!
|
Workspace_Properties_Update: Boolean!
|
||||||
|
Workspace_Read: Boolean!
|
||||||
Workspace_Settings_Read: Boolean!
|
Workspace_Settings_Read: Boolean!
|
||||||
Workspace_Settings_Update: Boolean!
|
Workspace_Settings_Update: Boolean!
|
||||||
Workspace_Sync: Boolean!
|
Workspace_Sync: Boolean!
|
||||||
@@ -1354,6 +1360,9 @@ type WorkspaceType {
|
|||||||
"""Cloud page metadata of workspace"""
|
"""Cloud page metadata of workspace"""
|
||||||
pageMeta(pageId: String!): WorkspacePageMeta!
|
pageMeta(pageId: String!): WorkspacePageMeta!
|
||||||
|
|
||||||
|
"""map of action permissions"""
|
||||||
|
permissions: WorkspacePermissions!
|
||||||
|
|
||||||
"""is Public workspace"""
|
"""is Public workspace"""
|
||||||
public: Boolean!
|
public: Boolean!
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ 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.workspaceUserPermission.create({
|
await client.workspaceUserRole.create({
|
||||||
data: {
|
data: {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
Reference in New Issue
Block a user