mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(server): allow to set default role in page (#9963)
This commit is contained in:
@@ -115,13 +115,15 @@ model Workspace {
|
|||||||
// NOTE:
|
// NOTE:
|
||||||
// We won't make sure every page has a corresponding record in this table.
|
// We won't make sure every page has a corresponding record in this table.
|
||||||
// Only the ones that have ever changed will have records here,
|
// Only the ones that have ever changed will have records here,
|
||||||
// and for others we will make sure it's has a default value return in our bussiness logic.
|
// and for others we will make sure it's has a default value return in our business logic.
|
||||||
model WorkspacePage {
|
model WorkspacePage {
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
pageId String @map("page_id") @db.VarChar
|
pageId String @map("page_id") @db.VarChar
|
||||||
public Boolean @default(false)
|
public Boolean @default(false)
|
||||||
|
// Workspace user's default role in this page, default is `Manager`
|
||||||
|
defaultRole Int @default(30) @db.SmallInt
|
||||||
// Page/Edgeless
|
// Page/Edgeless
|
||||||
mode Int @default(0) @db.SmallInt
|
mode Int @default(0) @db.SmallInt
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -277,7 +279,7 @@ model Snapshot {
|
|||||||
|
|
||||||
// user snapshots are special snapshots for user storage like personal app settings, distinguished from workspace snapshots
|
// user snapshots are special snapshots for user storage like personal app settings, distinguished from workspace snapshots
|
||||||
// basically they share the same structure with workspace snapshots
|
// basically they share the same structure with workspace snapshots
|
||||||
// but for convenience, we don't fork the updates queue and hisotry for user snapshots, until we have to
|
// but for convenience, we don't fork the updates queue and history for user snapshots, until we have to
|
||||||
// which means all operation on user snapshot will happen in-pace
|
// which means all operation on user snapshot will happen in-pace
|
||||||
model UserSnapshot {
|
model UserSnapshot {
|
||||||
userId String @map("user_id") @db.VarChar
|
userId String @map("user_id") @db.VarChar
|
||||||
@@ -299,7 +301,7 @@ model Update {
|
|||||||
createdAt DateTime @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @map("created_at") @db.Timestamptz(3)
|
||||||
createdBy String? @map("created_by") @db.VarChar
|
createdBy String? @map("created_by") @db.VarChar
|
||||||
|
|
||||||
// will delete createor record if createor's account is deleted
|
// will delete creator record if creator's account is deleted
|
||||||
createdByUser User? @relation(name: "createdUpdate", fields: [createdBy], references: [id], onDelete: SetNull)
|
createdByUser User? @relation(name: "createdUpdate", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
// @deprecated use createdAt only
|
// @deprecated use createdAt only
|
||||||
@@ -318,7 +320,7 @@ model SnapshotHistory {
|
|||||||
expiredAt DateTime @map("expired_at") @db.Timestamptz(3)
|
expiredAt DateTime @map("expired_at") @db.Timestamptz(3)
|
||||||
createdBy String? @map("created_by") @db.VarChar
|
createdBy String? @map("created_by") @db.VarChar
|
||||||
|
|
||||||
// will delete createor record if creator's account is deleted
|
// will delete creator record if creator's account is deleted
|
||||||
createdByUser User? @relation(name: "createdHistory", fields: [createdBy], references: [id], onDelete: SetNull)
|
createdByUser User? @relation(name: "createdHistory", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@id([workspaceId, id, timestamp])
|
@@id([workspaceId, id, timestamp])
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud';
|
|||||||
import { WorkspaceMemberStatus } from '@prisma/client';
|
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import type { TestFn } from 'ava';
|
import type { TestFn } from 'ava';
|
||||||
import ava from 'ava';
|
import ava from 'ava';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
import { AppModule } from '../app.module';
|
import { AppModule } from '../app.module';
|
||||||
import { EventBus } from '../base';
|
import { EventBus } from '../base';
|
||||||
@@ -199,6 +201,11 @@ const init = async (
|
|||||||
WorkspaceRole.Collaborator
|
WorkspaceRole.Collaborator
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const external = await invite(
|
||||||
|
`${prefix}external@affine.pro`,
|
||||||
|
WorkspaceRole.External
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invite,
|
invite,
|
||||||
inviteBatch,
|
inviteBatch,
|
||||||
@@ -209,12 +216,13 @@ const init = async (
|
|||||||
admin,
|
admin,
|
||||||
write,
|
write,
|
||||||
read,
|
read,
|
||||||
|
external,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
test('should be able to invite multiple users', async t => {
|
test('should be able to invite multiple users', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
const { teamWorkspace: ws, owner, admin, write, read } = await init(app, 4);
|
const { teamWorkspace: ws, owner, admin, write, read } = await init(app, 5);
|
||||||
|
|
||||||
{
|
{
|
||||||
// no permission
|
// no permission
|
||||||
@@ -265,7 +273,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, permissions, models } = t.context;
|
||||||
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 4);
|
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 5);
|
||||||
|
|
||||||
{
|
{
|
||||||
// invite
|
// invite
|
||||||
@@ -275,7 +283,7 @@ test('should be able to check seat limit', async t => {
|
|||||||
'should throw error if exceed member limit'
|
'should throw error if exceed member limit'
|
||||||
);
|
);
|
||||||
models.workspaceFeature.add(ws.id, 'team_plan_v1', 'test', {
|
models.workspaceFeature.add(ws.id, 'team_plan_v1', 'test', {
|
||||||
memberLimit: 5,
|
memberLimit: 6,
|
||||||
});
|
});
|
||||||
await t.notThrowsAsync(
|
await t.notThrowsAsync(
|
||||||
invite('member4@affine.pro', WorkspaceRole.Collaborator),
|
invite('member4@affine.pro', WorkspaceRole.Collaborator),
|
||||||
@@ -303,7 +311,7 @@ test('should be able to check seat limit', async t => {
|
|||||||
// refresh seat, fifo
|
// refresh seat, fifo
|
||||||
sleep(1000);
|
sleep(1000);
|
||||||
const [[members2]] = await inviteBatch(['member6@affine.pro']);
|
const [[members2]] = await inviteBatch(['member6@affine.pro']);
|
||||||
await permissions.refreshSeatStatus(ws.id, 6);
|
await permissions.refreshSeatStatus(ws.id, 7);
|
||||||
|
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(
|
await permissions.getWorkspaceMemberStatus(
|
||||||
@@ -471,7 +479,7 @@ test('should be able to manage invite link', async t => {
|
|||||||
admin,
|
admin,
|
||||||
write,
|
write,
|
||||||
read,
|
read,
|
||||||
} = await init(app, 4);
|
} = await init(app);
|
||||||
|
|
||||||
for (const [workspace, managers] of [
|
for (const [workspace, managers] of [
|
||||||
[ws, [owner]],
|
[ws, [owner]],
|
||||||
@@ -519,7 +527,7 @@ test('should be able to manage invite link', async t => {
|
|||||||
|
|
||||||
test('should be able to approve team member', async t => {
|
test('should be able to approve team member', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
const { teamWorkspace: tws, owner, admin, write, read } = await init(app, 5);
|
const { teamWorkspace: tws, owner, admin, write, read } = await init(app, 6);
|
||||||
|
|
||||||
{
|
{
|
||||||
const { link } = await createInviteLink(
|
const { link } = await createInviteLink(
|
||||||
@@ -577,7 +585,7 @@ test('should be able to invite by link', async t => {
|
|||||||
owner,
|
owner,
|
||||||
workspace: ws,
|
workspace: ws,
|
||||||
teamWorkspace: tws,
|
teamWorkspace: tws,
|
||||||
} = await init(app, 4);
|
} = await init(app, 5);
|
||||||
const [inviteId, invite] = await createInviteLink(ws);
|
const [inviteId, invite] = await createInviteLink(ws);
|
||||||
const [teamInviteId, teamInvite, acceptTeamInvite] =
|
const [teamInviteId, teamInvite, acceptTeamInvite] =
|
||||||
await createInviteLink(tws);
|
await createInviteLink(tws);
|
||||||
@@ -594,7 +602,7 @@ test('should be able to invite by link', async t => {
|
|||||||
|
|
||||||
{
|
{
|
||||||
// invite link
|
// invite link
|
||||||
for (const [i] of Array.from({ length: 6 }).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 permissions.getWorkspaceMemberStatus(ws.id, user.id);
|
||||||
t.is(
|
t.is(
|
||||||
@@ -632,9 +640,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: 5,
|
memberLimit: 6,
|
||||||
});
|
});
|
||||||
await permissions.refreshSeatStatus(tws.id, 5);
|
await permissions.refreshSeatStatus(tws.id, 6);
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(tws.id, m3.id),
|
await permissions.getWorkspaceMemberStatus(tws.id, m3.id),
|
||||||
WorkspaceMemberStatus.UnderReview,
|
WorkspaceMemberStatus.UnderReview,
|
||||||
@@ -647,9 +655,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: 6,
|
memberLimit: 7,
|
||||||
});
|
});
|
||||||
await permissions.refreshSeatStatus(tws.id, 6);
|
await permissions.refreshSeatStatus(tws.id, 7);
|
||||||
t.is(
|
t.is(
|
||||||
await permissions.getWorkspaceMemberStatus(tws.id, m4.id),
|
await permissions.getWorkspaceMemberStatus(tws.id, m4.id),
|
||||||
WorkspaceMemberStatus.UnderReview,
|
WorkspaceMemberStatus.UnderReview,
|
||||||
@@ -669,7 +677,7 @@ test('should be able to invite by link', async t => {
|
|||||||
|
|
||||||
test('should be able to send mails', async t => {
|
test('should be able to send mails', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
const { inviteBatch } = await init(app, 4);
|
const { inviteBatch } = await init(app, 5);
|
||||||
const primitiveMailCount = await getCurrentMailMessageCount();
|
const primitiveMailCount = await getCurrentMailMessageCount();
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -682,7 +690,7 @@ test('should be able to emit events', async t => {
|
|||||||
const { app, event } = t.context;
|
const { app, event } = t.context;
|
||||||
|
|
||||||
{
|
{
|
||||||
const { teamWorkspace: tws, inviteBatch } = await init(app, 4);
|
const { teamWorkspace: tws, inviteBatch } = await init(app, 5);
|
||||||
|
|
||||||
await inviteBatch(['m1@affine.pro', 'm2@affine.pro']);
|
await inviteBatch(['m1@affine.pro', 'm2@affine.pro']);
|
||||||
const [membersUpdated] = event.emit
|
const [membersUpdated] = event.emit
|
||||||
@@ -693,7 +701,7 @@ test('should be able to emit events', async t => {
|
|||||||
'workspace.members.updated',
|
'workspace.members.updated',
|
||||||
{
|
{
|
||||||
workspaceId: tws.id,
|
workspaceId: tws.id,
|
||||||
count: 6,
|
count: 7,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -787,7 +795,7 @@ test('should be able to emit events', async t => {
|
|||||||
[
|
[
|
||||||
'workspace.members.updated',
|
'workspace.members.updated',
|
||||||
{
|
{
|
||||||
count: 3,
|
count: 4,
|
||||||
workspaceId: tws.id,
|
workspaceId: tws.id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -795,3 +803,225 @@ test('should be able to emit events', async t => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should be able to change the default role in page', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
const { teamWorkspace: ws, admin } = await init(app, 5);
|
||||||
|
const pageId = nanoid();
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.auth(admin.token.token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
updatePageDefaultRole(input: {
|
||||||
|
workspaceId: "${ws.id}",
|
||||||
|
docId: "${pageId}",
|
||||||
|
role: Reader,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
t.deepEqual(res.body, {
|
||||||
|
data: {
|
||||||
|
updatePageDefaultRole: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Default page role should be able to override the workspace role', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
const {
|
||||||
|
teamWorkspace: workspace,
|
||||||
|
admin,
|
||||||
|
read,
|
||||||
|
external,
|
||||||
|
} = await init(app, 5);
|
||||||
|
|
||||||
|
const pageId = nanoid();
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.auth(admin.token.token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
updatePageDefaultRole(input: {
|
||||||
|
workspaceId: "${workspace.id}",
|
||||||
|
docId: "${pageId}",
|
||||||
|
role: Manager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
t.deepEqual(res.body, {
|
||||||
|
data: {
|
||||||
|
updatePageDefaultRole: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// reader can manage the page if the page default role is Manager
|
||||||
|
{
|
||||||
|
const readerRes = await request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.auth(read.token.token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
updatePageDefaultRole(input: {
|
||||||
|
workspaceId: "${workspace.id}",
|
||||||
|
docId: "${pageId}",
|
||||||
|
role: Manager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
t.deepEqual(readerRes.body, {
|
||||||
|
data: {
|
||||||
|
updatePageDefaultRole: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// external can't manage the page even if the page default role is Manager
|
||||||
|
{
|
||||||
|
const externalRes = await request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.auth(external.token.token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
updatePageDefaultRole(input: {
|
||||||
|
workspaceId: "${workspace.id}",
|
||||||
|
docId: "${pageId}",
|
||||||
|
role: Manager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
t.like(externalRes.body, {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `You do not have permission to access doc ${pageId} under Space ${workspace.id}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to grant and revoke doc user role', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
const { teamWorkspace: ws, admin, read, external } = await init(app, 5);
|
||||||
|
const pageId = nanoid();
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.auth(admin.token.token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
grantDocUserRoles(input: {
|
||||||
|
workspaceId: "${ws.id}",
|
||||||
|
docId: "${pageId}",
|
||||||
|
role: Manager,
|
||||||
|
userIds: ["${external.id}"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
t.deepEqual(res.body, {
|
||||||
|
data: {
|
||||||
|
grantDocUserRoles: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// external user now can manage the page
|
||||||
|
{
|
||||||
|
const externalRes = await request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.auth(external.token.token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
grantDocUserRoles(input: {
|
||||||
|
workspaceId: "${ws.id}",
|
||||||
|
docId: "${pageId}",
|
||||||
|
role: Manager,
|
||||||
|
userIds: ["${read.id}"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
t.deepEqual(externalRes.body, {
|
||||||
|
data: {
|
||||||
|
grantDocUserRoles: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// revoke the role of the external user
|
||||||
|
{
|
||||||
|
const revokeRes = await request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.auth(admin.token.token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
revokeDocUserRoles(input: {
|
||||||
|
workspaceId: "${ws.id}",
|
||||||
|
docId: "${pageId}",
|
||||||
|
userIds: ["${external.id}"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
t.deepEqual(revokeRes.body, {
|
||||||
|
data: {
|
||||||
|
revokeDocUserRoles: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// external user can't manage the page
|
||||||
|
const externalRes = await request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.auth(external.token.token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
revokeDocUserRoles(input: {
|
||||||
|
workspaceId: "${ws.id}",
|
||||||
|
docId: "${pageId}",
|
||||||
|
userIds: ["${read.id}"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
t.like(externalRes.body, {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `You do not have permission to access Space ${ws.id}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ test('should create a workspace', async t => {
|
|||||||
t.is(typeof workspace.id, 'string', 'workspace.id is not a string');
|
t.is(typeof workspace.id, 'string', 'workspace.id is not a string');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should can publish workspace', async t => {
|
test('should be able to publish workspace', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||||
const workspace = await createWorkspace(app, user.token.token);
|
const workspace = await createWorkspace(app, user.token.token);
|
||||||
|
|||||||
@@ -470,6 +470,10 @@ export const USER_FRIENDLY_ERRORS = {
|
|||||||
type: 'action_forbidden',
|
type: 'action_forbidden',
|
||||||
message: 'A Team workspace is required to perform this action.',
|
message: 'A Team workspace is required to perform this action.',
|
||||||
},
|
},
|
||||||
|
page_default_role_can_not_be_owner: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message: 'Page default role can not be owner.',
|
||||||
|
},
|
||||||
|
|
||||||
// Subscription Errors
|
// Subscription Errors
|
||||||
unsupported_subscription_plan: {
|
unsupported_subscription_plan: {
|
||||||
|
|||||||
@@ -405,6 +405,12 @@ export class ActionForbiddenOnNonTeamWorkspace extends UserFriendlyError {
|
|||||||
super('action_forbidden', 'action_forbidden_on_non_team_workspace', message);
|
super('action_forbidden', 'action_forbidden_on_non_team_workspace', message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PageDefaultRoleCanNotBeOwner extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'page_default_role_can_not_be_owner', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
class UnsupportedSubscriptionPlanDataType {
|
class UnsupportedSubscriptionPlanDataType {
|
||||||
@Field() plan!: string
|
@Field() plan!: string
|
||||||
@@ -761,6 +767,7 @@ export enum ErrorNames {
|
|||||||
FAILED_TO_SAVE_UPDATES,
|
FAILED_TO_SAVE_UPDATES,
|
||||||
FAILED_TO_UPSERT_SNAPSHOT,
|
FAILED_TO_UPSERT_SNAPSHOT,
|
||||||
ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE,
|
ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE,
|
||||||
|
PAGE_DEFAULT_ROLE_CAN_NOT_BE_OWNER,
|
||||||
UNSUPPORTED_SUBSCRIPTION_PLAN,
|
UNSUPPORTED_SUBSCRIPTION_PLAN,
|
||||||
FAILED_TO_CHECKOUT,
|
FAILED_TO_CHECKOUT,
|
||||||
INVALID_CHECKOUT_PARAMETERS,
|
INVALID_CHECKOUT_PARAMETERS,
|
||||||
|
|||||||
@@ -571,30 +571,69 @@ export class PermissionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const count = await this.prisma.workspacePageUserPermission.count({
|
const [roleEntity, pageEntity, workspaceRoleEntity] = await Promise.all([
|
||||||
where: {
|
this.prisma.workspacePageUserPermission.findFirst({
|
||||||
workspaceId: ws,
|
where: {
|
||||||
pageId: page,
|
workspaceId: ws,
|
||||||
userId: user,
|
pageId: page,
|
||||||
type: {
|
userId: user,
|
||||||
gte: role,
|
|
||||||
},
|
},
|
||||||
},
|
select: {
|
||||||
});
|
type: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.workspacePage.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId: ws,
|
||||||
|
pageId: page,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
defaultRole: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.workspaceUserPermission.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId: ws,
|
||||||
|
userId: user,
|
||||||
|
OR: this.acceptedCondition,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
// page shared to user
|
if (
|
||||||
// accessible
|
// Page role exists, check it first
|
||||||
if (count > 0) {
|
(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,
|
||||||
|
pageEntity?.defaultRole ?? Number.MIN_SAFE_INTEGER
|
||||||
|
) >= role)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
|
||||||
this.logger.log("User's PageRole is lower than required", {
|
|
||||||
workspaceId: ws,
|
|
||||||
pageId: page,
|
|
||||||
userId: user,
|
|
||||||
requiredRole: DocRole[role],
|
|
||||||
action,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
this.logger.log("User's role is lower than required", {
|
||||||
|
workspaceId: ws,
|
||||||
|
docId: page,
|
||||||
|
userId: user,
|
||||||
|
workspaceRole: workspaceRoleEntity
|
||||||
|
? WorkspaceRole[workspaceRoleEntity.type]
|
||||||
|
: undefined,
|
||||||
|
pageRole: roleEntity ? DocRole[roleEntity.type] : undefined,
|
||||||
|
pageDefaultRole: pageEntity
|
||||||
|
? DocRole[pageEntity.defaultRole]
|
||||||
|
: undefined,
|
||||||
|
requiredRole: DocRole[role],
|
||||||
|
action,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// check whether user has workspace related permission
|
// check whether user has workspace related permission
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export function mapDocRoleToPermissions(docRole: DocRole) {
|
|||||||
export function fixupDocRole(
|
export function fixupDocRole(
|
||||||
workspaceRole: WorkspaceRole = WorkspaceRole.External,
|
workspaceRole: WorkspaceRole = WorkspaceRole.External,
|
||||||
docRole: DocRole = DocRole.External
|
docRole: DocRole = DocRole.External
|
||||||
) {
|
): DocRole {
|
||||||
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
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DocAccessDenied,
|
||||||
ExpectToGrantDocUserRoles,
|
ExpectToGrantDocUserRoles,
|
||||||
ExpectToPublishPage,
|
ExpectToPublishPage,
|
||||||
ExpectToRevokeDocUserRoles,
|
ExpectToRevokeDocUserRoles,
|
||||||
ExpectToRevokePublicPage,
|
ExpectToRevokePublicPage,
|
||||||
ExpectToUpdateDocUserRole,
|
ExpectToUpdateDocUserRole,
|
||||||
|
PageDefaultRoleCanNotBeOwner,
|
||||||
PageIsNotPublic,
|
PageIsNotPublic,
|
||||||
paginate,
|
paginate,
|
||||||
Paginated,
|
Paginated,
|
||||||
@@ -74,6 +76,45 @@ class GrantDocUserRolesInput {
|
|||||||
userIds!: string[];
|
userIds!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class UpdateDocUserRoleInput {
|
||||||
|
@Field(() => String)
|
||||||
|
docId!: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Field(() => DocRole)
|
||||||
|
role!: DocRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class RevokeDocUserRolesInput {
|
||||||
|
@Field(() => String)
|
||||||
|
docId!: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@Field(() => [String])
|
||||||
|
userIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class UpdatePageDefaultRoleInput {
|
||||||
|
@Field(() => String)
|
||||||
|
docId!: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@Field(() => DocRole)
|
||||||
|
role!: DocRole;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
class GrantedDocUserType {
|
class GrantedDocUserType {
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
@@ -413,12 +454,11 @@ export class PagePermissionResolver {
|
|||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
async revokeDocUserRoles(
|
async revokeDocUserRoles(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('docId') docId: string,
|
@Args('input') input: RevokeDocUserRolesInput
|
||||||
@Args('userIds', { type: () => [String] }) userIds: string[]
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const doc = new DocID(docId);
|
const doc = new DocID(input.docId, input.workspaceId);
|
||||||
const pairs = {
|
const pairs = {
|
||||||
spaceId: doc.workspace,
|
spaceId: input.workspaceId,
|
||||||
docId: doc.guid,
|
docId: doc.guid,
|
||||||
};
|
};
|
||||||
if (doc.isWorkspace) {
|
if (doc.isWorkspace) {
|
||||||
@@ -436,10 +476,10 @@ export class PagePermissionResolver {
|
|||||||
user.id,
|
user.id,
|
||||||
WorkspaceRole.Collaborator
|
WorkspaceRole.Collaborator
|
||||||
);
|
);
|
||||||
await this.permission.revokePage(doc.workspace, doc.guid, userIds);
|
await this.permission.revokePage(doc.workspace, doc.guid, input.userIds);
|
||||||
this.logger.log('Revoke doc user roles', {
|
this.logger.log('Revoke doc user roles', {
|
||||||
...pairs,
|
...pairs,
|
||||||
userIds: userIds,
|
userIds: input.userIds,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -447,11 +487,9 @@ export class PagePermissionResolver {
|
|||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
async updateDocUserRole(
|
async updateDocUserRole(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('docId') docId: string,
|
@Args('input') input: UpdateDocUserRoleInput
|
||||||
@Args('userId') userId: string,
|
|
||||||
@Args('role', { type: () => DocRole }) role: DocRole
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const doc = new DocID(docId);
|
const doc = new DocID(input.docId, input.workspaceId);
|
||||||
const pairs = {
|
const pairs = {
|
||||||
spaceId: doc.workspace,
|
spaceId: doc.workspace,
|
||||||
docId: doc.guid,
|
docId: doc.guid,
|
||||||
@@ -471,32 +509,94 @@ export class PagePermissionResolver {
|
|||||||
user.id,
|
user.id,
|
||||||
WorkspaceRole.Collaborator
|
WorkspaceRole.Collaborator
|
||||||
);
|
);
|
||||||
if (role === DocRole.Owner) {
|
if (input.role === DocRole.Owner) {
|
||||||
const ret = await this.permission.grantPagePermission(
|
const ret = await this.permission.grantPagePermission(
|
||||||
doc.workspace,
|
doc.workspace,
|
||||||
doc.guid,
|
doc.guid,
|
||||||
[userId],
|
[input.userId],
|
||||||
role
|
input.role
|
||||||
);
|
);
|
||||||
this.logger.log('Transfer doc owner', {
|
this.logger.log('Transfer doc owner', {
|
||||||
...pairs,
|
...pairs,
|
||||||
userId: userId,
|
userId: input.userId,
|
||||||
role: role,
|
role: input.role,
|
||||||
});
|
});
|
||||||
return ret.length > 0;
|
return ret.length > 0;
|
||||||
} else {
|
} else {
|
||||||
await this.permission.updatePagePermission(
|
await this.permission.updatePagePermission(
|
||||||
doc.workspace,
|
doc.workspace,
|
||||||
doc.guid,
|
doc.guid,
|
||||||
userId,
|
input.userId,
|
||||||
role
|
input.role
|
||||||
);
|
);
|
||||||
this.logger.log('Update doc user role', {
|
this.logger.log('Update doc user role', {
|
||||||
...pairs,
|
...pairs,
|
||||||
userId: userId,
|
userId: input.userId,
|
||||||
role: role,
|
role: input.role,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean)
|
||||||
|
async updatePageDefaultRole(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Args('input') input: UpdatePageDefaultRoleInput
|
||||||
|
) {
|
||||||
|
if (input.role === DocRole.Owner) {
|
||||||
|
this.logger.log('Page default role can not be owner', input);
|
||||||
|
throw new PageDefaultRoleCanNotBeOwner();
|
||||||
|
}
|
||||||
|
const doc = new DocID(input.docId, input.workspaceId);
|
||||||
|
const pairs = {
|
||||||
|
spaceId: doc.workspace,
|
||||||
|
docId: doc.guid,
|
||||||
|
};
|
||||||
|
if (doc.isWorkspace) {
|
||||||
|
this.logger.error(
|
||||||
|
'Expect to update page default role, but it is a workspace',
|
||||||
|
pairs
|
||||||
|
);
|
||||||
|
throw new ExpectToUpdateDocUserRole(
|
||||||
|
pairs,
|
||||||
|
'Expect doc not to be workspace'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.permission.checkCloudPagePermission(
|
||||||
|
doc.workspace,
|
||||||
|
doc.guid,
|
||||||
|
'Doc.Users.Manage',
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DocAccessDenied) {
|
||||||
|
this.logger.log(
|
||||||
|
'User does not have permission to update page default role',
|
||||||
|
{
|
||||||
|
...pairs,
|
||||||
|
userId: user.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await this.prisma.workspacePage.upsert({
|
||||||
|
where: {
|
||||||
|
workspaceId_pageId: {
|
||||||
|
workspaceId: doc.workspace,
|
||||||
|
pageId: doc.guid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
defaultRole: input.role,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
workspaceId: doc.workspace,
|
||||||
|
pageId: doc.guid,
|
||||||
|
defaultRole: input.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,6 +305,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
|
||||||
|
PAGE_DEFAULT_ROLE_CAN_NOT_BE_OWNER
|
||||||
PAGE_IS_NOT_PUBLIC
|
PAGE_IS_NOT_PUBLIC
|
||||||
PASSWORD_REQUIRED
|
PASSWORD_REQUIRED
|
||||||
QUERY_TOO_LONG
|
QUERY_TOO_LONG
|
||||||
@@ -630,7 +631,7 @@ type Mutation {
|
|||||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
||||||
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||||
revoke(userId: String!, workspaceId: String!): Boolean!
|
revoke(userId: String!, workspaceId: String!): Boolean!
|
||||||
revokeDocUserRoles(docId: String!, userIds: [String!]!): Boolean!
|
revokeDocUserRoles(input: RevokeDocUserRolesInput!): Boolean!
|
||||||
revokeInviteLink(workspaceId: String!): Boolean!
|
revokeInviteLink(workspaceId: String!): Boolean!
|
||||||
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
|
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
|
||||||
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
|
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
|
||||||
@@ -647,7 +648,8 @@ type Mutation {
|
|||||||
|
|
||||||
"""Update a chat session"""
|
"""Update a chat session"""
|
||||||
updateCopilotSession(options: UpdateChatSessionInput!): String!
|
updateCopilotSession(options: UpdateChatSessionInput!): String!
|
||||||
updateDocUserRole(docId: String!, role: DocRole!, userId: String!): Boolean!
|
updateDocUserRole(input: UpdateDocUserRoleInput!): Boolean!
|
||||||
|
updatePageDefaultRole(input: UpdatePageDefaultRoleInput!): Boolean!
|
||||||
updateProfile(input: UpdateUserInput!): UserType!
|
updateProfile(input: UpdateUserInput!): UserType!
|
||||||
|
|
||||||
"""update server runtime configurable setting"""
|
"""update server runtime configurable setting"""
|
||||||
@@ -802,6 +804,12 @@ type RemoveAvatar {
|
|||||||
success: Boolean!
|
success: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input RevokeDocUserRolesInput {
|
||||||
|
docId: String!
|
||||||
|
userIds: [String!]!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
type RuntimeConfigNotFoundDataType {
|
type RuntimeConfigNotFoundDataType {
|
||||||
key: String!
|
key: String!
|
||||||
}
|
}
|
||||||
@@ -996,6 +1004,19 @@ input UpdateChatSessionInput {
|
|||||||
sessionId: String!
|
sessionId: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpdateDocUserRoleInput {
|
||||||
|
docId: String!
|
||||||
|
role: DocRole!
|
||||||
|
userId: String!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdatePageDefaultRoleInput {
|
||||||
|
docId: String!
|
||||||
|
role: DocRole!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
input UpdateUserInput {
|
input UpdateUserInput {
|
||||||
"""User name"""
|
"""User name"""
|
||||||
name: String
|
name: String
|
||||||
|
|||||||
Reference in New Issue
Block a user