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

View File

@@ -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<TData> {
table?: Table<TData>;
@@ -25,19 +26,21 @@ interface DataTableToolbarProps<TData> {
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<TData>({
@@ -45,6 +48,8 @@ export function DataTableToolbar<TData>({
onKeywordChange,
selectedFeatures,
onFeaturesChange,
flags,
onFlagsChange,
sort,
onSortChange,
disabled = false,
@@ -80,6 +85,35 @@ export function DataTableToolbar<TData>({
[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 (
<div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap">
<FeatureFilterPopover
@@ -119,6 +153,37 @@ export function DataTableToolbar<TData>({
</div>
</PopoverContent>
</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">
<Input
placeholder="Search Workspace / Owner"

View File

@@ -3,6 +3,7 @@ import type { ColumnDef, PaginationState } from '@tanstack/react-table';
import type { Dispatch, SetStateAction } from 'react';
import { SharedDataTable } from '../../../components/shared/data-table';
import type { WorkspaceFlagFilter } from '../schema';
import { DataTableToolbar } from './data-table-toolbar';
interface DataTableProps<TData, TValue> {
@@ -14,6 +15,8 @@ interface DataTableProps<TData, TValue> {
onKeywordChange: (value: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
flags: WorkspaceFlagFilter;
onFlagsChange: Dispatch<SetStateAction<WorkspaceFlagFilter>>;
sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
loading?: boolean;
@@ -34,6 +37,8 @@ export function DataTable<TData extends { id: string }, TValue>({
onKeywordChange,
selectedFeatures,
onFeaturesChange,
flags,
onFlagsChange,
sort,
onSortChange,
onPaginationChange,
@@ -46,7 +51,7 @@ export function DataTable<TData extends { id: string }, TValue>({
totalCount={workspacesCount}
pagination={pagination}
onPaginationChange={onPaginationChange}
resetFiltersDeps={[keyword, selectedFeatures, sort]}
resetFiltersDeps={[keyword, selectedFeatures, sort, flags]}
renderToolbar={table => (
<DataTableToolbar
table={table}
@@ -54,6 +59,8 @@ export function DataTable<TData extends { id: string }, TValue>({
onKeywordChange={onKeywordChange}
selectedFeatures={selectedFeatures}
onFeaturesChange={onFeaturesChange}
flags={flags}
onFlagsChange={onFlagsChange}
sort={sort}
onSortChange={onSortChange}
disabled={loading}

View File

@@ -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({
}
/>
<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
label="Enable Doc Embedding"
description="Allow document embedding for search"

View File

@@ -4,11 +4,13 @@ import { useState } from 'react';
import { Header } from '../header';
import { useColumns } from './components/columns';
import { DataTable } from './components/data-table';
import type { WorkspaceFlagFilter } from './schema';
import { useWorkspaceList } from './use-workspace-list';
export function WorkspacePage() {
const [keyword, setKeyword] = useState('');
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
const [flagFilters, setFlagFilters] = useState<WorkspaceFlagFilter>({});
const [sort, setSort] = useState<AdminWorkspaceSort | undefined>(
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}

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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}
/>
</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>
);
};

View File

@@ -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 (
<ShareMenu
workspaceMetadata={workspace.meta}
@@ -31,6 +50,8 @@ export const SharePageButton = ({ workspace, page }: SharePageModalProps) => {
})
}
onOpenShareModal={handleOpenShareModal}
disabled={sharingDisabled}
disabledReason={disabledReason}
/>
);
};

View File

@@ -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<HTMLButtonElement>
) {
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 (
<Tooltip
content={
shared
? t['com.affine.share-menu.option.link.readonly.description']()
: t['com.affine.share-menu.option.link.no-access.description']()
}
>
<Button ref={ref} className={styles.button} variant="primary">
<Tooltip content={tooltip}>
<Button
ref={ref}
className={styles.button}
variant="primary"
disabled={props.disabled}
>
<div className={styles.buttonContainer}>
{shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />}
{t['com.affine.share-menu.shareButton']()}
@@ -233,6 +243,13 @@ const DefaultShareButton = forwardRef(function DefaultShareButton(
});
const LocalShareMenu = (props: ShareMenuProps) => {
if (props.disabled) {
return (
<div data-testid="local-share-menu-button">
<DefaultShareButton disabled tooltip={props.disabledReason} />
</div>
);
}
return (
<Menu
items={<ShareMenuContent {...props} />}
@@ -254,6 +271,13 @@ const LocalShareMenu = (props: ShareMenuProps) => {
};
const CloudShareMenu = (props: ShareMenuProps) => {
if (props.disabled) {
return (
<div data-testid="cloud-share-menu-button">
<DefaultShareButton disabled tooltip={props.disabledReason} />
</div>
);
}
return (
<Menu
items={<ShareMenuContent {...props} />}

View File

@@ -16,6 +16,7 @@ import type { WorkspaceService } from '../../workspace';
import type { WorkspaceShareSettingStore } from '../stores/share-setting';
type EnableAi = GetWorkspaceConfigQuery['workspace']['enableAi'];
type EnableSharing = GetWorkspaceConfigQuery['workspace']['enableSharing'];
type EnableUrlPreview =
GetWorkspaceConfigQuery['workspace']['enableUrlPreview'];
@@ -23,6 +24,7 @@ const logger = new DebugLogger('affine:workspace-permission');
export class WorkspaceShareSetting extends Entity {
enableAi$ = new LiveData<EnableAi | null>(null);
enableSharing$ = new LiveData<EnableSharing | null>(null);
enableUrlPreview$ = new LiveData<EnableUrlPreview | null>(null);
inviteLink$ = new LiveData<InviteLink | null>(null);
isLoading$ = new LiveData(false);
@@ -48,12 +50,13 @@ export class WorkspaceShareSetting extends Entity {
tap(value => {
if (value) {
this.enableAi$.next(value.enableAi);
this.enableSharing$.next(value.enableSharing);
this.enableUrlPreview$.next(value.enableUrlPreview);
this.inviteLink$.next(value.inviteLink);
}
}),
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)),
onComplete(() => this.isLoading$.setValue(false))
@@ -74,6 +77,14 @@ export class WorkspaceShareSetting extends Entity {
await this.waitForRevalidation();
}
async setEnableSharing(enableSharing: EnableSharing) {
await this.store.updateWorkspaceEnableSharing(
this.workspaceService.workspace.id,
enableSharing
);
await this.waitForRevalidation();
}
async setEnableAi(enableAi: EnableAi) {
await this.store.updateWorkspaceEnableAi(
this.workspaceService.workspace.id,

View File

@@ -2,6 +2,7 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import {
getWorkspaceConfigQuery,
setEnableAiMutation,
setEnableSharingMutation,
setEnableUrlPreviewMutation,
} from '@affine/graphql';
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(
workspaceId: string,
enableUrlPreview: boolean,

View File

@@ -6346,6 +6346,14 @@ export function useAFFiNEI18N(): {
* `Always enable url preview`
*/
["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`
*/
@@ -6605,6 +6613,10 @@ export function useAFFiNEI18N(): {
* `Anyone can access this link`
*/
["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`
*/

View File

@@ -1591,6 +1591,8 @@
"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.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.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.",
@@ -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.readonly": "Read only",
"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-edit": "Can edit",
"com.affine.share-menu.option.permission.can-read": "Can read",

View File

@@ -1589,6 +1589,8 @@
"com.affine.settings.workspace.sharing.title": "分享",
"com.affine.settings.workspace.sharing.url-preview.description": "允许 Slack 和其他社交应用程序展开 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.label": "启用 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.readonly": "只读",
"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-edit": "可编辑",
"com.affine.share-menu.option.permission.can-read": "可阅读",

View File

@@ -1566,6 +1566,8 @@
"com.affine.settings.workspace.sharing.title": "分享",
"com.affine.settings.workspace.sharing.url-preview.description": "允許 Slack 和其他社交應用程序展開 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.label": "啟用 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.readonly": "只讀",
"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-edit": "可編輯",
"com.affine.share-menu.option.permission.can-read": "可閱讀",