feat: workspace level share settings (#14201)

fix #13698
This commit is contained in:
DarkSky
2026-01-03 01:13:27 +08:00
committed by GitHub
parent 60de882a30
commit 9a7f8e7d4d
36 changed files with 560 additions and 34 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "workspaces" ADD COLUMN "enable_sharing" BOOLEAN NOT NULL DEFAULT true;
+9 -8
View File
@@ -117,6 +117,7 @@ model Workspace {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
// workspace level feature flags // workspace level feature flags
enableAi Boolean @default(true) @map("enable_ai") enableAi Boolean @default(true) @map("enable_ai")
enableSharing Boolean @default(true) @map("enable_sharing")
enableUrlPreview Boolean @default(false) @map("enable_url_preview") enableUrlPreview Boolean @default(false) @map("enable_url_preview")
enableDocEmbedding Boolean @default(true) @map("enable_doc_embedding") enableDocEmbedding Boolean @default(true) @map("enable_doc_embedding")
name String? @db.VarChar name String? @db.VarChar
@@ -147,17 +148,17 @@ model Workspace {
// 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 business logic. // and for others we will make sure it's has a default value return in our business logic.
model WorkspaceDoc { model WorkspaceDoc {
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
public Boolean @default(false) public Boolean @default(false)
// Workspace user's default role in this page, default is `Manager` // Workspace user's default role in this page, default is `Manager`
defaultRole Int @default(30) @db.SmallInt defaultRole Int @default(30) @db.SmallInt
// Page/Edgeless // Page/Edgeless
mode Int @default(0) @db.SmallInt mode Int @default(0) @db.SmallInt
// Whether the doc is blocked // Whether the doc is blocked
blocked Boolean @default(false) blocked Boolean @default(false)
title String? @db.VarChar title String? @db.VarChar
summary String? @db.VarChar summary String? @db.VarChar
publishedAt DateTime? @map("published_at") @db.Timestamptz(3) publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@ -59,6 +59,7 @@ test('should update workspace', async t => {
const data = { const data = {
public: true, public: true,
enableAi: true, enableAi: true,
enableSharing: false,
enableUrlPreview: true, enableUrlPreview: true,
enableDocEmbedding: false, enableDocEmbedding: false,
}; };
@@ -85,6 +85,26 @@ export async function updateWorkspace(
return res.updateWorkspace.public; return res.updateWorkspace.public;
} }
export async function setWorkspaceSharing(
app: TestingApp,
workspaceId: string,
enableSharing: boolean
) {
const res = await app.gql(
`
mutation {
updateWorkspace(
input: { id: "${workspaceId}", enableSharing: ${enableSharing} }
) {
enableSharing
}
}
`
);
return res.updateWorkspace.enableSharing as boolean;
}
export async function deleteWorkspace( export async function deleteWorkspace(
app: TestingApp, app: TestingApp,
workspaceId: string workspaceId: string
@@ -10,6 +10,7 @@ import {
inviteUser, inviteUser,
publishDoc, publishDoc,
revokePublicDoc, revokePublicDoc,
setWorkspaceSharing,
TestingApp, TestingApp,
updateWorkspace, updateWorkspace,
} from './utils'; } from './utils';
@@ -180,4 +181,17 @@ test('should be able to get public workspace doc', async t => {
.type('application/octet-stream'); .type('application/octet-stream');
t.deepEqual(res.body, Buffer.from([0, 0]), 'failed to get public doc'); t.deepEqual(res.body, Buffer.from([0, 0]), 'failed to get public doc');
const disabled = await setWorkspaceSharing(app, workspace.id, false);
t.false(disabled, 'failed to disable workspace sharing');
// owner should still be able to access
await app
.GET(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.expect(200);
await app.logout();
await app
.GET(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.expect(403);
}); });
@@ -100,6 +100,11 @@ export class DocRendererController {
workspaceId: string, workspaceId: string,
docId: string docId: string
): Promise<RenderOptions | null> { ): Promise<RenderOptions | null> {
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
if (!allowSharing) {
return null;
}
let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId); let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId);
if (!allowUrlPreview) { if (!allowUrlPreview) {
@@ -118,6 +123,11 @@ export class DocRendererController {
private async getWorkspaceContent( private async getWorkspaceContent(
workspaceId: string workspaceId: string
): Promise<RenderOptions | null> { ): Promise<RenderOptions | null> {
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
if (!allowSharing) {
return null;
}
const allowUrlPreview = const allowUrlPreview =
await this.models.workspace.allowUrlPreview(workspaceId); await this.models.workspace.allowUrlPreview(workspaceId);
@@ -21,7 +21,7 @@ let ws: Workspace;
test.before(async () => { test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] }); module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models); models = module.get<Models>(Models);
ac = new DocAccessController(); ac = module.get(DocAccessController);
}); });
test.beforeEach(async () => { test.beforeEach(async () => {
@@ -80,6 +80,20 @@ test('should fallback to [External] if workspace is public', async t => {
t.is(role, WorkspaceRole.External); t.is(role, WorkspaceRole.External);
}); });
test('should return null if workspace is public but sharing disabled', async t => {
await models.workspace.update(ws.id, {
public: true,
enableSharing: false,
});
const role = await ac.getRole({
workspaceId: ws.id,
userId: 'random-user-id',
});
t.is(role, null);
});
test('should return null even workspace has public doc', async t => { test('should return null even workspace has public doc', async t => {
await models.doc.publish(ws.id, 'doc1'); await models.doc.publish(ws.id, 'doc1');
@@ -91,6 +105,18 @@ test('should return null even workspace has public doc', async t => {
t.is(role, null); t.is(role, null);
}); });
test('should return null even workspace has public doc when sharing disabled', async t => {
await models.doc.publish(ws.id, 'doc1');
await models.workspace.update(ws.id, { enableSharing: false });
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 => { test('should return mapped external permission for workspace has public docs', async t => {
await models.doc.publish(ws.id, 'doc1'); await models.doc.publish(ws.id, 'doc1');
@@ -105,6 +131,24 @@ test('should return mapped external permission for workspace has public docs', a
); );
}); });
test('should reject external doc roles when sharing disabled', async t => {
await models.workspace.update(ws.id, {
public: true,
enableSharing: false,
});
const [docRole] = await ac.docRoles(
{
workspaceId: ws.id,
userId: 'random-user-id',
},
['doc1']
);
t.is(docRole.role, null);
t.false(docRole.permissions['Doc.Read']);
});
test('should return mapped permissions', async t => { test('should return mapped permissions', async t => {
const { permissions } = await ac.role({ const { permissions } = await ac.role({
workspaceId: ws.id, workspaceId: ws.id,
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DocActionDenied } from '../../base'; import { DocActionDenied } from '../../base';
import { Models } from '../../models';
import { AccessController, getAccessController } from './controller'; import { AccessController, getAccessController } from './controller';
import type { Resource } from './resource'; import type { Resource } from './resource';
import { import {
@@ -14,14 +15,21 @@ import { WorkspaceAccessController } from './workspace';
@Injectable() @Injectable()
export class DocAccessController extends AccessController<'doc'> { export class DocAccessController extends AccessController<'doc'> {
protected readonly type = 'doc'; protected readonly type = 'doc';
constructor(private readonly models: Models) {
super();
}
async role(resource: Resource<'doc'>) { async role(resource: Resource<'doc'>) {
const role = await this.getRole(resource); const role = await this.getRole(resource);
const permissions = mapDocRoleToPermissions(role);
const sharingAllowed = await this.models.workspace.allowSharing(
resource.workspaceId
);
if (!sharingAllowed) {
permissions['Doc.Publish'] = false;
}
return { return { role, permissions };
role,
permissions: mapDocRoleToPermissions(role),
};
} }
async can(resource: Resource<'doc'>, action: DocAction) { async can(resource: Resource<'doc'>, action: DocAction) {
@@ -27,7 +27,11 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
// NOTE(@forehalo): special case for public page // NOTE(@forehalo): special case for public page
// Currently, we can not only load binary of a public Doc to render in a shared 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. // so we need to ensure anyone has basic 'read' permission to a workspace that has public pages.
if (!role && (await this.models.doc.hasPublic(resource.workspaceId))) { if (
!role &&
(await this.models.workspace.allowSharing(resource.workspaceId)) &&
(await this.models.doc.hasPublic(resource.workspaceId))
) {
role = WorkspaceRole.External; role = WorkspaceRole.External;
} }
@@ -92,6 +96,15 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
} }
const workspaceRole = await this.getRole(payload); const workspaceRole = await this.getRole(payload);
const sharingAllowed = await this.models.workspace.allowSharing(
payload.workspaceId
);
if (
!sharingAllowed &&
(workspaceRole === null || workspaceRole === WorkspaceRole.External)
) {
return docIds.map(() => null);
}
const userRoles = await this.models.docUser.findMany( const userRoles = await this.models.docUser.findMany(
payload.workspaceId, payload.workspaceId,
@@ -190,7 +203,8 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
} }
if (ws.public) { if (ws.public) {
return WorkspaceRole.External; const sharingAllowed = await this.models.workspace.allowSharing(ws.id);
return sharingAllowed ? WorkspaceRole.External : null;
} }
return null; return null;
@@ -56,6 +56,21 @@ class ListWorkspaceInput {
@Field(() => AdminWorkspaceSort, { nullable: true }) @Field(() => AdminWorkspaceSort, { nullable: true })
orderBy?: AdminWorkspaceSort; orderBy?: AdminWorkspaceSort;
@Field({ nullable: true })
public?: boolean;
@Field({ nullable: true })
enableAi?: boolean;
@Field({ nullable: true })
enableSharing?: boolean;
@Field({ nullable: true })
enableUrlPreview?: boolean;
@Field({ nullable: true })
enableDocEmbedding?: boolean;
} }
@ObjectType() @ObjectType()
@@ -111,6 +126,9 @@ export class AdminWorkspace {
@Field() @Field()
enableAi!: boolean; enableAi!: boolean;
@Field()
enableSharing!: boolean;
@Field() @Field()
enableUrlPreview!: boolean; enableUrlPreview!: boolean;
@@ -150,6 +168,7 @@ class AdminUpdateWorkspaceInput extends PartialType(
PickType(AdminWorkspace, [ PickType(AdminWorkspace, [
'public', 'public',
'enableAi', 'enableAi',
'enableSharing',
'enableUrlPreview', 'enableUrlPreview',
'enableDocEmbedding', 'enableDocEmbedding',
'name', 'name',
@@ -183,6 +202,13 @@ export class AdminWorkspaceResolver {
keyword: filter.keyword, keyword: filter.keyword,
features: filter.features, features: filter.features,
order: this.mapSort(filter.orderBy), order: this.mapSort(filter.orderBy),
flags: {
public: filter.public ?? undefined,
enableAi: filter.enableAi ?? undefined,
enableSharing: filter.enableSharing ?? undefined,
enableUrlPreview: filter.enableUrlPreview ?? undefined,
enableDocEmbedding: filter.enableDocEmbedding ?? undefined,
},
includeTotal: false, includeTotal: false,
}); });
return rows; return rows;
@@ -196,6 +222,13 @@ export class AdminWorkspaceResolver {
const total = await this.models.workspace.adminCountWorkspaces({ const total = await this.models.workspace.adminCountWorkspaces({
keyword: filter.keyword, keyword: filter.keyword,
features: filter.features, features: filter.features,
flags: {
public: filter.public ?? undefined,
enableAi: filter.enableAi ?? undefined,
enableSharing: filter.enableSharing ?? undefined,
enableUrlPreview: filter.enableUrlPreview ?? undefined,
enableDocEmbedding: filter.enableDocEmbedding ?? undefined,
},
}); });
return total; return total;
} }
@@ -79,6 +79,9 @@ export class WorkspaceType extends WorkspaceFeatureType {
@Field({ description: 'Enable AI' }) @Field({ description: 'Enable AI' })
enableAi!: boolean; enableAi!: boolean;
@Field({ description: 'Enable workspace sharing' })
enableSharing!: boolean;
@Field({ description: 'Enable url previous when sharing' }) @Field({ description: 'Enable url previous when sharing' })
enableUrlPreview!: boolean; enableUrlPreview!: boolean;
@@ -130,7 +133,13 @@ export class InvitationType {
@InputType() @InputType()
export class UpdateWorkspaceInput extends PickType( export class UpdateWorkspaceInput extends PickType(
PartialType(WorkspaceType), PartialType(WorkspaceType),
['public', 'enableAi', 'enableUrlPreview', 'enableDocEmbedding'], [
'public',
'enableAi',
'enableSharing',
'enableUrlPreview',
'enableDocEmbedding',
],
InputType InputType
) { ) {
@Field(() => ID) @Field(() => ID)
@@ -14,6 +14,7 @@ type RawWorkspaceSummary = {
name: string | null; name: string | null;
avatarKey: string | null; avatarKey: string | null;
enableAi: boolean; enableAi: boolean;
enableSharing: boolean;
enableUrlPreview: boolean; enableUrlPreview: boolean;
enableDocEmbedding: boolean; enableDocEmbedding: boolean;
memberCount: bigint | number | null; memberCount: bigint | number | null;
@@ -36,6 +37,7 @@ export type AdminWorkspaceSummary = {
name: string | null; name: string | null;
avatarKey: string | null; avatarKey: string | null;
enableAi: boolean; enableAi: boolean;
enableSharing: boolean;
enableUrlPreview: boolean; enableUrlPreview: boolean;
enableDocEmbedding: boolean; enableDocEmbedding: boolean;
memberCount: number; memberCount: number;
@@ -67,6 +69,7 @@ export type UpdateWorkspaceInput = Pick<
Partial<Workspace>, Partial<Workspace>,
| 'public' | 'public'
| 'enableAi' | 'enableAi'
| 'enableSharing'
| 'enableUrlPreview' | 'enableUrlPreview'
| 'enableDocEmbedding' | 'enableDocEmbedding'
| 'name' | 'name'
@@ -169,6 +172,11 @@ export class WorkspaceModel extends BaseModel {
return workspace?.enableUrlPreview ?? false; return workspace?.enableUrlPreview ?? false;
} }
async allowSharing(workspaceId: string) {
const workspace = await this.get(workspaceId);
return workspace?.enableSharing ?? true;
}
async allowEmbedding(workspaceId: string) { async allowEmbedding(workspaceId: string) {
const workspace = await this.get(workspaceId); const workspace = await this.get(workspaceId);
return workspace?.enableDocEmbedding ?? false; return workspace?.enableDocEmbedding ?? false;
@@ -185,6 +193,13 @@ export class WorkspaceModel extends BaseModel {
first: number; first: number;
keyword?: string | null; keyword?: string | null;
features?: WorkspaceFeatureName[] | null; features?: WorkspaceFeatureName[] | null;
flags?: {
public?: boolean;
enableAi?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
enableDocEmbedding?: boolean;
};
order?: order?:
| 'createdAt' | 'createdAt'
| 'snapshotSize' | 'snapshotSize'
@@ -197,9 +212,10 @@ export class WorkspaceModel extends BaseModel {
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> { }): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
const keyword = options.keyword?.trim(); const keyword = options.keyword?.trim();
const features = options.features ?? []; const features = options.features ?? [];
const flags = options.flags ?? {};
const includeTotal = options.includeTotal ?? true; const includeTotal = options.includeTotal ?? true;
const total = includeTotal const total = includeTotal
? await this.adminCountWorkspaces({ keyword, features }) ? await this.adminCountWorkspaces({ keyword, features, flags })
: 0; : 0;
if (includeTotal && total === 0) { if (includeTotal && total === 0) {
return { rows: [], total: 0 }; return { rows: [], total: 0 };
@@ -251,6 +267,7 @@ export class WorkspaceModel extends BaseModel {
w.name, w.name,
w.avatar_key AS "avatarKey", w.avatar_key AS "avatarKey",
w.enable_ai AS "enableAi", w.enable_ai AS "enableAi",
w.enable_sharing AS "enableSharing",
w.enable_url_preview AS "enableUrlPreview", w.enable_url_preview AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding", w.enable_doc_embedding AS "enableDocEmbedding",
o.owner_id AS "ownerId", o.owner_id AS "ownerId",
@@ -283,6 +300,14 @@ export class WorkspaceModel extends BaseModel {
` `
: Prisma.sql`TRUE` : Prisma.sql`TRUE`
} }
${
this.buildAdminFlagWhere(flags).length
? Prisma.sql`AND ${Prisma.join(
this.buildAdminFlagWhere(flags),
' AND '
)}`
: Prisma.empty
}
${groupAndHaving} ${groupAndHaving}
) )
SELECT f.*, SELECT f.*,
@@ -307,6 +332,7 @@ export class WorkspaceModel extends BaseModel {
name: row.name, name: row.name,
avatarKey: row.avatarKey, avatarKey: row.avatarKey,
enableAi: row.enableAi, enableAi: row.enableAi,
enableSharing: row.enableSharing,
enableUrlPreview: row.enableUrlPreview, enableUrlPreview: row.enableUrlPreview,
enableDocEmbedding: row.enableDocEmbedding, enableDocEmbedding: row.enableDocEmbedding,
memberCount: Number(row.memberCount ?? 0), memberCount: Number(row.memberCount ?? 0),
@@ -332,9 +358,17 @@ export class WorkspaceModel extends BaseModel {
async adminCountWorkspaces(options: { async adminCountWorkspaces(options: {
keyword?: string | null; keyword?: string | null;
features?: WorkspaceFeatureName[] | null; features?: WorkspaceFeatureName[] | null;
flags?: {
public?: boolean;
enableAi?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
enableDocEmbedding?: boolean;
};
}) { }) {
const keyword = options.keyword?.trim(); const keyword = options.keyword?.trim();
const features = options.features ?? []; const features = options.features ?? [];
const flags = options.flags ?? {};
const featuresHaving = const featuresHaving =
features.length > 0 features.length > 0
@@ -393,6 +427,14 @@ export class WorkspaceModel extends BaseModel {
` `
: Prisma.sql`TRUE` : Prisma.sql`TRUE`
} }
${
this.buildAdminFlagWhere(flags).length
? Prisma.sql`AND ${Prisma.join(
this.buildAdminFlagWhere(flags),
' AND '
)}`
: Prisma.empty
}
${groupAndHaving} ${groupAndHaving}
) )
SELECT COUNT(*) AS total FROM filtered SELECT COUNT(*) AS total FROM filtered
@@ -401,6 +443,36 @@ export class WorkspaceModel extends BaseModel {
return row?.total ? Number(row.total) : 0; return row?.total ? Number(row.total) : 0;
} }
private buildAdminFlagWhere(flags: {
public?: boolean;
enableAi?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
enableDocEmbedding?: boolean;
}) {
const conditions: Prisma.Sql[] = [];
if (flags.public !== undefined) {
conditions.push(Prisma.sql`w.public = ${flags.public}`);
}
if (flags.enableAi !== undefined) {
conditions.push(Prisma.sql`w.enable_ai = ${flags.enableAi}`);
}
if (flags.enableSharing !== undefined) {
conditions.push(Prisma.sql`w.enable_sharing = ${flags.enableSharing}`);
}
if (flags.enableUrlPreview !== undefined) {
conditions.push(
Prisma.sql`w.enable_url_preview = ${flags.enableUrlPreview}`
);
}
if (flags.enableDocEmbedding !== undefined) {
conditions.push(
Prisma.sql`w.enable_doc_embedding = ${flags.enableDocEmbedding}`
);
}
return conditions;
}
private buildAdminOrder( private buildAdminOrder(
order?: order?:
| 'createdAt' | 'createdAt'
+13
View File
@@ -35,6 +35,7 @@ input AdminUpdateWorkspaceInput {
avatarKey: String avatarKey: String
enableAi: Boolean enableAi: Boolean
enableDocEmbedding: Boolean enableDocEmbedding: Boolean
enableSharing: Boolean
enableUrlPreview: Boolean enableUrlPreview: Boolean
features: [FeatureType!] features: [FeatureType!]
id: String! id: String!
@@ -49,6 +50,7 @@ type AdminWorkspace {
createdAt: DateTime! createdAt: DateTime!
enableAi: Boolean! enableAi: Boolean!
enableDocEmbedding: Boolean! enableDocEmbedding: Boolean!
enableSharing: Boolean!
enableUrlPreview: Boolean! enableUrlPreview: Boolean!
features: [FeatureType!]! features: [FeatureType!]!
id: String! id: String!
@@ -1226,10 +1228,15 @@ input ListUserInput {
} }
input ListWorkspaceInput { input ListWorkspaceInput {
enableAi: Boolean
enableDocEmbedding: Boolean
enableSharing: Boolean
enableUrlPreview: Boolean
features: [FeatureType!] features: [FeatureType!]
first: Int! = 20 first: Int! = 20
keyword: String keyword: String
orderBy: AdminWorkspaceSort orderBy: AdminWorkspaceSort
public: Boolean
skip: Int! = 0 skip: Int! = 0
} }
@@ -2207,6 +2214,9 @@ input UpdateWorkspaceInput {
"""Enable doc embedding""" """Enable doc embedding"""
enableDocEmbedding: Boolean enableDocEmbedding: Boolean
"""Enable workspace sharing"""
enableSharing: Boolean
"""Enable url previous when sharing""" """Enable url previous when sharing"""
enableUrlPreview: Boolean enableUrlPreview: Boolean
id: ID! id: ID!
@@ -2432,6 +2442,9 @@ type WorkspaceType {
"""Enable doc embedding""" """Enable doc embedding"""
enableDocEmbedding: Boolean! enableDocEmbedding: Boolean!
"""Enable workspace sharing"""
enableSharing: Boolean!
"""Enable url previous when sharing""" """Enable url previous when sharing"""
enableUrlPreview: Boolean! enableUrlPreview: Boolean!
histories(before: DateTime, guid: String!, take: Int): [DocHistoryType!]! histories(before: DateTime, guid: String!, take: Int): [DocHistoryType!]!
@@ -6,6 +6,7 @@ mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) {
name name
avatarKey avatarKey
enableAi enableAi
enableSharing
enableUrlPreview enableUrlPreview
enableDocEmbedding enableDocEmbedding
features features
@@ -11,6 +11,7 @@ query adminWorkspace(
name name
avatarKey avatarKey
enableAi enableAi
enableSharing
enableUrlPreview enableUrlPreview
enableDocEmbedding enableDocEmbedding
features features
@@ -6,6 +6,7 @@ query adminWorkspaces($filter: ListWorkspaceInput!) {
name name
avatarKey avatarKey
enableAi enableAi
enableSharing
enableUrlPreview enableUrlPreview
enableDocEmbedding enableDocEmbedding
features features
@@ -145,6 +145,7 @@ export const adminUpdateWorkspaceMutation = {
name name
avatarKey avatarKey
enableAi enableAi
enableSharing
enableUrlPreview enableUrlPreview
enableDocEmbedding enableDocEmbedding
features features
@@ -175,6 +176,7 @@ export const adminWorkspaceQuery = {
name name
avatarKey avatarKey
enableAi enableAi
enableSharing
enableUrlPreview enableUrlPreview
enableDocEmbedding enableDocEmbedding
features features
@@ -218,6 +220,7 @@ export const adminWorkspacesQuery = {
name name
avatarKey avatarKey
enableAi enableAi
enableSharing
enableUrlPreview enableUrlPreview
enableDocEmbedding enableDocEmbedding
features features
@@ -2545,6 +2548,7 @@ export const getWorkspaceConfigQuery = {
query: `query getWorkspaceConfig($id: String!) { query: `query getWorkspaceConfig($id: String!) {
workspace(id: $id) { workspace(id: $id) {
enableAi enableAi
enableSharing
enableUrlPreview enableUrlPreview
enableDocEmbedding enableDocEmbedding
inviteLink { inviteLink {
@@ -2575,6 +2579,16 @@ export const setEnableDocEmbeddingMutation = {
}`, }`,
}; };
export const setEnableSharingMutation = {
id: 'setEnableSharingMutation' as const,
op: 'setEnableSharing',
query: `mutation setEnableSharing($id: ID!, $enableSharing: Boolean!) {
updateWorkspace(input: {id: $id, enableSharing: $enableSharing}) {
id
}
}`,
};
export const setEnableUrlPreviewMutation = { export const setEnableUrlPreviewMutation = {
id: 'setEnableUrlPreviewMutation' as const, id: 'setEnableUrlPreviewMutation' as const,
op: 'setEnableUrlPreview', op: 'setEnableUrlPreview',
@@ -1,6 +1,7 @@
query getWorkspaceConfig($id: String!) { query getWorkspaceConfig($id: String!) {
workspace(id: $id) { workspace(id: $id) {
enableAi enableAi
enableSharing
enableUrlPreview enableUrlPreview
enableDocEmbedding enableDocEmbedding
inviteLink { inviteLink {
@@ -0,0 +1,5 @@
mutation setEnableSharing($id: ID!, $enableSharing: Boolean!) {
updateWorkspace(input: { id: $id, enableSharing: $enableSharing }) {
id
}
}
+30
View File
@@ -71,6 +71,7 @@ export interface AdminUpdateWorkspaceInput {
avatarKey?: InputMaybe<Scalars['String']['input']>; avatarKey?: InputMaybe<Scalars['String']['input']>;
enableAi?: InputMaybe<Scalars['Boolean']['input']>; enableAi?: InputMaybe<Scalars['Boolean']['input']>;
enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>; enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>;
enableSharing?: InputMaybe<Scalars['Boolean']['input']>;
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>; enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
features?: InputMaybe<Array<FeatureType>>; features?: InputMaybe<Array<FeatureType>>;
id: Scalars['String']['input']; id: Scalars['String']['input'];
@@ -86,6 +87,7 @@ export interface AdminWorkspace {
createdAt: Scalars['DateTime']['output']; createdAt: Scalars['DateTime']['output'];
enableAi: Scalars['Boolean']['output']; enableAi: Scalars['Boolean']['output'];
enableDocEmbedding: Scalars['Boolean']['output']; enableDocEmbedding: Scalars['Boolean']['output'];
enableSharing: Scalars['Boolean']['output'];
enableUrlPreview: Scalars['Boolean']['output']; enableUrlPreview: Scalars['Boolean']['output'];
features: Array<FeatureType>; features: Array<FeatureType>;
id: Scalars['String']['output']; id: Scalars['String']['output'];
@@ -1411,10 +1413,15 @@ export interface ListUserInput {
} }
export interface ListWorkspaceInput { export interface ListWorkspaceInput {
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>;
enableSharing?: InputMaybe<Scalars['Boolean']['input']>;
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
features?: InputMaybe<Array<FeatureType>>; features?: InputMaybe<Array<FeatureType>>;
first?: Scalars['Int']['input']; first?: Scalars['Int']['input'];
keyword?: InputMaybe<Scalars['String']['input']>; keyword?: InputMaybe<Scalars['String']['input']>;
orderBy?: InputMaybe<AdminWorkspaceSort>; orderBy?: InputMaybe<AdminWorkspaceSort>;
public?: InputMaybe<Scalars['Boolean']['input']>;
skip?: Scalars['Int']['input']; skip?: Scalars['Int']['input'];
} }
@@ -2881,6 +2888,8 @@ export interface UpdateWorkspaceInput {
enableAi?: InputMaybe<Scalars['Boolean']['input']>; enableAi?: InputMaybe<Scalars['Boolean']['input']>;
/** Enable doc embedding */ /** Enable doc embedding */
enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>; enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>;
/** Enable workspace sharing */
enableSharing?: InputMaybe<Scalars['Boolean']['input']>;
/** Enable url previous when sharing */ /** Enable url previous when sharing */
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>; enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
id: Scalars['ID']['input']; id: Scalars['ID']['input'];
@@ -3115,6 +3124,8 @@ export interface WorkspaceType {
enableAi: Scalars['Boolean']['output']; enableAi: Scalars['Boolean']['output'];
/** Enable doc embedding */ /** Enable doc embedding */
enableDocEmbedding: Scalars['Boolean']['output']; enableDocEmbedding: Scalars['Boolean']['output'];
/** Enable workspace sharing */
enableSharing: Scalars['Boolean']['output'];
/** Enable url previous when sharing */ /** Enable url previous when sharing */
enableUrlPreview: Scalars['Boolean']['output']; enableUrlPreview: Scalars['Boolean']['output'];
histories: Array<DocHistoryType>; histories: Array<DocHistoryType>;
@@ -3332,6 +3343,7 @@ export type AdminUpdateWorkspaceMutation = {
name: string | null; name: string | null;
avatarKey: string | null; avatarKey: string | null;
enableAi: boolean; enableAi: boolean;
enableSharing: boolean;
enableUrlPreview: boolean; enableUrlPreview: boolean;
enableDocEmbedding: boolean; enableDocEmbedding: boolean;
features: Array<FeatureType>; features: Array<FeatureType>;
@@ -3368,6 +3380,7 @@ export type AdminWorkspaceQuery = {
name: string | null; name: string | null;
avatarKey: string | null; avatarKey: string | null;
enableAi: boolean; enableAi: boolean;
enableSharing: boolean;
enableUrlPreview: boolean; enableUrlPreview: boolean;
enableDocEmbedding: boolean; enableDocEmbedding: boolean;
features: Array<FeatureType>; features: Array<FeatureType>;
@@ -3416,6 +3429,7 @@ export type AdminWorkspacesQuery = {
name: string | null; name: string | null;
avatarKey: string | null; avatarKey: string | null;
enableAi: boolean; enableAi: boolean;
enableSharing: boolean;
enableUrlPreview: boolean; enableUrlPreview: boolean;
enableDocEmbedding: boolean; enableDocEmbedding: boolean;
features: Array<FeatureType>; features: Array<FeatureType>;
@@ -6534,6 +6548,7 @@ export type GetWorkspaceConfigQuery = {
workspace: { workspace: {
__typename?: 'WorkspaceType'; __typename?: 'WorkspaceType';
enableAi: boolean; enableAi: boolean;
enableSharing: boolean;
enableUrlPreview: boolean; enableUrlPreview: boolean;
enableDocEmbedding: boolean; enableDocEmbedding: boolean;
inviteLink: { inviteLink: {
@@ -6564,6 +6579,16 @@ export type SetEnableDocEmbeddingMutation = {
updateWorkspace: { __typename?: 'WorkspaceType'; id: string }; updateWorkspace: { __typename?: 'WorkspaceType'; id: string };
}; };
export type SetEnableSharingMutationVariables = Exact<{
id: Scalars['ID']['input'];
enableSharing: Scalars['Boolean']['input'];
}>;
export type SetEnableSharingMutation = {
__typename?: 'Mutation';
updateWorkspace: { __typename?: 'WorkspaceType'; id: string };
};
export type SetEnableUrlPreviewMutationVariables = Exact<{ export type SetEnableUrlPreviewMutationVariables = Exact<{
id: Scalars['ID']['input']; id: Scalars['ID']['input'];
enableUrlPreview: Scalars['Boolean']['input']; enableUrlPreview: Scalars['Boolean']['input'];
@@ -7587,6 +7612,11 @@ export type Mutations =
variables: SetEnableDocEmbeddingMutationVariables; variables: SetEnableDocEmbeddingMutationVariables;
response: SetEnableDocEmbeddingMutation; response: SetEnableDocEmbeddingMutation;
} }
| {
name: 'setEnableSharingMutation';
variables: SetEnableSharingMutationVariables;
response: SetEnableSharingMutation;
}
| { | {
name: 'setEnableUrlPreviewMutation'; name: 'setEnableUrlPreviewMutation';
variables: SetEnableUrlPreviewMutationVariables; variables: SetEnableUrlPreviewMutationVariables;
@@ -18,6 +18,7 @@ import {
} from '../../../components/ui/popover'; } from '../../../components/ui/popover';
import { useDebouncedValue } from '../../../hooks/use-debounced-value'; import { useDebouncedValue } from '../../../hooks/use-debounced-value';
import { useServerConfig } from '../../common'; import { useServerConfig } from '../../common';
import type { WorkspaceFlagFilter } from '../schema';
interface DataTableToolbarProps<TData> { interface DataTableToolbarProps<TData> {
table?: Table<TData>; table?: Table<TData>;
@@ -25,19 +26,21 @@ interface DataTableToolbarProps<TData> {
onKeywordChange: (keyword: string) => void; onKeywordChange: (keyword: string) => void;
selectedFeatures: FeatureType[]; selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void; onFeaturesChange: (features: FeatureType[]) => void;
flags: WorkspaceFlagFilter;
onFlagsChange: (flags: WorkspaceFlagFilter) => void;
sort: AdminWorkspaceSort | undefined; sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void; onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
disabled?: boolean; disabled?: boolean;
} }
const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [ const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
{ value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' }, { value: AdminWorkspaceSort.CreatedAt, label: 'Created time' },
{ value: AdminWorkspaceSort.BlobCount, label: 'Blob count' }, { value: AdminWorkspaceSort.BlobCount, label: 'Blob count' },
{ value: AdminWorkspaceSort.BlobSize, label: 'Blob size' }, { value: AdminWorkspaceSort.BlobSize, label: 'Blob size' },
{ value: AdminWorkspaceSort.SnapshotCount, label: 'Snapshot count' }, { value: AdminWorkspaceSort.SnapshotCount, label: 'Snapshot count' },
{ value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' },
{ value: AdminWorkspaceSort.MemberCount, label: 'Member count' }, { value: AdminWorkspaceSort.MemberCount, label: 'Member count' },
{ value: AdminWorkspaceSort.PublicPageCount, label: 'Public pages' }, { value: AdminWorkspaceSort.PublicPageCount, label: 'Public pages' },
{ value: AdminWorkspaceSort.CreatedAt, label: 'Created time' },
]; ];
export function DataTableToolbar<TData>({ export function DataTableToolbar<TData>({
@@ -45,6 +48,8 @@ export function DataTableToolbar<TData>({
onKeywordChange, onKeywordChange,
selectedFeatures, selectedFeatures,
onFeaturesChange, onFeaturesChange,
flags,
onFlagsChange,
sort, sort,
onSortChange, onSortChange,
disabled = false, disabled = false,
@@ -80,6 +85,35 @@ export function DataTableToolbar<TData>({
[sort] [sort]
); );
const flagOptions: { key: keyof WorkspaceFlagFilter; label: string }[] = [
{ key: 'public', label: 'Public' },
{ key: 'enableSharing', label: 'Enable sharing' },
{ key: 'enableAi', label: 'Enable AI' },
{ key: 'enableUrlPreview', label: 'Enable URL preview' },
{ key: 'enableDocEmbedding', label: 'Enable doc embedding' },
];
const flagLabel = (value: boolean | undefined) => {
if (value === true) return 'On';
if (value === false) return 'Off';
return 'Any';
};
const handleFlagToggle = useCallback(
(key: keyof WorkspaceFlagFilter) => {
const current = flags[key];
const next =
current === undefined ? true : current === true ? false : undefined;
onFlagsChange({ ...flags, [key]: next });
},
[flags, onFlagsChange]
);
const hasFlagFilter = useMemo(
() => Object.values(flags).some(v => v !== undefined),
[flags]
);
return ( return (
<div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap"> <div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap">
<FeatureFilterPopover <FeatureFilterPopover
@@ -119,6 +153,37 @@ export function DataTableToolbar<TData>({
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Popover open={disabled ? false : undefined}>
<PopoverTrigger asChild>
<Button
variant={hasFlagFilter ? 'secondary' : 'outline'}
size="sm"
className="h-8 px-2 lg:px-3"
disabled={disabled}
>
Flags
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-2">
<div className="flex flex-col gap-1">
{flagOptions.map(option => (
<Button
key={option.key}
variant="ghost"
className="justify-between"
size="sm"
disabled={disabled}
onClick={() => handleFlagToggle(option.key)}
>
<span>{option.label}</span>
<span className="text-xs text-muted-foreground">
{flagLabel(flags[option.key])}
</span>
</Button>
))}
</div>
</PopoverContent>
</Popover>
<div className="flex"> <div className="flex">
<Input <Input
placeholder="Search Workspace / Owner" placeholder="Search Workspace / Owner"
@@ -3,6 +3,7 @@ import type { ColumnDef, PaginationState } from '@tanstack/react-table';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { SharedDataTable } from '../../../components/shared/data-table'; import { SharedDataTable } from '../../../components/shared/data-table';
import type { WorkspaceFlagFilter } from '../schema';
import { DataTableToolbar } from './data-table-toolbar'; import { DataTableToolbar } from './data-table-toolbar';
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
@@ -14,6 +15,8 @@ interface DataTableProps<TData, TValue> {
onKeywordChange: (value: string) => void; onKeywordChange: (value: string) => void;
selectedFeatures: FeatureType[]; selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void; onFeaturesChange: (features: FeatureType[]) => void;
flags: WorkspaceFlagFilter;
onFlagsChange: Dispatch<SetStateAction<WorkspaceFlagFilter>>;
sort: AdminWorkspaceSort | undefined; sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void; onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
loading?: boolean; loading?: boolean;
@@ -34,6 +37,8 @@ export function DataTable<TData extends { id: string }, TValue>({
onKeywordChange, onKeywordChange,
selectedFeatures, selectedFeatures,
onFeaturesChange, onFeaturesChange,
flags,
onFlagsChange,
sort, sort,
onSortChange, onSortChange,
onPaginationChange, onPaginationChange,
@@ -46,7 +51,7 @@ export function DataTable<TData extends { id: string }, TValue>({
totalCount={workspacesCount} totalCount={workspacesCount}
pagination={pagination} pagination={pagination}
onPaginationChange={onPaginationChange} onPaginationChange={onPaginationChange}
resetFiltersDeps={[keyword, selectedFeatures, sort]} resetFiltersDeps={[keyword, selectedFeatures, sort, flags]}
renderToolbar={table => ( renderToolbar={table => (
<DataTableToolbar <DataTableToolbar
table={table} table={table}
@@ -54,6 +59,8 @@ export function DataTable<TData extends { id: string }, TValue>({
onKeywordChange={onKeywordChange} onKeywordChange={onKeywordChange}
selectedFeatures={selectedFeatures} selectedFeatures={selectedFeatures}
onFeaturesChange={onFeaturesChange} onFeaturesChange={onFeaturesChange}
flags={flags}
onFlagsChange={onFlagsChange}
sort={sort} sort={sort}
onSortChange={onSortChange} onSortChange={onSortChange}
disabled={loading} disabled={loading}
@@ -86,6 +86,7 @@ function WorkspacePanelContent({
flags: { flags: {
public: workspace.public, public: workspace.public,
enableAi: workspace.enableAi, enableAi: workspace.enableAi,
enableSharing: workspace.enableSharing,
enableUrlPreview: workspace.enableUrlPreview, enableUrlPreview: workspace.enableUrlPreview,
enableDocEmbedding: workspace.enableDocEmbedding, enableDocEmbedding: workspace.enableDocEmbedding,
name: workspace.name ?? '', name: workspace.name ?? '',
@@ -110,6 +111,7 @@ function WorkspacePanelContent({
return ( return (
flags.public !== baseline.flags.public || flags.public !== baseline.flags.public ||
flags.enableAi !== baseline.flags.enableAi || flags.enableAi !== baseline.flags.enableAi ||
flags.enableSharing !== baseline.flags.enableSharing ||
flags.enableUrlPreview !== baseline.flags.enableUrlPreview || flags.enableUrlPreview !== baseline.flags.enableUrlPreview ||
flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding || flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding ||
flags.name !== baseline.flags.name || flags.name !== baseline.flags.name ||
@@ -134,6 +136,7 @@ function WorkspacePanelContent({
id: workspace.id, id: workspace.id,
public: flags.public, public: flags.public,
enableAi: flags.enableAi, enableAi: flags.enableAi,
enableSharing: flags.enableSharing,
enableUrlPreview: flags.enableUrlPreview, enableUrlPreview: flags.enableUrlPreview,
enableDocEmbedding: flags.enableDocEmbedding, enableDocEmbedding: flags.enableDocEmbedding,
name: flags.name || null, name: flags.name || null,
@@ -231,6 +234,15 @@ function WorkspacePanelContent({
} }
/> />
<Separator /> <Separator />
<FlagItem
label="Allow Workspace Sharing"
description="Allow pages in this workspace to be shared publicly"
checked={flags.enableSharing}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableSharing: value }))
}
/>
<Separator />
<FlagItem <FlagItem
label="Enable Doc Embedding" label="Enable Doc Embedding"
description="Allow document embedding for search" description="Allow document embedding for search"
@@ -4,11 +4,13 @@ import { useState } from 'react';
import { Header } from '../header'; import { Header } from '../header';
import { useColumns } from './components/columns'; import { useColumns } from './components/columns';
import { DataTable } from './components/data-table'; import { DataTable } from './components/data-table';
import type { WorkspaceFlagFilter } from './schema';
import { useWorkspaceList } from './use-workspace-list'; import { useWorkspaceList } from './use-workspace-list';
export function WorkspacePage() { export function WorkspacePage() {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]); const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
const [flagFilters, setFlagFilters] = useState<WorkspaceFlagFilter>({});
const [sort, setSort] = useState<AdminWorkspaceSort | undefined>( const [sort, setSort] = useState<AdminWorkspaceSort | undefined>(
AdminWorkspaceSort.CreatedAt AdminWorkspaceSort.CreatedAt
); );
@@ -18,6 +20,7 @@ export function WorkspacePage() {
keyword, keyword,
features: featureFilters, features: featureFilters,
orderBy: sort, orderBy: sort,
flags: flagFilters,
}); });
const columns = useColumns(); const columns = useColumns();
@@ -36,6 +39,8 @@ export function WorkspacePage() {
onKeywordChange={setKeyword} onKeywordChange={setKeyword}
selectedFeatures={featureFilters} selectedFeatures={featureFilters}
onFeaturesChange={setFeatureFilters} onFeaturesChange={setFeatureFilters}
flags={flagFilters}
onFlagsChange={setFlagFilters}
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
loading={loading} loading={loading}
@@ -16,3 +16,11 @@ export type WorkspaceUpdateInput =
AdminUpdateWorkspaceMutation['adminUpdateWorkspace']; AdminUpdateWorkspaceMutation['adminUpdateWorkspace'];
export type WorkspaceFeatureFilter = FeatureType[]; export type WorkspaceFeatureFilter = FeatureType[];
export type WorkspaceFlagFilter = {
public?: boolean;
enableAi?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
enableDocEmbedding?: boolean;
};
@@ -7,10 +7,13 @@ import {
} from '@affine/graphql'; } from '@affine/graphql';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { WorkspaceFlagFilter } from './schema';
export const useWorkspaceList = (filter?: { export const useWorkspaceList = (filter?: {
keyword?: string; keyword?: string;
features?: FeatureType[]; features?: FeatureType[];
orderBy?: AdminWorkspaceSort; orderBy?: AdminWorkspaceSort;
flags?: WorkspaceFlagFilter;
}) => { }) => {
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
@@ -21,8 +24,10 @@ export const useWorkspaceList = (filter?: {
() => () =>
`${filter?.keyword ?? ''}-${[...(filter?.features ?? [])] `${filter?.keyword ?? ''}-${[...(filter?.features ?? [])]
.sort() .sort()
.join(',')}-${filter?.orderBy ?? ''}`, .join(',')}-${filter?.orderBy ?? ''}-${JSON.stringify(
[filter?.features, filter?.keyword, filter?.orderBy] filter?.flags ?? {}
)}`,
[filter?.features, filter?.flags, filter?.keyword, filter?.orderBy]
); );
useEffect(() => { useEffect(() => {
@@ -40,10 +45,20 @@ export const useWorkspaceList = (filter?: {
? filter.features ? filter.features
: undefined, : undefined,
orderBy: filter?.orderBy, orderBy: filter?.orderBy,
public: filter?.flags?.public,
enableAi: filter?.flags?.enableAi,
enableSharing: filter?.flags?.enableSharing,
enableUrlPreview: filter?.flags?.enableUrlPreview,
enableDocEmbedding: filter?.flags?.enableDocEmbedding,
}, },
}), }),
[ [
filter?.features, filter?.features,
filter?.flags?.enableAi,
filter?.flags?.enableDocEmbedding,
filter?.flags?.enableSharing,
filter?.flags?.enableUrlPreview,
filter?.flags?.public,
filter?.keyword, filter?.keyword,
filter?.orderBy, filter?.orderBy,
pagination.pageIndex, pagination.pageIndex,
@@ -21,11 +21,19 @@ export const SharingPanel = () => {
export const Sharing = () => { export const Sharing = () => {
const t = useI18n(); const t = useI18n();
const shareSetting = useService(WorkspaceShareSettingService).sharePreview; const shareSetting = useService(WorkspaceShareSettingService).sharePreview;
const enableSharing = useLiveData(shareSetting.enableSharing$);
const enableUrlPreview = useLiveData(shareSetting.enableUrlPreview$); const enableUrlPreview = useLiveData(shareSetting.enableUrlPreview$);
const loading = useLiveData(shareSetting.isLoading$); const loading = useLiveData(shareSetting.isLoading$);
const permissionService = useService(WorkspacePermissionService); const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$); const isOwner = useLiveData(permissionService.permission.isOwner$);
const handleToggleSharing = useAsyncCallback(
async (checked: boolean) => {
await shareSetting.setEnableSharing(checked);
},
[shareSetting]
);
const handleCheck = useAsyncCallback( const handleCheck = useAsyncCallback(
async (checked: boolean) => { async (checked: boolean) => {
await shareSetting.setEnableUrlPreview(checked); await shareSetting.setEnableUrlPreview(checked);
@@ -51,6 +59,20 @@ export const Sharing = () => {
disabled={loading} disabled={loading}
/> />
</SettingRow> </SettingRow>
<SettingRow
name={t[
'com.affine.settings.workspace.sharing.workspace-sharing.title'
]()}
desc={t[
'com.affine.settings.workspace.sharing.workspace-sharing.description'
]()}
>
<Switch
checked={enableSharing ?? true}
onChange={handleToggleSharing}
disabled={loading}
/>
</SettingRow>
</SettingWrapper> </SettingWrapper>
); );
}; };
@@ -1,8 +1,11 @@
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud'; import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { WorkspaceShareSettingService } from '@affine/core/modules/share-setting';
import type { Workspace } from '@affine/core/modules/workspace'; import type { Workspace } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import type { Store } from '@blocksuite/affine/store'; import type { Store } from '@blocksuite/affine/store';
import { useCallback } from 'react'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import { ShareMenu } from './share-menu'; import { ShareMenu } from './share-menu';
export { CloudSvg } from './cloud-svg'; export { CloudSvg } from './cloud-svg';
@@ -14,6 +17,10 @@ type SharePageModalProps = {
}; };
export const SharePageButton = ({ workspace, page }: SharePageModalProps) => { export const SharePageButton = ({ workspace, page }: SharePageModalProps) => {
const t = useI18n();
const shareSetting = useService(WorkspaceShareSettingService).sharePreview;
const enableSharing = useLiveData(shareSetting.enableSharing$);
const confirmEnableCloud = useEnableCloud(); const confirmEnableCloud = useEnableCloud();
const handleOpenShareModal = useCallback((open: boolean) => { const handleOpenShareModal = useCallback((open: boolean) => {
if (open) { if (open) {
@@ -21,6 +28,18 @@ export const SharePageButton = ({ workspace, page }: SharePageModalProps) => {
} }
}, []); }, []);
useEffect(() => {
if (workspace.meta.flavour === 'local') {
return;
}
shareSetting.revalidate();
}, [shareSetting, workspace.meta.flavour]);
const sharingDisabled = enableSharing === false;
const disabledReason = sharingDisabled
? t['com.affine.share-menu.workspace-sharing.disabled.tooltip']()
: undefined;
return ( return (
<ShareMenu <ShareMenu
workspaceMetadata={workspace.meta} workspaceMetadata={workspace.meta}
@@ -31,6 +50,8 @@ export const SharePageButton = ({ workspace, page }: SharePageModalProps) => {
}) })
} }
onOpenShareModal={handleOpenShareModal} onOpenShareModal={handleOpenShareModal}
disabled={sharingDisabled}
disabledReason={disabledReason}
/> />
); );
}; };
@@ -35,6 +35,8 @@ export interface ShareMenuProps extends PropsWithChildren {
onOpenShareModal?: (open: boolean) => void; onOpenShareModal?: (open: boolean) => void;
openPaywallModal?: () => void; openPaywallModal?: () => void;
hittingPaywall?: boolean; hittingPaywall?: boolean;
disabled?: boolean;
disabledReason?: string;
} }
export enum ShareMenuTab { export enum ShareMenuTab {
@@ -203,7 +205,7 @@ export const ShareMenuContent = (props: ShareMenuProps) => {
}; };
const DefaultShareButton = forwardRef(function DefaultShareButton( const DefaultShareButton = forwardRef(function DefaultShareButton(
_, props: { disabled?: boolean; tooltip?: string },
ref: Ref<HTMLButtonElement> ref: Ref<HTMLButtonElement>
) { ) {
const t = useI18n(); const t = useI18n();
@@ -211,18 +213,26 @@ const DefaultShareButton = forwardRef(function DefaultShareButton(
const shared = useLiveData(shareInfoService.shareInfo.isShared$); const shared = useLiveData(shareInfoService.shareInfo.isShared$);
useEffect(() => { useEffect(() => {
if (props.disabled) {
return;
}
shareInfoService.shareInfo.revalidate(); shareInfoService.shareInfo.revalidate();
}, [shareInfoService]); }, [props.disabled, shareInfoService]);
const tooltip =
props.tooltip ??
(shared
? t['com.affine.share-menu.option.link.readonly.description']()
: t['com.affine.share-menu.option.link.no-access.description']());
return ( return (
<Tooltip <Tooltip content={tooltip}>
content={ <Button
shared ref={ref}
? t['com.affine.share-menu.option.link.readonly.description']() className={styles.button}
: t['com.affine.share-menu.option.link.no-access.description']() variant="primary"
} disabled={props.disabled}
> >
<Button ref={ref} className={styles.button} variant="primary">
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
{shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />} {shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />}
{t['com.affine.share-menu.shareButton']()} {t['com.affine.share-menu.shareButton']()}
@@ -233,6 +243,13 @@ const DefaultShareButton = forwardRef(function DefaultShareButton(
}); });
const LocalShareMenu = (props: ShareMenuProps) => { const LocalShareMenu = (props: ShareMenuProps) => {
if (props.disabled) {
return (
<div data-testid="local-share-menu-button">
<DefaultShareButton disabled tooltip={props.disabledReason} />
</div>
);
}
return ( return (
<Menu <Menu
items={<ShareMenuContent {...props} />} items={<ShareMenuContent {...props} />}
@@ -254,6 +271,13 @@ const LocalShareMenu = (props: ShareMenuProps) => {
}; };
const CloudShareMenu = (props: ShareMenuProps) => { const CloudShareMenu = (props: ShareMenuProps) => {
if (props.disabled) {
return (
<div data-testid="cloud-share-menu-button">
<DefaultShareButton disabled tooltip={props.disabledReason} />
</div>
);
}
return ( return (
<Menu <Menu
items={<ShareMenuContent {...props} />} items={<ShareMenuContent {...props} />}
@@ -16,6 +16,7 @@ import type { WorkspaceService } from '../../workspace';
import type { WorkspaceShareSettingStore } from '../stores/share-setting'; import type { WorkspaceShareSettingStore } from '../stores/share-setting';
type EnableAi = GetWorkspaceConfigQuery['workspace']['enableAi']; type EnableAi = GetWorkspaceConfigQuery['workspace']['enableAi'];
type EnableSharing = GetWorkspaceConfigQuery['workspace']['enableSharing'];
type EnableUrlPreview = type EnableUrlPreview =
GetWorkspaceConfigQuery['workspace']['enableUrlPreview']; GetWorkspaceConfigQuery['workspace']['enableUrlPreview'];
@@ -23,6 +24,7 @@ const logger = new DebugLogger('affine:workspace-permission');
export class WorkspaceShareSetting extends Entity { export class WorkspaceShareSetting extends Entity {
enableAi$ = new LiveData<EnableAi | null>(null); enableAi$ = new LiveData<EnableAi | null>(null);
enableSharing$ = new LiveData<EnableSharing | null>(null);
enableUrlPreview$ = new LiveData<EnableUrlPreview | null>(null); enableUrlPreview$ = new LiveData<EnableUrlPreview | null>(null);
inviteLink$ = new LiveData<InviteLink | null>(null); inviteLink$ = new LiveData<InviteLink | null>(null);
isLoading$ = new LiveData(false); isLoading$ = new LiveData(false);
@@ -48,12 +50,13 @@ export class WorkspaceShareSetting extends Entity {
tap(value => { tap(value => {
if (value) { if (value) {
this.enableAi$.next(value.enableAi); this.enableAi$.next(value.enableAi);
this.enableSharing$.next(value.enableSharing);
this.enableUrlPreview$.next(value.enableUrlPreview); this.enableUrlPreview$.next(value.enableUrlPreview);
this.inviteLink$.next(value.inviteLink); this.inviteLink$.next(value.inviteLink);
} }
}), }),
catchErrorInto(this.error$, error => { catchErrorInto(this.error$, error => {
logger.error('Failed to fetch enableUrlPreview', error); logger.error('Failed to fetch workspace share settings', error);
}), }),
onStart(() => this.isLoading$.setValue(true)), onStart(() => this.isLoading$.setValue(true)),
onComplete(() => this.isLoading$.setValue(false)) onComplete(() => this.isLoading$.setValue(false))
@@ -74,6 +77,14 @@ export class WorkspaceShareSetting extends Entity {
await this.waitForRevalidation(); await this.waitForRevalidation();
} }
async setEnableSharing(enableSharing: EnableSharing) {
await this.store.updateWorkspaceEnableSharing(
this.workspaceService.workspace.id,
enableSharing
);
await this.waitForRevalidation();
}
async setEnableAi(enableAi: EnableAi) { async setEnableAi(enableAi: EnableAi) {
await this.store.updateWorkspaceEnableAi( await this.store.updateWorkspaceEnableAi(
this.workspaceService.workspace.id, this.workspaceService.workspace.id,
@@ -2,6 +2,7 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import { import {
getWorkspaceConfigQuery, getWorkspaceConfigQuery,
setEnableAiMutation, setEnableAiMutation,
setEnableSharingMutation,
setEnableUrlPreviewMutation, setEnableUrlPreviewMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import { Store } from '@toeverything/infra'; import { Store } from '@toeverything/infra';
@@ -47,6 +48,26 @@ export class WorkspaceShareSettingStore extends Store {
}); });
} }
async updateWorkspaceEnableSharing(
workspaceId: string,
enableSharing: boolean,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
await this.workspaceServerService.server.gql({
query: setEnableSharingMutation,
variables: {
id: workspaceId,
enableSharing,
},
context: {
signal,
},
});
}
async updateWorkspaceEnableUrlPreview( async updateWorkspaceEnableUrlPreview(
workspaceId: string, workspaceId: string,
enableUrlPreview: boolean, enableUrlPreview: boolean,
+12
View File
@@ -6346,6 +6346,14 @@ export function useAFFiNEI18N(): {
* `Always enable url preview` * `Always enable url preview`
*/ */
["com.affine.settings.workspace.sharing.url-preview.title"](): string; ["com.affine.settings.workspace.sharing.url-preview.title"](): string;
/**
* `Control whether pages in this workspace can be shared publicly. Turn off to block new shares and external access for existing shares.`
*/
["com.affine.settings.workspace.sharing.workspace-sharing.description"](): string;
/**
* `Allow workspace page sharing`
*/
["com.affine.settings.workspace.sharing.workspace-sharing.title"](): string;
/** /**
* `AFFiNE AI` * `AFFiNE AI`
*/ */
@@ -6605,6 +6613,10 @@ export function useAFFiNEI18N(): {
* `Anyone can access this link` * `Anyone can access this link`
*/ */
["com.affine.share-menu.option.link.readonly.description"](): string; ["com.affine.share-menu.option.link.readonly.description"](): string;
/**
* `Sharing for this workspace is turned off. Please contact an admin to enable it.`
*/
["com.affine.share-menu.workspace-sharing.disabled.tooltip"](): string;
/** /**
* `Can manage` * `Can manage`
*/ */
@@ -1591,6 +1591,8 @@
"com.affine.settings.workspace.sharing.title": "Sharing", "com.affine.settings.workspace.sharing.title": "Sharing",
"com.affine.settings.workspace.sharing.url-preview.description": "Allow URL unfurling by Slack & other social apps, even if a doc is only accessible by workspace members.", "com.affine.settings.workspace.sharing.url-preview.description": "Allow URL unfurling by Slack & other social apps, even if a doc is only accessible by workspace members.",
"com.affine.settings.workspace.sharing.url-preview.title": "Always enable url preview", "com.affine.settings.workspace.sharing.url-preview.title": "Always enable url preview",
"com.affine.settings.workspace.sharing.workspace-sharing.description": "Control whether pages in this workspace can be shared publicly. Turn off to block new shares and external access for existing shares.",
"com.affine.settings.workspace.sharing.workspace-sharing.title": "Allow workspace page sharing",
"com.affine.settings.workspace.affine-ai.title": "AFFiNE AI", "com.affine.settings.workspace.affine-ai.title": "AFFiNE AI",
"com.affine.settings.workspace.affine-ai.label": "Allow AFFiNE AI Assistant", "com.affine.settings.workspace.affine-ai.label": "Allow AFFiNE AI Assistant",
"com.affine.settings.workspace.affine-ai.description": "Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.", "com.affine.settings.workspace.affine-ai.description": "Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.",
@@ -1655,6 +1657,7 @@
"com.affine.share-menu.option.link.no-access.description": "Only workspace members can access this link", "com.affine.share-menu.option.link.no-access.description": "Only workspace members can access this link",
"com.affine.share-menu.option.link.readonly": "Read only", "com.affine.share-menu.option.link.readonly": "Read only",
"com.affine.share-menu.option.link.readonly.description": "Anyone can access this link", "com.affine.share-menu.option.link.readonly.description": "Anyone can access this link",
"com.affine.share-menu.workspace-sharing.disabled.tooltip": "Sharing for this workspace is turned off. Please contact an admin to enable it.",
"com.affine.share-menu.option.permission.can-manage": "Can manage", "com.affine.share-menu.option.permission.can-manage": "Can manage",
"com.affine.share-menu.option.permission.can-edit": "Can edit", "com.affine.share-menu.option.permission.can-edit": "Can edit",
"com.affine.share-menu.option.permission.can-read": "Can read", "com.affine.share-menu.option.permission.can-read": "Can read",
@@ -1589,6 +1589,8 @@
"com.affine.settings.workspace.sharing.title": "分享", "com.affine.settings.workspace.sharing.title": "分享",
"com.affine.settings.workspace.sharing.url-preview.description": "允许 Slack 和其他社交应用程序展开 URL,即使文档仅由工作区成员访问。", "com.affine.settings.workspace.sharing.url-preview.description": "允许 Slack 和其他社交应用程序展开 URL,即使文档仅由工作区成员访问。",
"com.affine.settings.workspace.sharing.url-preview.title": "始终启用 URL 预览", "com.affine.settings.workspace.sharing.url-preview.title": "始终启用 URL 预览",
"com.affine.settings.workspace.sharing.workspace-sharing.description": "控制此工作区的页面是否允许公开分享。关闭后,禁止新的分享且现有分享外部无法访问。",
"com.affine.settings.workspace.sharing.workspace-sharing.title": "允许工作区页面分享",
"com.affine.settings.workspace.affine-ai.title": "AFFiNE AI", "com.affine.settings.workspace.affine-ai.title": "AFFiNE AI",
"com.affine.settings.workspace.affine-ai.label": "启用 AFFiNE AI 助手", "com.affine.settings.workspace.affine-ai.label": "启用 AFFiNE AI 助手",
"com.affine.settings.workspace.affine-ai.description": "允许工作区成员使用 AFFiNE AI 功能。此设置不会影响计费。工作区成员通过个人帐户使用 AFFiNE AI。", "com.affine.settings.workspace.affine-ai.description": "允许工作区成员使用 AFFiNE AI 功能。此设置不会影响计费。工作区成员通过个人帐户使用 AFFiNE AI。",
@@ -1653,6 +1655,7 @@
"com.affine.share-menu.option.link.no-access.description": "只有此工作区的成员可以打开此链接。", "com.affine.share-menu.option.link.no-access.description": "只有此工作区的成员可以打开此链接。",
"com.affine.share-menu.option.link.readonly": "只读", "com.affine.share-menu.option.link.readonly": "只读",
"com.affine.share-menu.option.link.readonly.description": "任何人可以访问该链接", "com.affine.share-menu.option.link.readonly.description": "任何人可以访问该链接",
"com.affine.share-menu.workspace-sharing.disabled.tooltip": "该工作区已禁用分享,请联系管理员开启。",
"com.affine.share-menu.option.permission.can-manage": "可管理", "com.affine.share-menu.option.permission.can-manage": "可管理",
"com.affine.share-menu.option.permission.can-edit": "可编辑", "com.affine.share-menu.option.permission.can-edit": "可编辑",
"com.affine.share-menu.option.permission.can-read": "可阅读", "com.affine.share-menu.option.permission.can-read": "可阅读",
@@ -1566,6 +1566,8 @@
"com.affine.settings.workspace.sharing.title": "分享", "com.affine.settings.workspace.sharing.title": "分享",
"com.affine.settings.workspace.sharing.url-preview.description": "允許 Slack 和其他社交應用程序展開 URL,即使文件僅由工作區成員訪問。", "com.affine.settings.workspace.sharing.url-preview.description": "允許 Slack 和其他社交應用程序展開 URL,即使文件僅由工作區成員訪問。",
"com.affine.settings.workspace.sharing.url-preview.title": "始終啟用 URL 預覽", "com.affine.settings.workspace.sharing.url-preview.title": "始終啟用 URL 預覽",
"com.affine.settings.workspace.sharing.workspace-sharing.description": "控制此工作區的頁面是否允許公開分享。關閉後,禁止新的分享且现有分享外部無法訪問。",
"com.affine.settings.workspace.sharing.workspace-sharing.title": "允許工作區頁面分享",
"com.affine.settings.workspace.affine-ai.title": "AFFiNE AI", "com.affine.settings.workspace.affine-ai.title": "AFFiNE AI",
"com.affine.settings.workspace.affine-ai.label": "啟用 AFFiNE AI 助理", "com.affine.settings.workspace.affine-ai.label": "啟用 AFFiNE AI 助理",
"com.affine.settings.workspace.affine-ai.description": "允許工作區成員使用 AFFiNE AI 功能。此設置不影響計費。工作區成員透過他們的個人帳號使用 AFFiNE AI。", "com.affine.settings.workspace.affine-ai.description": "允許工作區成員使用 AFFiNE AI 功能。此設置不影響計費。工作區成員透過他們的個人帳號使用 AFFiNE AI。",
@@ -1630,6 +1632,7 @@
"com.affine.share-menu.option.link.no-access.description": "只有此工作區的成員可以打開此連結。", "com.affine.share-menu.option.link.no-access.description": "只有此工作區的成員可以打開此連結。",
"com.affine.share-menu.option.link.readonly": "只讀", "com.affine.share-menu.option.link.readonly": "只讀",
"com.affine.share-menu.option.link.readonly.description": "任何人可以訪問該連結", "com.affine.share-menu.option.link.readonly.description": "任何人可以訪問該連結",
"com.affine.share-menu.workspace-sharing.disabled.tooltip": "此工作區已停用分享,請聯絡管理員開啟。",
"com.affine.share-menu.option.permission.can-manage": "可管理", "com.affine.share-menu.option.permission.can-manage": "可管理",
"com.affine.share-menu.option.permission.can-edit": "可編輯", "com.affine.share-menu.option.permission.can-edit": "可編輯",
"com.affine.share-menu.option.permission.can-read": "可閱讀", "com.affine.share-menu.option.permission.can-read": "可閱讀",