From 9a7f8e7d4d1425f058eb82b562e426ae9990afd9 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sat, 3 Jan 2026 01:13:27 +0800 Subject: [PATCH] feat: workspace level share settings (#14201) fix #13698 --- .../migration.sql | 2 + packages/backend/server/schema.prisma | 17 +++-- .../src/__tests__/models/workspace.spec.ts | 1 + .../server/src/__tests__/utils/workspace.ts | 20 +++++ .../server/src/__tests__/workspace.e2e.ts | 14 ++++ .../src/core/doc-renderer/controller.ts | 10 +++ .../src/core/permission/__tests__/doc.spec.ts | 2 +- .../permission/__tests__/workspace.spec.ts | 44 +++++++++++ .../backend/server/src/core/permission/doc.ts | 16 +++- .../server/src/core/permission/workspace.ts | 18 ++++- .../src/core/workspaces/resolvers/admin.ts | 33 +++++++++ .../server/src/core/workspaces/types.ts | 11 ++- .../backend/server/src/models/workspace.ts | 74 ++++++++++++++++++- packages/backend/server/src/schema.gql | 13 ++++ .../graphql/admin/admin-update-workspace.gql | 1 + .../src/graphql/admin/admin-workspace.gql | 1 + .../src/graphql/admin/admin-workspaces.gql | 1 + packages/common/graphql/src/graphql/index.ts | 14 ++++ .../graphql/src/graphql/workspace-config.gql | 1 + .../src/graphql/workspace-enable-sharing.gql | 5 ++ packages/common/graphql/src/schema.ts | 30 ++++++++ .../components/data-table-toolbar.tsx | 69 ++++++++++++++++- .../workspaces/components/data-table.tsx | 9 ++- .../workspaces/components/workspace-panel.tsx | 12 +++ .../admin/src/modules/workspaces/index.tsx | 5 ++ .../admin/src/modules/workspaces/schema.ts | 8 ++ .../modules/workspaces/use-workspace-list.ts | 19 ++++- .../workspace-setting/preference/sharing.tsx | 22 ++++++ .../src/modules/share-menu/view/index.tsx | 23 +++++- .../share-menu/view/share-menu/share-menu.tsx | 44 ++++++++--- .../share-setting/entities/share-setting.ts | 13 +++- .../share-setting/stores/share-setting.ts | 21 ++++++ packages/frontend/i18n/src/i18n.gen.ts | 12 +++ packages/frontend/i18n/src/resources/en.json | 3 + .../frontend/i18n/src/resources/zh-Hans.json | 3 + .../frontend/i18n/src/resources/zh-Hant.json | 3 + 36 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 packages/backend/server/migrations/20260102142014_workspace_enable_sharing/migration.sql create mode 100644 packages/common/graphql/src/graphql/workspace-enable-sharing.gql diff --git a/packages/backend/server/migrations/20260102142014_workspace_enable_sharing/migration.sql b/packages/backend/server/migrations/20260102142014_workspace_enable_sharing/migration.sql new file mode 100644 index 0000000000..9469beb712 --- /dev/null +++ b/packages/backend/server/migrations/20260102142014_workspace_enable_sharing/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "enable_sharing" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 53fc0339d9..56add0af0b 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -117,6 +117,7 @@ model Workspace { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) // workspace level feature flags enableAi Boolean @default(true) @map("enable_ai") + enableSharing Boolean @default(true) @map("enable_sharing") enableUrlPreview Boolean @default(false) @map("enable_url_preview") enableDocEmbedding Boolean @default(true) @map("enable_doc_embedding") name String? @db.VarChar @@ -147,17 +148,17 @@ model Workspace { // 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. model WorkspaceDoc { - workspaceId String @map("workspace_id") @db.VarChar - docId String @map("page_id") @db.VarChar - public Boolean @default(false) + workspaceId String @map("workspace_id") @db.VarChar + docId String @map("page_id") @db.VarChar + public Boolean @default(false) // 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 - mode Int @default(0) @db.SmallInt + mode Int @default(0) @db.SmallInt // Whether the doc is blocked - blocked Boolean @default(false) - title String? @db.VarChar - summary String? @db.VarChar + blocked Boolean @default(false) + title String? @db.VarChar + summary String? @db.VarChar publishedAt DateTime? @map("published_at") @db.Timestamptz(3) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) diff --git a/packages/backend/server/src/__tests__/models/workspace.spec.ts b/packages/backend/server/src/__tests__/models/workspace.spec.ts index dd7ade1d5b..5389a84aff 100644 --- a/packages/backend/server/src/__tests__/models/workspace.spec.ts +++ b/packages/backend/server/src/__tests__/models/workspace.spec.ts @@ -59,6 +59,7 @@ test('should update workspace', async t => { const data = { public: true, enableAi: true, + enableSharing: false, enableUrlPreview: true, enableDocEmbedding: false, }; diff --git a/packages/backend/server/src/__tests__/utils/workspace.ts b/packages/backend/server/src/__tests__/utils/workspace.ts index b3a61ce9ef..2cfb928d98 100644 --- a/packages/backend/server/src/__tests__/utils/workspace.ts +++ b/packages/backend/server/src/__tests__/utils/workspace.ts @@ -85,6 +85,26 @@ export async function updateWorkspace( 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( app: TestingApp, workspaceId: string diff --git a/packages/backend/server/src/__tests__/workspace.e2e.ts b/packages/backend/server/src/__tests__/workspace.e2e.ts index 52d602ed2a..0ecc7cb2db 100644 --- a/packages/backend/server/src/__tests__/workspace.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace.e2e.ts @@ -10,6 +10,7 @@ import { inviteUser, publishDoc, revokePublicDoc, + setWorkspaceSharing, TestingApp, updateWorkspace, } from './utils'; @@ -180,4 +181,17 @@ test('should be able to get public workspace doc', async t => { .type('application/octet-stream'); 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); }); diff --git a/packages/backend/server/src/core/doc-renderer/controller.ts b/packages/backend/server/src/core/doc-renderer/controller.ts index 1458498245..6c1fd2101d 100644 --- a/packages/backend/server/src/core/doc-renderer/controller.ts +++ b/packages/backend/server/src/core/doc-renderer/controller.ts @@ -100,6 +100,11 @@ export class DocRendererController { workspaceId: string, docId: string ): Promise { + const allowSharing = await this.models.workspace.allowSharing(workspaceId); + if (!allowSharing) { + return null; + } + let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId); if (!allowUrlPreview) { @@ -118,6 +123,11 @@ export class DocRendererController { private async getWorkspaceContent( workspaceId: string ): Promise { + const allowSharing = await this.models.workspace.allowSharing(workspaceId); + if (!allowSharing) { + return null; + } + const allowUrlPreview = await this.models.workspace.allowUrlPreview(workspaceId); diff --git a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts index b7c387f568..85f9881aa6 100644 --- a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts @@ -21,7 +21,7 @@ let ws: Workspace; test.before(async () => { module = await createTestingModule({ imports: [PermissionModule] }); models = module.get(Models); - ac = new DocAccessController(); + ac = module.get(DocAccessController); }); test.beforeEach(async () => { diff --git a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts index 5c9a110b1f..1839cb9979 100644 --- a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts @@ -80,6 +80,20 @@ test('should fallback to [External] if workspace is public', async t => { 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 => { 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); }); +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 => { 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 => { const { permissions } = await ac.role({ workspaceId: ws.id, diff --git a/packages/backend/server/src/core/permission/doc.ts b/packages/backend/server/src/core/permission/doc.ts index 47d0dfe8d9..75a3bd7abf 100644 --- a/packages/backend/server/src/core/permission/doc.ts +++ b/packages/backend/server/src/core/permission/doc.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { DocActionDenied } from '../../base'; +import { Models } from '../../models'; import { AccessController, getAccessController } from './controller'; import type { Resource } from './resource'; import { @@ -14,14 +15,21 @@ import { WorkspaceAccessController } from './workspace'; @Injectable() export class DocAccessController extends AccessController<'doc'> { protected readonly type = 'doc'; + constructor(private readonly models: Models) { + super(); + } async role(resource: Resource<'doc'>) { const role = await this.getRole(resource); + const permissions = mapDocRoleToPermissions(role); + const sharingAllowed = await this.models.workspace.allowSharing( + resource.workspaceId + ); + if (!sharingAllowed) { + permissions['Doc.Publish'] = false; + } - return { - role, - permissions: mapDocRoleToPermissions(role), - }; + return { role, permissions }; } async can(resource: Resource<'doc'>, action: DocAction) { diff --git a/packages/backend/server/src/core/permission/workspace.ts b/packages/backend/server/src/core/permission/workspace.ts index b492f39a09..dbd523dc52 100644 --- a/packages/backend/server/src/core/permission/workspace.ts +++ b/packages/backend/server/src/core/permission/workspace.ts @@ -27,7 +27,11 @@ export class WorkspaceAccessController extends AccessController<'ws'> { // NOTE(@forehalo): special case for public page // Currently, we can not only load binary of a public Doc to render in a shared page, // so we need to ensure anyone has basic 'read' permission to a workspace that has public pages. - if (!role && (await this.models.doc.hasPublic(resource.workspaceId))) { + if ( + !role && + (await this.models.workspace.allowSharing(resource.workspaceId)) && + (await this.models.doc.hasPublic(resource.workspaceId)) + ) { role = WorkspaceRole.External; } @@ -92,6 +96,15 @@ export class WorkspaceAccessController extends AccessController<'ws'> { } 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( payload.workspaceId, @@ -190,7 +203,8 @@ export class WorkspaceAccessController extends AccessController<'ws'> { } if (ws.public) { - return WorkspaceRole.External; + const sharingAllowed = await this.models.workspace.allowSharing(ws.id); + return sharingAllowed ? WorkspaceRole.External : null; } return null; diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts index 6b0d5efc60..d6ae1ed0fe 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/admin.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -56,6 +56,21 @@ class ListWorkspaceInput { @Field(() => AdminWorkspaceSort, { nullable: true }) 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() @@ -111,6 +126,9 @@ export class AdminWorkspace { @Field() enableAi!: boolean; + @Field() + enableSharing!: boolean; + @Field() enableUrlPreview!: boolean; @@ -150,6 +168,7 @@ class AdminUpdateWorkspaceInput extends PartialType( PickType(AdminWorkspace, [ 'public', 'enableAi', + 'enableSharing', 'enableUrlPreview', 'enableDocEmbedding', 'name', @@ -183,6 +202,13 @@ export class AdminWorkspaceResolver { keyword: filter.keyword, features: filter.features, 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, }); return rows; @@ -196,6 +222,13 @@ export class AdminWorkspaceResolver { const total = await this.models.workspace.adminCountWorkspaces({ keyword: filter.keyword, 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; } diff --git a/packages/backend/server/src/core/workspaces/types.ts b/packages/backend/server/src/core/workspaces/types.ts index c09ac4c5f3..0145a4249a 100644 --- a/packages/backend/server/src/core/workspaces/types.ts +++ b/packages/backend/server/src/core/workspaces/types.ts @@ -79,6 +79,9 @@ export class WorkspaceType extends WorkspaceFeatureType { @Field({ description: 'Enable AI' }) enableAi!: boolean; + @Field({ description: 'Enable workspace sharing' }) + enableSharing!: boolean; + @Field({ description: 'Enable url previous when sharing' }) enableUrlPreview!: boolean; @@ -130,7 +133,13 @@ export class InvitationType { @InputType() export class UpdateWorkspaceInput extends PickType( PartialType(WorkspaceType), - ['public', 'enableAi', 'enableUrlPreview', 'enableDocEmbedding'], + [ + 'public', + 'enableAi', + 'enableSharing', + 'enableUrlPreview', + 'enableDocEmbedding', + ], InputType ) { @Field(() => ID) diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts index 518748a263..46d0e23a55 100644 --- a/packages/backend/server/src/models/workspace.ts +++ b/packages/backend/server/src/models/workspace.ts @@ -14,6 +14,7 @@ type RawWorkspaceSummary = { name: string | null; avatarKey: string | null; enableAi: boolean; + enableSharing: boolean; enableUrlPreview: boolean; enableDocEmbedding: boolean; memberCount: bigint | number | null; @@ -36,6 +37,7 @@ export type AdminWorkspaceSummary = { name: string | null; avatarKey: string | null; enableAi: boolean; + enableSharing: boolean; enableUrlPreview: boolean; enableDocEmbedding: boolean; memberCount: number; @@ -67,6 +69,7 @@ export type UpdateWorkspaceInput = Pick< Partial, | 'public' | 'enableAi' + | 'enableSharing' | 'enableUrlPreview' | 'enableDocEmbedding' | 'name' @@ -169,6 +172,11 @@ export class WorkspaceModel extends BaseModel { return workspace?.enableUrlPreview ?? false; } + async allowSharing(workspaceId: string) { + const workspace = await this.get(workspaceId); + return workspace?.enableSharing ?? true; + } + async allowEmbedding(workspaceId: string) { const workspace = await this.get(workspaceId); return workspace?.enableDocEmbedding ?? false; @@ -185,6 +193,13 @@ export class WorkspaceModel extends BaseModel { first: number; keyword?: string | null; features?: WorkspaceFeatureName[] | null; + flags?: { + public?: boolean; + enableAi?: boolean; + enableSharing?: boolean; + enableUrlPreview?: boolean; + enableDocEmbedding?: boolean; + }; order?: | 'createdAt' | 'snapshotSize' @@ -197,9 +212,10 @@ export class WorkspaceModel extends BaseModel { }): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> { const keyword = options.keyword?.trim(); const features = options.features ?? []; + const flags = options.flags ?? {}; const includeTotal = options.includeTotal ?? true; const total = includeTotal - ? await this.adminCountWorkspaces({ keyword, features }) + ? await this.adminCountWorkspaces({ keyword, features, flags }) : 0; if (includeTotal && total === 0) { return { rows: [], total: 0 }; @@ -251,6 +267,7 @@ export class WorkspaceModel extends BaseModel { w.name, w.avatar_key AS "avatarKey", w.enable_ai AS "enableAi", + w.enable_sharing AS "enableSharing", w.enable_url_preview AS "enableUrlPreview", w.enable_doc_embedding AS "enableDocEmbedding", o.owner_id AS "ownerId", @@ -283,6 +300,14 @@ export class WorkspaceModel extends BaseModel { ` : Prisma.sql`TRUE` } + ${ + this.buildAdminFlagWhere(flags).length + ? Prisma.sql`AND ${Prisma.join( + this.buildAdminFlagWhere(flags), + ' AND ' + )}` + : Prisma.empty + } ${groupAndHaving} ) SELECT f.*, @@ -307,6 +332,7 @@ export class WorkspaceModel extends BaseModel { name: row.name, avatarKey: row.avatarKey, enableAi: row.enableAi, + enableSharing: row.enableSharing, enableUrlPreview: row.enableUrlPreview, enableDocEmbedding: row.enableDocEmbedding, memberCount: Number(row.memberCount ?? 0), @@ -332,9 +358,17 @@ export class WorkspaceModel extends BaseModel { async adminCountWorkspaces(options: { keyword?: string | null; features?: WorkspaceFeatureName[] | null; + flags?: { + public?: boolean; + enableAi?: boolean; + enableSharing?: boolean; + enableUrlPreview?: boolean; + enableDocEmbedding?: boolean; + }; }) { const keyword = options.keyword?.trim(); const features = options.features ?? []; + const flags = options.flags ?? {}; const featuresHaving = features.length > 0 @@ -393,6 +427,14 @@ export class WorkspaceModel extends BaseModel { ` : Prisma.sql`TRUE` } + ${ + this.buildAdminFlagWhere(flags).length + ? Prisma.sql`AND ${Prisma.join( + this.buildAdminFlagWhere(flags), + ' AND ' + )}` + : Prisma.empty + } ${groupAndHaving} ) SELECT COUNT(*) AS total FROM filtered @@ -401,6 +443,36 @@ export class WorkspaceModel extends BaseModel { 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( order?: | 'createdAt' diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 10622ed78b..9a05362dbf 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -35,6 +35,7 @@ input AdminUpdateWorkspaceInput { avatarKey: String enableAi: Boolean enableDocEmbedding: Boolean + enableSharing: Boolean enableUrlPreview: Boolean features: [FeatureType!] id: String! @@ -49,6 +50,7 @@ type AdminWorkspace { createdAt: DateTime! enableAi: Boolean! enableDocEmbedding: Boolean! + enableSharing: Boolean! enableUrlPreview: Boolean! features: [FeatureType!]! id: String! @@ -1226,10 +1228,15 @@ input ListUserInput { } input ListWorkspaceInput { + enableAi: Boolean + enableDocEmbedding: Boolean + enableSharing: Boolean + enableUrlPreview: Boolean features: [FeatureType!] first: Int! = 20 keyword: String orderBy: AdminWorkspaceSort + public: Boolean skip: Int! = 0 } @@ -2207,6 +2214,9 @@ input UpdateWorkspaceInput { """Enable doc embedding""" enableDocEmbedding: Boolean + """Enable workspace sharing""" + enableSharing: Boolean + """Enable url previous when sharing""" enableUrlPreview: Boolean id: ID! @@ -2432,6 +2442,9 @@ type WorkspaceType { """Enable doc embedding""" enableDocEmbedding: Boolean! + """Enable workspace sharing""" + enableSharing: Boolean! + """Enable url previous when sharing""" enableUrlPreview: Boolean! histories(before: DateTime, guid: String!, take: Int): [DocHistoryType!]! diff --git a/packages/common/graphql/src/graphql/admin/admin-update-workspace.gql b/packages/common/graphql/src/graphql/admin/admin-update-workspace.gql index 1c472942ed..e2c1a85a3d 100644 --- a/packages/common/graphql/src/graphql/admin/admin-update-workspace.gql +++ b/packages/common/graphql/src/graphql/admin/admin-update-workspace.gql @@ -6,6 +6,7 @@ mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) { name avatarKey enableAi + enableSharing enableUrlPreview enableDocEmbedding features diff --git a/packages/common/graphql/src/graphql/admin/admin-workspace.gql b/packages/common/graphql/src/graphql/admin/admin-workspace.gql index 91e2810127..a7d6aa78f6 100644 --- a/packages/common/graphql/src/graphql/admin/admin-workspace.gql +++ b/packages/common/graphql/src/graphql/admin/admin-workspace.gql @@ -11,6 +11,7 @@ query adminWorkspace( name avatarKey enableAi + enableSharing enableUrlPreview enableDocEmbedding features diff --git a/packages/common/graphql/src/graphql/admin/admin-workspaces.gql b/packages/common/graphql/src/graphql/admin/admin-workspaces.gql index fc05396dd4..3c5188b09d 100644 --- a/packages/common/graphql/src/graphql/admin/admin-workspaces.gql +++ b/packages/common/graphql/src/graphql/admin/admin-workspaces.gql @@ -6,6 +6,7 @@ query adminWorkspaces($filter: ListWorkspaceInput!) { name avatarKey enableAi + enableSharing enableUrlPreview enableDocEmbedding features diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 18e48bb06f..3a791c42a2 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -145,6 +145,7 @@ export const adminUpdateWorkspaceMutation = { name avatarKey enableAi + enableSharing enableUrlPreview enableDocEmbedding features @@ -175,6 +176,7 @@ export const adminWorkspaceQuery = { name avatarKey enableAi + enableSharing enableUrlPreview enableDocEmbedding features @@ -218,6 +220,7 @@ export const adminWorkspacesQuery = { name avatarKey enableAi + enableSharing enableUrlPreview enableDocEmbedding features @@ -2545,6 +2548,7 @@ export const getWorkspaceConfigQuery = { query: `query getWorkspaceConfig($id: String!) { workspace(id: $id) { enableAi + enableSharing enableUrlPreview enableDocEmbedding 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 = { id: 'setEnableUrlPreviewMutation' as const, op: 'setEnableUrlPreview', diff --git a/packages/common/graphql/src/graphql/workspace-config.gql b/packages/common/graphql/src/graphql/workspace-config.gql index e6d1f8762a..cb2be6ac76 100644 --- a/packages/common/graphql/src/graphql/workspace-config.gql +++ b/packages/common/graphql/src/graphql/workspace-config.gql @@ -1,6 +1,7 @@ query getWorkspaceConfig($id: String!) { workspace(id: $id) { enableAi + enableSharing enableUrlPreview enableDocEmbedding inviteLink { diff --git a/packages/common/graphql/src/graphql/workspace-enable-sharing.gql b/packages/common/graphql/src/graphql/workspace-enable-sharing.gql new file mode 100644 index 0000000000..67f90a0b99 --- /dev/null +++ b/packages/common/graphql/src/graphql/workspace-enable-sharing.gql @@ -0,0 +1,5 @@ +mutation setEnableSharing($id: ID!, $enableSharing: Boolean!) { + updateWorkspace(input: { id: $id, enableSharing: $enableSharing }) { + id + } +} diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 6d7589cd87..10fef6a046 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -71,6 +71,7 @@ export interface AdminUpdateWorkspaceInput { avatarKey?: InputMaybe; enableAi?: InputMaybe; enableDocEmbedding?: InputMaybe; + enableSharing?: InputMaybe; enableUrlPreview?: InputMaybe; features?: InputMaybe>; id: Scalars['String']['input']; @@ -86,6 +87,7 @@ export interface AdminWorkspace { createdAt: Scalars['DateTime']['output']; enableAi: Scalars['Boolean']['output']; enableDocEmbedding: Scalars['Boolean']['output']; + enableSharing: Scalars['Boolean']['output']; enableUrlPreview: Scalars['Boolean']['output']; features: Array; id: Scalars['String']['output']; @@ -1411,10 +1413,15 @@ export interface ListUserInput { } export interface ListWorkspaceInput { + enableAi?: InputMaybe; + enableDocEmbedding?: InputMaybe; + enableSharing?: InputMaybe; + enableUrlPreview?: InputMaybe; features?: InputMaybe>; first?: Scalars['Int']['input']; keyword?: InputMaybe; orderBy?: InputMaybe; + public?: InputMaybe; skip?: Scalars['Int']['input']; } @@ -2881,6 +2888,8 @@ export interface UpdateWorkspaceInput { enableAi?: InputMaybe; /** Enable doc embedding */ enableDocEmbedding?: InputMaybe; + /** Enable workspace sharing */ + enableSharing?: InputMaybe; /** Enable url previous when sharing */ enableUrlPreview?: InputMaybe; id: Scalars['ID']['input']; @@ -3115,6 +3124,8 @@ export interface WorkspaceType { enableAi: Scalars['Boolean']['output']; /** Enable doc embedding */ enableDocEmbedding: Scalars['Boolean']['output']; + /** Enable workspace sharing */ + enableSharing: Scalars['Boolean']['output']; /** Enable url previous when sharing */ enableUrlPreview: Scalars['Boolean']['output']; histories: Array; @@ -3332,6 +3343,7 @@ export type AdminUpdateWorkspaceMutation = { name: string | null; avatarKey: string | null; enableAi: boolean; + enableSharing: boolean; enableUrlPreview: boolean; enableDocEmbedding: boolean; features: Array; @@ -3368,6 +3380,7 @@ export type AdminWorkspaceQuery = { name: string | null; avatarKey: string | null; enableAi: boolean; + enableSharing: boolean; enableUrlPreview: boolean; enableDocEmbedding: boolean; features: Array; @@ -3416,6 +3429,7 @@ export type AdminWorkspacesQuery = { name: string | null; avatarKey: string | null; enableAi: boolean; + enableSharing: boolean; enableUrlPreview: boolean; enableDocEmbedding: boolean; features: Array; @@ -6534,6 +6548,7 @@ export type GetWorkspaceConfigQuery = { workspace: { __typename?: 'WorkspaceType'; enableAi: boolean; + enableSharing: boolean; enableUrlPreview: boolean; enableDocEmbedding: boolean; inviteLink: { @@ -6564,6 +6579,16 @@ export type SetEnableDocEmbeddingMutation = { 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<{ id: Scalars['ID']['input']; enableUrlPreview: Scalars['Boolean']['input']; @@ -7587,6 +7612,11 @@ export type Mutations = variables: SetEnableDocEmbeddingMutationVariables; response: SetEnableDocEmbeddingMutation; } + | { + name: 'setEnableSharingMutation'; + variables: SetEnableSharingMutationVariables; + response: SetEnableSharingMutation; + } | { name: 'setEnableUrlPreviewMutation'; variables: SetEnableUrlPreviewMutationVariables; diff --git a/packages/frontend/admin/src/modules/workspaces/components/data-table-toolbar.tsx b/packages/frontend/admin/src/modules/workspaces/components/data-table-toolbar.tsx index 0c7865bcff..65c4a46b18 100644 --- a/packages/frontend/admin/src/modules/workspaces/components/data-table-toolbar.tsx +++ b/packages/frontend/admin/src/modules/workspaces/components/data-table-toolbar.tsx @@ -18,6 +18,7 @@ import { } from '../../../components/ui/popover'; import { useDebouncedValue } from '../../../hooks/use-debounced-value'; import { useServerConfig } from '../../common'; +import type { WorkspaceFlagFilter } from '../schema'; interface DataTableToolbarProps { table?: Table; @@ -25,19 +26,21 @@ interface DataTableToolbarProps { onKeywordChange: (keyword: string) => void; selectedFeatures: FeatureType[]; onFeaturesChange: (features: FeatureType[]) => void; + flags: WorkspaceFlagFilter; + onFlagsChange: (flags: WorkspaceFlagFilter) => void; sort: AdminWorkspaceSort | undefined; onSortChange: (sort: AdminWorkspaceSort | undefined) => void; disabled?: boolean; } 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.BlobSize, label: 'Blob size' }, { value: AdminWorkspaceSort.SnapshotCount, label: 'Snapshot count' }, + { value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' }, { value: AdminWorkspaceSort.MemberCount, label: 'Member count' }, { value: AdminWorkspaceSort.PublicPageCount, label: 'Public pages' }, - { value: AdminWorkspaceSort.CreatedAt, label: 'Created time' }, ]; export function DataTableToolbar({ @@ -45,6 +48,8 @@ export function DataTableToolbar({ onKeywordChange, selectedFeatures, onFeaturesChange, + flags, + onFlagsChange, sort, onSortChange, disabled = false, @@ -80,6 +85,35 @@ export function DataTableToolbar({ [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 (
({
+ + + + + +
+ {flagOptions.map(option => ( + + ))} +
+
+
{ @@ -14,6 +15,8 @@ interface DataTableProps { onKeywordChange: (value: string) => void; selectedFeatures: FeatureType[]; onFeaturesChange: (features: FeatureType[]) => void; + flags: WorkspaceFlagFilter; + onFlagsChange: Dispatch>; sort: AdminWorkspaceSort | undefined; onSortChange: (sort: AdminWorkspaceSort | undefined) => void; loading?: boolean; @@ -34,6 +37,8 @@ export function DataTable({ onKeywordChange, selectedFeatures, onFeaturesChange, + flags, + onFlagsChange, sort, onSortChange, onPaginationChange, @@ -46,7 +51,7 @@ export function DataTable({ totalCount={workspacesCount} pagination={pagination} onPaginationChange={onPaginationChange} - resetFiltersDeps={[keyword, selectedFeatures, sort]} + resetFiltersDeps={[keyword, selectedFeatures, sort, flags]} renderToolbar={table => ( ({ onKeywordChange={onKeywordChange} selectedFeatures={selectedFeatures} onFeaturesChange={onFeaturesChange} + flags={flags} + onFlagsChange={onFlagsChange} sort={sort} onSortChange={onSortChange} disabled={loading} diff --git a/packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx b/packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx index befc601c08..77b18befd4 100644 --- a/packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx +++ b/packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx @@ -86,6 +86,7 @@ function WorkspacePanelContent({ flags: { public: workspace.public, enableAi: workspace.enableAi, + enableSharing: workspace.enableSharing, enableUrlPreview: workspace.enableUrlPreview, enableDocEmbedding: workspace.enableDocEmbedding, name: workspace.name ?? '', @@ -110,6 +111,7 @@ function WorkspacePanelContent({ return ( flags.public !== baseline.flags.public || flags.enableAi !== baseline.flags.enableAi || + flags.enableSharing !== baseline.flags.enableSharing || flags.enableUrlPreview !== baseline.flags.enableUrlPreview || flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding || flags.name !== baseline.flags.name || @@ -134,6 +136,7 @@ function WorkspacePanelContent({ id: workspace.id, public: flags.public, enableAi: flags.enableAi, + enableSharing: flags.enableSharing, enableUrlPreview: flags.enableUrlPreview, enableDocEmbedding: flags.enableDocEmbedding, name: flags.name || null, @@ -231,6 +234,15 @@ function WorkspacePanelContent({ } /> + + setFlags(prev => ({ ...prev, enableSharing: value })) + } + /> + ([]); + const [flagFilters, setFlagFilters] = useState({}); const [sort, setSort] = useState( AdminWorkspaceSort.CreatedAt ); @@ -18,6 +20,7 @@ export function WorkspacePage() { keyword, features: featureFilters, orderBy: sort, + flags: flagFilters, }); const columns = useColumns(); @@ -36,6 +39,8 @@ export function WorkspacePage() { onKeywordChange={setKeyword} selectedFeatures={featureFilters} onFeaturesChange={setFeatureFilters} + flags={flagFilters} + onFlagsChange={setFlagFilters} sort={sort} onSortChange={setSort} loading={loading} diff --git a/packages/frontend/admin/src/modules/workspaces/schema.ts b/packages/frontend/admin/src/modules/workspaces/schema.ts index a50d445b65..f95f0bd049 100644 --- a/packages/frontend/admin/src/modules/workspaces/schema.ts +++ b/packages/frontend/admin/src/modules/workspaces/schema.ts @@ -16,3 +16,11 @@ export type WorkspaceUpdateInput = AdminUpdateWorkspaceMutation['adminUpdateWorkspace']; export type WorkspaceFeatureFilter = FeatureType[]; + +export type WorkspaceFlagFilter = { + public?: boolean; + enableAi?: boolean; + enableSharing?: boolean; + enableUrlPreview?: boolean; + enableDocEmbedding?: boolean; +}; diff --git a/packages/frontend/admin/src/modules/workspaces/use-workspace-list.ts b/packages/frontend/admin/src/modules/workspaces/use-workspace-list.ts index e82d16a1d9..39e1561e27 100644 --- a/packages/frontend/admin/src/modules/workspaces/use-workspace-list.ts +++ b/packages/frontend/admin/src/modules/workspaces/use-workspace-list.ts @@ -7,10 +7,13 @@ import { } from '@affine/graphql'; import { useEffect, useMemo, useState } from 'react'; +import type { WorkspaceFlagFilter } from './schema'; + export const useWorkspaceList = (filter?: { keyword?: string; features?: FeatureType[]; orderBy?: AdminWorkspaceSort; + flags?: WorkspaceFlagFilter; }) => { const [pagination, setPagination] = useState({ pageIndex: 0, @@ -21,8 +24,10 @@ export const useWorkspaceList = (filter?: { () => `${filter?.keyword ?? ''}-${[...(filter?.features ?? [])] .sort() - .join(',')}-${filter?.orderBy ?? ''}`, - [filter?.features, filter?.keyword, filter?.orderBy] + .join(',')}-${filter?.orderBy ?? ''}-${JSON.stringify( + filter?.flags ?? {} + )}`, + [filter?.features, filter?.flags, filter?.keyword, filter?.orderBy] ); useEffect(() => { @@ -40,10 +45,20 @@ export const useWorkspaceList = (filter?: { ? filter.features : undefined, 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?.flags?.enableAi, + filter?.flags?.enableDocEmbedding, + filter?.flags?.enableSharing, + filter?.flags?.enableUrlPreview, + filter?.flags?.public, filter?.keyword, filter?.orderBy, pagination.pageIndex, diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/preference/sharing.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/preference/sharing.tsx index a4e773684f..1a36dde537 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/preference/sharing.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/preference/sharing.tsx @@ -21,11 +21,19 @@ export const SharingPanel = () => { export const Sharing = () => { const t = useI18n(); const shareSetting = useService(WorkspaceShareSettingService).sharePreview; + const enableSharing = useLiveData(shareSetting.enableSharing$); const enableUrlPreview = useLiveData(shareSetting.enableUrlPreview$); const loading = useLiveData(shareSetting.isLoading$); const permissionService = useService(WorkspacePermissionService); const isOwner = useLiveData(permissionService.permission.isOwner$); + const handleToggleSharing = useAsyncCallback( + async (checked: boolean) => { + await shareSetting.setEnableSharing(checked); + }, + [shareSetting] + ); + const handleCheck = useAsyncCallback( async (checked: boolean) => { await shareSetting.setEnableUrlPreview(checked); @@ -51,6 +59,20 @@ export const Sharing = () => { disabled={loading} /> + + + ); }; diff --git a/packages/frontend/core/src/modules/share-menu/view/index.tsx b/packages/frontend/core/src/modules/share-menu/view/index.tsx index 40af8cfcc9..902f81f055 100644 --- a/packages/frontend/core/src/modules/share-menu/view/index.tsx +++ b/packages/frontend/core/src/modules/share-menu/view/index.tsx @@ -1,8 +1,11 @@ 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 { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; 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'; export { CloudSvg } from './cloud-svg'; @@ -14,6 +17,10 @@ type SharePageModalProps = { }; export const SharePageButton = ({ workspace, page }: SharePageModalProps) => { + const t = useI18n(); + const shareSetting = useService(WorkspaceShareSettingService).sharePreview; + const enableSharing = useLiveData(shareSetting.enableSharing$); + const confirmEnableCloud = useEnableCloud(); const handleOpenShareModal = useCallback((open: boolean) => { 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 ( { }) } onOpenShareModal={handleOpenShareModal} + disabled={sharingDisabled} + disabledReason={disabledReason} /> ); }; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/share-menu.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/share-menu.tsx index 1ddf68270e..7a134e3c04 100644 --- a/packages/frontend/core/src/modules/share-menu/view/share-menu/share-menu.tsx +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/share-menu.tsx @@ -35,6 +35,8 @@ export interface ShareMenuProps extends PropsWithChildren { onOpenShareModal?: (open: boolean) => void; openPaywallModal?: () => void; hittingPaywall?: boolean; + disabled?: boolean; + disabledReason?: string; } export enum ShareMenuTab { @@ -203,7 +205,7 @@ export const ShareMenuContent = (props: ShareMenuProps) => { }; const DefaultShareButton = forwardRef(function DefaultShareButton( - _, + props: { disabled?: boolean; tooltip?: string }, ref: Ref ) { const t = useI18n(); @@ -211,18 +213,26 @@ const DefaultShareButton = forwardRef(function DefaultShareButton( const shared = useLiveData(shareInfoService.shareInfo.isShared$); useEffect(() => { + if (props.disabled) { + return; + } 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 ( - -