From 92f4f0c2d9120f5a3a15ac5a1156cd98a099d947 Mon Sep 17 00:00:00 2001 From: EYHN Date: Mon, 10 Feb 2025 07:26:38 +0800 Subject: [PATCH] feat(core): guard service (#9816) --- .../server/src/core/permission/index.ts | 4 +- .../server/src/core/permission/types.ts | 6 - .../src/core/workspaces/resolvers/doc.ts | 29 ++- .../core/workspaces/resolvers/workspace.ts | 32 +++- .../frontend/component/src/theme/global.css | 2 +- .../component/src/ui/button/button.css.ts | 5 +- .../component/src/ui/dnd/drop-target.ts | 12 +- .../component/src/ui/menu/styles.css.ts | 2 +- .../component/src/ui/menu/use-menu-item.tsx | 6 +- .../component/src/ui/property/property.css.ts | 10 +- .../frontend/component/src/ui/radio/radio.tsx | 3 + .../component/src/ui/radio/styles.css.ts | 2 +- .../presets/ai/_common/chat-actions-handle.ts | 31 ++- .../page-history-modal/history-modal.tsx | 10 +- .../blocksuite-editor-container.tsx | 8 +- .../block-suite-editor/blocksuite-editor.tsx | 3 + .../block-suite-editor/lit-adaper.tsx | 4 +- .../block-suite-header/menu/index.tsx | 169 ++++++++-------- .../block-suite-header/title/index.tsx | 22 ++- .../icons/icons-selector.css.ts | 10 +- .../doc-properties/icons/icons-selector.tsx | 9 + .../doc-properties/manager/index.tsx | 12 +- .../doc-properties/menu/edit-doc-property.tsx | 9 +- .../doc-properties/sidebar/index.tsx | 11 +- .../src/components/doc-properties/table.tsx | 66 +++++-- .../doc-properties/types/checkbox.tsx | 12 +- .../types/created-updated-by.tsx | 8 +- .../components/doc-properties/types/date.tsx | 18 +- .../doc-properties/types/doc-primary-mode.tsx | 12 +- .../doc-properties/types/edgeless-theme.tsx | 12 +- .../doc-properties/types/journal.css.ts | 7 +- .../doc-properties/types/journal.tsx | 21 +- .../doc-properties/types/number.tsx | 8 +- .../doc-properties/types/page-width.tsx | 8 +- .../components/doc-properties/types/tags.tsx | 6 +- .../doc-properties/types/template.tsx | 14 +- .../components/doc-properties/types/text.tsx | 8 +- .../components/doc-properties/types/types.ts | 1 + .../core/src/components/guard/doc-guard.tsx | 24 +++ .../affine/use-block-suite-meta-helper.ts | 9 +- ...se-register-blocksuite-editor-commands.tsx | 33 +++- .../hooks/use-block-suite-page-meta.ts | 14 +- .../src/components/page-detail-editor.tsx | 12 +- .../components/list-floating-toolbar.tsx | 2 +- .../page-list/docs/page-list-item.tsx | 37 +++- .../page-list/docs/virtualized-page-list.tsx | 4 +- .../components/page-list/operation-cell.tsx | 45 ++++- .../page-list/virtualized-trash-list.tsx | 52 +++-- .../root-app-sidebar/trash-button.tsx | 27 ++- .../core/src/components/tags/styles.css.ts | 3 + .../src/desktop/dialogs/doc-info/index.tsx | 5 +- .../desktop/dialogs/doc-info/info-modal.tsx | 39 +++- .../pages/workspace/all-page/all-page.tsx | 7 +- .../pages/workspace/collection/index.tsx | 5 + .../detail-page/detail-page-header.tsx | 5 +- .../workspace/detail-page/detail-page.tsx | 17 +- .../detail-page/tabs/journal/index.tsx | 45 +++-- .../pages/workspace/share/share-header.tsx | 3 +- .../pages/workspace/share/share-page.tsx | 2 +- .../src/desktop/pages/workspace/tag/index.tsx | 5 + .../desktop/pages/workspace/trash-page.tsx | 11 +- .../mobile/components/doc-info/doc-info.tsx | 40 +++- .../components/explorer/nodes/doc/index.tsx | 15 +- .../explorer/nodes/doc/operations.tsx | 83 ++++---- .../src/mobile/components/rename/sub-menu.tsx | 2 + .../core/src/mobile/components/rename/type.ts | 1 + .../detail/menu/journal-conflicts.tsx | 33 +++- .../workspace/detail/mobile-detail-page.tsx | 28 ++- .../detail/page-header-more-button.tsx | 4 + .../explorer/views/nodes/doc/index.tsx | 21 +- .../explorer/views/nodes/doc/operations.tsx | 62 +++--- .../src/modules/explorer/views/tree/node.tsx | 25 ++- .../permissions/entities/permission.ts | 45 ++--- .../core/src/modules/permissions/index.ts | 21 +- .../src/modules/permissions/services/guard.ts | 181 ++++++++++++++++++ .../src/modules/permissions/stores/guard.ts | 60 ++++++ .../modules/permissions/stores/permission.ts | 23 ++- .../general-access/members-permission.tsx | 65 ++++--- .../general-access/public-page-button.tsx | 49 ++--- .../member-management/member-item.tsx | 53 ++--- .../member-management/member-management.tsx | 20 +- .../share-menu/view/share-menu/share-page.tsx | 17 +- packages/frontend/graphql/src/error.ts | 9 +- .../src/graphql/doc-role-permissions.gql | 21 ++ .../frontend/graphql/src/graphql/index.ts | 56 ++++++ .../graphql/workspace-role-permissions.gql | 19 ++ packages/frontend/graphql/src/schema.ts | 68 +++++++ packages/frontend/i18n/src/resources/en.json | 1 + tests/affine-local/e2e/import-dialog.spec.ts | 2 + 89 files changed, 1520 insertions(+), 522 deletions(-) create mode 100644 packages/frontend/core/src/components/guard/doc-guard.tsx create mode 100644 packages/frontend/core/src/modules/permissions/services/guard.ts create mode 100644 packages/frontend/core/src/modules/permissions/stores/guard.ts create mode 100644 packages/frontend/graphql/src/graphql/doc-role-permissions.gql create mode 100644 packages/frontend/graphql/src/graphql/workspace-role-permissions.gql diff --git a/packages/backend/server/src/core/permission/index.ts b/packages/backend/server/src/core/permission/index.ts index fab3588cd5..3bc7f3924b 100644 --- a/packages/backend/server/src/core/permission/index.ts +++ b/packages/backend/server/src/core/permission/index.ts @@ -11,13 +11,13 @@ export class PermissionModule {} export { PermissionService } from './service'; export { DOC_ACTIONS, - type DocActionPermissions, + type DocAction, DocRole, fixupDocRole, mapDocRoleToPermissions, mapWorkspaceRoleToPermissions, PublicDocMode, WORKSPACE_ACTIONS, - type WorkspaceActionPermissions, + type WorkspaceAction, WorkspaceRole, } from './types'; diff --git a/packages/backend/server/src/core/permission/types.ts b/packages/backend/server/src/core/permission/types.ts index 9db31efcd3..6f6042a98a 100644 --- a/packages/backend/server/src/core/permission/types.ts +++ b/packages/backend/server/src/core/permission/types.ts @@ -146,12 +146,6 @@ type ResourceActionName = export type WorkspaceAction = ResourceActionName<'Workspace'>; export type DocAction = ResourceActionName<'Doc'>; export type Action = WorkspaceAction | DocAction; -export type WorkspaceActionPermissions = { - [key in WorkspaceAction]: boolean; -}; -export type DocActionPermissions = { - [key in DocAction]: boolean; -}; const cache = new WeakMap(); const buildPathReader = ( diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 1475acfbe4..43c56e5364 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -31,7 +31,7 @@ import { Models } from '../../../models'; import { CurrentUser } from '../../auth'; import { DOC_ACTIONS, - type DocActionPermissions, + DocAction, DocRole, fixupDocRole, mapDocRoleToPermissions, @@ -41,6 +41,7 @@ import { import { PublicUserType } from '../../user'; import { DocID } from '../../utils/doc'; import { WorkspaceType } from '../types'; +import { DotToUnderline, mapPermissionToGraphqlPermissions } from './workspace'; registerEnumType(PublicDocMode, { name: 'PublicDocMode', @@ -128,10 +129,12 @@ class GrantedDocUserType { @ObjectType() class PaginatedGrantedDocUserType extends Paginated(GrantedDocUserType) {} -const DocPermissions = registerObjectType( +const DocPermissions = registerObjectType< + Record, boolean> +>( Object.fromEntries( DOC_ACTIONS.map(action => [ - action, + action.replaceAll('.', '_'), { type: () => Boolean, options: { @@ -143,15 +146,6 @@ const DocPermissions = registerObjectType( { name: 'DocPermissions' } ); -@ObjectType() -export class DocRolePermissions { - @Field(() => DocRole) - role!: DocRole; - - @Field(() => DocPermissions) - permissions!: DocActionPermissions; -} - @Resolver(() => WorkspaceType) export class WorkspaceDocResolver { private readonly logger = new Logger(WorkspaceDocResolver.name); @@ -365,7 +359,7 @@ export class DocResolver { async permissions( @CurrentUser() user: CurrentUser, @Parent() doc: DocType - ): Promise { + ): Promise> { const [permission, workspacePermission] = await this.prisma.$transaction( tx => Promise.all([ @@ -385,12 +379,11 @@ export class DocResolver { ]) ); - return { - role: permission?.type ?? DocRole.External, - permissions: mapDocRoleToPermissions( + return mapPermissionToGraphqlPermissions( + mapDocRoleToPermissions( fixupDocRole(workspacePermission?.type, permission?.type) - ), - }; + ) + ); } @ResolveField(() => PaginatedGrantedDocUserType, { diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 7a4267a103..257b53dc26 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -38,7 +38,7 @@ import { mapWorkspaceRoleToPermissions, PermissionService, WORKSPACE_ACTIONS, - type WorkspaceActionPermissions, + WorkspaceAction, WorkspaceRole, } from '../../permission'; import { QuotaService, WorkspaceQuotaType } from '../../quota'; @@ -51,6 +51,22 @@ import { } from '../types'; import { WorkspaceService } from './service'; +export type DotToUnderline = + T extends `${infer Prefix}.${infer Suffix}` + ? `${Prefix}_${DotToUnderline}` + : T; + +export function mapPermissionToGraphqlPermissions( + permission: Record +): Record, boolean> { + return Object.fromEntries( + Object.entries(permission).map(([key, value]) => [ + key.replaceAll('.', '_'), + value, + ]) + ) as Record, boolean>; +} + @ObjectType() export class EditorType implements Partial { @Field() @@ -75,10 +91,12 @@ class WorkspacePageMeta { updatedBy!: EditorType | null; } -const WorkspacePermissions = registerObjectType( +const WorkspacePermissions = registerObjectType< + Record, boolean> +>( Object.fromEntries( WORKSPACE_ACTIONS.map(action => [ - action, + action.replaceAll('.', '_'), { type: () => Boolean, options: { @@ -96,7 +114,7 @@ export class WorkspaceRolePermissions { role!: WorkspaceRole; @Field(() => WorkspacePermissions) - permissions!: WorkspaceActionPermissions; + permissions!: Record, boolean>; } /** @@ -342,7 +360,7 @@ export class WorkspaceResolver { async workspaceRolePermissions( @CurrentUser() user: CurrentUser, @Args('id') id: string - ) { + ): Promise { const workspace = await this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId: id, userId: user.id }, }); @@ -351,7 +369,9 @@ export class WorkspaceResolver { } return { role: workspace.type, - permissions: mapWorkspaceRoleToPermissions(workspace.type), + permissions: mapPermissionToGraphqlPermissions( + mapWorkspaceRoleToPermissions(workspace.type) + ), }; } diff --git a/packages/frontend/component/src/theme/global.css b/packages/frontend/component/src/theme/global.css index ac7a8bc0e5..26fdf86472 100644 --- a/packages/frontend/component/src/theme/global.css +++ b/packages/frontend/component/src/theme/global.css @@ -134,7 +134,7 @@ summary { } a, -button { +button:not([disabled]) { cursor: pointer; } diff --git a/packages/frontend/component/src/ui/button/button.css.ts b/packages/frontend/component/src/ui/button/button.css.ts index 17328ba7ab..c4fbbf37f2 100644 --- a/packages/frontend/component/src/ui/button/button.css.ts +++ b/packages/frontend/component/src/ui/button/button.css.ts @@ -39,7 +39,6 @@ export const button = style({ outline: 0, borderRadius: 8, transition: 'all .3s', - cursor: 'pointer', ['WebkitAppRegion' as string]: 'no-drag', // hover layer @@ -169,6 +168,10 @@ export const button = style({ opacity: 0.5, }, + '&:not([data-disabled])': { + cursor: 'pointer', + }, + // default keyboard focus style '&:focus-visible::after': { content: '""', diff --git a/packages/frontend/component/src/ui/dnd/drop-target.ts b/packages/frontend/component/src/ui/dnd/drop-target.ts index 31d0bfd2a7..66831f8e1d 100644 --- a/packages/frontend/component/src/ui/dnd/drop-target.ts +++ b/packages/frontend/component/src/ui/dnd/drop-target.ts @@ -316,7 +316,11 @@ export const useDropTarget = ( // external data is only available in drop event thus // this is the only case for getAdaptedEventArgs - const args = getAdaptedEventArgs(_args, options.fromExternalData, true); + const args = { + ...getAdaptedEventArgs(_args, options.fromExternalData, true), + treeInstruction: extractInstruction(_args.self.data), + closestEdge: extractClosestEdge(_args.self.data), + }; if ( isExternalDrag(_args) && options.fromExternalData && @@ -329,11 +333,7 @@ export const useDropTarget = ( } if (args.location.current.dropTargets[0]?.element === element) { - options.onDrop?.({ - ...args, - treeInstruction: extractInstruction(args.self.data), - closestEdge: extractClosestEdge(args.self.data), - } as DropTargetDropEvent); + options.onDrop?.(args as DropTargetDropEvent); } }, getData: (args: DropTargetGetFeedback) => { diff --git a/packages/frontend/component/src/ui/menu/styles.css.ts b/packages/frontend/component/src/ui/menu/styles.css.ts index 668aa2805f..68716c2e00 100644 --- a/packages/frontend/component/src/ui/menu/styles.css.ts +++ b/packages/frontend/component/src/ui/menu/styles.css.ts @@ -51,7 +51,7 @@ export const menuItem = style({ '&.block': { maxWidth: '100%', }, - '&[data-disabled]': { + '&[data-disabled], &.disabled': { vars: { [iconColor]: cssVarV2('icon/disable'), [labelColor]: cssVarV2('text/secondary'), diff --git a/packages/frontend/component/src/ui/menu/use-menu-item.tsx b/packages/frontend/component/src/ui/menu/use-menu-item.tsx index 165243572d..1b3d00f287 100644 --- a/packages/frontend/component/src/ui/menu/use-menu-item.tsx +++ b/packages/frontend/component/src/ui/menu/use-menu-item.tsx @@ -16,13 +16,15 @@ export const useMenuItem = ({ checked, selected, block, + disabled, ...otherProps }: T) => { const className = clsx( styles.menuItem, { - danger: type === 'danger', - warning: type === 'warning', + danger: disabled ? false : type === 'danger', + warning: disabled ? false : type === 'warning', + disabled, checked, selected, block, diff --git a/packages/frontend/component/src/ui/property/property.css.ts b/packages/frontend/component/src/ui/property/property.css.ts index f81c917991..9b2b438b9a 100644 --- a/packages/frontend/component/src/ui/property/property.css.ts +++ b/packages/frontend/component/src/ui/property/property.css.ts @@ -133,10 +133,12 @@ export const propertyValueContainer = style({ '&[data-readonly="false"][data-hoverable="true"]': { cursor: 'pointer', }, - '&[data-readonly="false"][data-hoverable="true"]:is(:hover, :focus-within)': - { - backgroundColor: cssVarV2('layer/background/hoverOverlay'), - }, + '&[data-readonly="true"][data-hoverable="true"]': { + cursor: 'default', + }, + '&[data-hoverable="true"]:is(:hover, :focus-within)': { + backgroundColor: cssVarV2('layer/background/hoverOverlay'), + }, }, }); diff --git a/packages/frontend/component/src/ui/radio/radio.tsx b/packages/frontend/component/src/ui/radio/radio.tsx index db73cbbbad..00d8c92212 100644 --- a/packages/frontend/component/src/ui/radio/radio.tsx +++ b/packages/frontend/component/src/ui/radio/radio.tsx @@ -83,6 +83,7 @@ export const RadioGroup = memo(function RadioGroup({ indicatorStyle, iconMode, onChange, + disabled, }: RadioProps) { const animationTImerRef = useRef | null>(null); const finalItems = useMemo(() => { @@ -158,6 +159,7 @@ export const RadioGroup = memo(function RadioGroup({ className={clsx(styles.radioButtonGroup, className)} style={finalStyle} data-icon-mode={iconMode} + disabled={disabled} > {finalItems.map(({ customRender, ...item }, index) => { const testId = item.testId ? { 'data-testid': item.testId } : {}; @@ -179,6 +181,7 @@ export const RadioGroup = memo(function RadioGroup({ style={style} {...testId} {...item.attrs} + disabled={disabled} > { + if (host.std.store.readonly$.value) { + return false; + } const textSelection = host.selection.find(TextSelection); const blockSelections = host.selection.filter(BlockSelection); if ( @@ -252,7 +255,12 @@ const REPLACE_SELECTION = { const INSERT_BELOW = { icon: InsertBelowIcon, title: 'Insert below', - showWhen: () => true, + showWhen: (host: EditorHost) => { + if (host.std.store.readonly$.value) { + return false; + } + return true; + }, toast: 'Successfully inserted', handler: async ( host: EditorHost, @@ -282,7 +290,12 @@ const SAVE_CHAT_TO_BLOCK_ACTION: ChatAction = { icon: BlockIcon, title: 'Save chat to block', toast: 'Successfully saved chat to a block', - showWhen: () => true, + showWhen: (host: EditorHost) => { + if (host.std.store.readonly$.value) { + return false; + } + return true; + }, handler: async ( host: EditorHost, _, @@ -378,7 +391,12 @@ const SAVE_CHAT_TO_BLOCK_ACTION: ChatAction = { const ADD_TO_EDGELESS_AS_NOTE = { icon: CreateIcon, title: 'Add to edgeless as note', - showWhen: () => true, + showWhen: (host: EditorHost) => { + if (host.std.store.readonly$.value) { + return false; + } + return true; + }, toast: 'New note created', handler: async (host: EditorHost, content: string) => { reportResponse('result:add-note'); @@ -451,7 +469,12 @@ const CREATE_AS_DOC = { const CREATE_AS_LINKED_DOC = { icon: CreateIcon, title: 'Create as a linked doc', - showWhen: () => true, + showWhen: (host: EditorHost) => { + if (host.std.store.readonly$.value) { + return false; + } + return true; + }, toast: 'New doc created', handler: async (host: EditorHost, content: string) => { reportResponse('result:add-page'); diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx index 2fb3f1a5f7..15c8177c64 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -5,7 +5,10 @@ import { Modal, useConfirmModal } from '@affine/component/ui/modal'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { EditorService } from '@affine/core/modules/editor'; -import { WorkspacePermissionService } from '@affine/core/modules/permissions'; +import { + GuardService, + WorkspacePermissionService, +} from '@affine/core/modules/permissions'; import { WorkspaceQuotaService } from '@affine/core/modules/quota'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { i18nTime, Trans, useI18n } from '@affine/i18n'; @@ -409,6 +412,8 @@ const PageHistoryManager = ({ const workspaceId = docCollection.id; const [activeVersion, setActiveVersion] = useState(); + const guardService = useService(GuardService); + const pageDocId = useMemo(() => { return docCollection.getDoc(pageId)?.spaceDoc.guid ?? pageId; }, [pageId, docCollection]); @@ -440,6 +445,7 @@ const PageHistoryManager = ({ const i18n = useI18n(); const title = useLiveData(docDisplayMetaService.title$(pageId)); + const canEdit = useLiveData(guardService.can$('Doc_Update', pageDocId)); const onConfirmRestore = useCallback(() => { openConfirmModal({ @@ -499,7 +505,7 @@ const PageHistoryManager = ({ diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx index fd0612e480..38935698f8 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx @@ -31,6 +31,7 @@ interface BlocksuiteEditorContainerProps { page: Store; mode: DocMode; shared?: boolean; + readonly?: boolean; className?: string; defaultOpenProperty?: DefaultOpenProperty; style?: React.CSSProperties; @@ -40,7 +41,7 @@ export const BlocksuiteEditorContainer = forwardRef< AffineEditorContainer, BlocksuiteEditorContainerProps >(function AffineEditorContainer( - { page, mode, className, style, shared, defaultOpenProperty }, + { page, mode, className, style, shared, readonly, defaultOpenProperty }, ref ) { const rootRef = useRef(null); @@ -119,7 +120,7 @@ export const BlocksuiteEditorContainer = forwardRef< ]); const handleClickPageModeBlank = useCallback(() => { - if (shared || page.readonly) return; + if (shared || readonly || page.readonly) return; const std = affineEditorContainerProxy.host?.std; if (!std) { return; @@ -141,7 +142,7 @@ export const BlocksuiteEditorContainer = forwardRef< } std.command.exec(appendParagraphCommand); - }, [affineEditorContainerProxy, page, shared]); + }, [affineEditorContainerProxy.host?.std, page, readonly, shared]); return (
(() => void) | void; @@ -34,6 +35,7 @@ const BlockSuiteEditorImpl = ({ page, className, shared, + readonly, style, onEditorReady, defaultOpenProperty, @@ -111,6 +113,7 @@ const BlockSuiteEditorImpl = ({ mode={mode} page={page} shared={shared} + readonly={readonly} defaultOpenProperty={defaultOpenProperty} ref={editorRef} className={className} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index 47de5a9b0a..c8a68dac93 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -89,6 +89,7 @@ const adapted = { interface BlocksuiteEditorProps { page: Store; + readonly?: boolean; shared?: boolean; defaultOpenProperty?: DefaultOpenProperty; } @@ -220,6 +221,7 @@ export const BlocksuiteDocEditor = forwardRef< onClickBlank, titleRef: externalTitleRef, defaultOpenProperty, + readonly, }, ref ) { @@ -334,7 +336,7 @@ export const BlocksuiteDocEditor = forwardRef< data-testid="page-editor-blank" onClick={onClickBlank} >
- + {!readonly && } {!shared && displayBiDirectionalLink ? ( ) : null} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx index e4a1284242..e30888fe6b 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx @@ -1,4 +1,4 @@ -import { notify, useConfirmModal } from '@affine/component'; +import { notify, toast, useConfirmModal } from '@affine/component'; import { Menu, MenuItem, @@ -9,13 +9,13 @@ import { PageHistoryModal } from '@affine/core/components/affine/page-history-mo import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper'; import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud'; import { useExportPage } from '@affine/core/components/hooks/affine/use-export-page'; -import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta'; import { Export, MoveToTrash } from '@affine/core/components/page-list'; import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/workspace/detail-page/use-header-responsive'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { EditorService } from '@affine/core/modules/editor'; import { OpenInAppService } from '@affine/core/modules/open-in-app/services'; +import { GuardService } from '@affine/core/modules/permissions'; import { ShareMenuContent } from '@affine/core/modules/share-menu'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { ViewService } from '@affine/core/modules/workbench/services/view'; @@ -34,7 +34,6 @@ import { LocalWorkspaceIcon, OpenInNewIcon, PageIcon, - SaveIcon, ShareIcon, SplitViewIcon, TocIcon, @@ -56,24 +55,95 @@ type PageMenuProps = { isJournal?: boolean; containerWidth: number; }; -// fixme: refactor this file + export const PageHeaderMenuButton = ({ rename, page, isJournal, containerWidth, }: PageMenuProps) => { + const workspace = useService(WorkspaceService).workspace; + const editorService = useService(EditorService); + const isInTrash = useLiveData( + editorService.editor.doc.meta$.map(meta => meta.trash) + ); + + const [historyModalOpen, setHistoryModalOpen] = useState(false); + const [openHistoryTipsModal, setOpenHistoryTipsModal] = useState(false); + + const handleMenuOpenChange = useCallback((open: boolean) => { + if (open) { + track.$.header.docOptions.open(); + } + }, []); + + const openHistoryModal = useCallback(() => { + track.$.header.history.open(); + if (workspace.flavour === 'affine-cloud') { + return setHistoryModalOpen(true); + } + return setOpenHistoryTipsModal(true); + }, [setOpenHistoryTipsModal, workspace.flavour]); + + if (isInTrash) { + return null; + } + + return ( + <> + + } + contentOptions={{ + align: 'center', + }} + rootOptions={{ + onOpenChange: handleMenuOpenChange, + }} + > + + + {workspace.flavour !== 'local' ? ( + + ) : null} + + + ); +}; + +// fixme: refactor this file +const PageHeaderMenuItem = ({ + rename, + page, + isJournal, + containerWidth, + openHistoryModal, +}: PageMenuProps & { + openHistoryModal: () => void; +}) => { const pageId = page?.id; const t = useI18n(); const { hideShare } = useDetailPageHeaderResponsive(containerWidth); const confirmEnableCloud = useEnableCloud(); const workspace = useService(WorkspaceService).workspace; - + const guardService = useService(GuardService); const editorService = useService(EditorService); - const isInTrash = useLiveData( - editorService.editor.doc.meta$.map(meta => meta.trash) - ); const currentMode = useLiveData(editorService.editor.mode$); const primaryMode = useLiveData(editorService.editor.doc.primaryMode$); @@ -84,9 +154,6 @@ export const PageHeaderMenuButton = ({ const { duplicate } = useBlockSuiteMetaHelper(); - const [isEditing, setEditing] = useState(!page.readonly); - const { setDocReadonly } = useDocMetaHelper(); - const view = useService(ViewService).view; const openSidePanel = useCallback( @@ -105,17 +172,6 @@ export const PageHeaderMenuButton = ({ openSidePanel('outline'); }, [openSidePanel]); - const [historyModalOpen, setHistoryModalOpen] = useState(false); - const [openHistoryTipsModal, setOpenHistoryTipsModal] = useState(false); - - const openHistoryModal = useCallback(() => { - track.$.header.history.open(); - if (workspace.flavour === 'affine-cloud') { - return setHistoryModalOpen(true); - } - return setOpenHistoryTipsModal(true); - }, [setOpenHistoryTipsModal, workspace.flavour]); - const workspaceDialogService = useService(WorkspaceDialogService); const openInfoModal = useCallback(() => { track.$.header.pageInfo.open(); @@ -148,11 +204,16 @@ export const PageHeaderMenuButton = ({ confirmButtonOptions: { variant: 'error', }, - onConfirm: () => { + onConfirm: async () => { + const canTrash = await guardService.can('Doc_Trash', pageId); + if (!canTrash) { + toast(t['com.affine.no-permission']()); + return; + } editorService.editor.doc.moveToTrash(); }, }); - }, [editorService.editor.doc, openConfirmModal, t]); + }, [editorService.editor.doc, guardService, openConfirmModal, pageId, t]); const handleRename = useCallback(() => { rename?.(); @@ -178,12 +239,6 @@ export const PageHeaderMenuButton = ({ }); }, [primaryMode, editorService, t]); - const handleMenuOpenChange = useCallback((open: boolean) => { - if (open) { - track.$.header.docOptions.open(); - } - }, []); - const exportHandler = useExportPage(); const handleDuplicate = useCallback(() => { @@ -238,21 +293,6 @@ export const PageHeaderMenuButton = ({ toggleFavorite(); }, [toggleFavorite]); - const handleToggleEdit = useCallback(() => { - setDocReadonly(page.id, !page.readonly); - setEditing(!isEditing); - }, [isEditing, page.id, page.readonly, setDocReadonly]); - - const isMobile = environment.isMobile; - const mobileEditMenuItem = ( - : } - onSelect={handleToggleEdit} - > - {t[isEditing ? 'Save' : 'Edit']()} - - ); - const showResponsiveMenu = hideShare; const ResponsiveMenuItems = ( <> @@ -293,15 +333,18 @@ export const PageHeaderMenuButton = ({ openInAppService?.showOpenInAppPage(); }, [openInAppService]); - const EditMenu = ( + const canEdit = useLiveData(guardService.can$('Doc_Update', pageId)); + const canMoveToTrash = useLiveData(guardService.can$('Doc_Trash', pageId)); + + return ( <> {showResponsiveMenu ? ResponsiveMenuItems : null} - {isMobile && mobileEditMenuItem} {!isJournal && ( } data-testid="editor-option-menu-rename" onSelect={handleRename} + disabled={!canEdit} > {t['Rename']()} @@ -310,6 +353,7 @@ export const PageHeaderMenuButton = ({ prefixIcon={primaryMode === 'page' ? : } data-testid="editor-option-menu-edgeless" onSelect={handleSwitchMode} + disabled={!canEdit} > {primaryMode === 'page' ? t['com.affine.editorDefaultMode.edgeless']() @@ -396,6 +440,7 @@ export const PageHeaderMenuButton = ({ {BUILD_CONFIG.isWeb && workspace.flavour === 'affine-cloud' ? ( ); - if (isInTrash) { - return null; - } - return ( - <> - - - - {workspace.flavour !== 'local' ? ( - - ) : null} - - - ); }; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/title/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/title/index.tsx index f9de0447ab..3bd5fdca4b 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/title/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/title/index.tsx @@ -1,7 +1,8 @@ import type { InlineEditProps } from '@affine/component'; import { InlineEdit } from '@affine/component'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { DocsService } from '@affine/core/modules/doc'; +import { DocService, DocsService } from '@affine/core/modules/doc'; +import { GuardService } from '@affine/core/modules/permissions'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { track } from '@affine/track'; import { useLiveData, useService } from '@toeverything/infra'; @@ -11,7 +12,6 @@ import type { HTMLAttributes } from 'react'; import * as styles from './style.css'; export interface BlockSuiteHeaderTitleProps { - docId: string; /** if set, title cannot be edited */ inputHandleRef?: InlineEditProps['handleRef']; className?: string; @@ -21,20 +21,24 @@ const inputAttrs = { 'data-testid': 'title-content', } as HTMLAttributes; export const BlocksuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => { - const { inputHandleRef, docId } = props; + const { inputHandleRef } = props; const workspaceService = useService(WorkspaceService); const isSharedMode = workspaceService.workspace.openOptions.isSharedMode; const docsService = useService(DocsService); - - const docRecord = useLiveData(docsService.list.doc$(docId)); - const docTitle = useLiveData(docRecord?.title$); + const guardService = useService(GuardService); + const docService = useService(DocService); + const docTitle = useLiveData(docService.doc.record.title$); const onChange = useAsyncCallback( async (v: string) => { - await docsService.changeDocTitle(docId, v); + await docsService.changeDocTitle(docService.doc.id, v); track.$.header.actions.renameDoc(); }, - [docId, docsService] + [docService.doc.id, docsService] + ); + + const canEdit = useLiveData( + guardService.can$('Doc_Update', docService.doc.id) ); return ( @@ -42,7 +46,7 @@ export const BlocksuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => { className={clsx(styles.title, props.className)} value={docTitle} onChange={onChange} - editable={!isSharedMode} + editable={!isSharedMode && canEdit} exitible={true} placeholder="Untitled" data-testid="title-edit-button" diff --git a/packages/frontend/core/src/components/doc-properties/icons/icons-selector.css.ts b/packages/frontend/core/src/components/doc-properties/icons/icons-selector.css.ts index 65aa72a8d3..6a455daf36 100644 --- a/packages/frontend/core/src/components/doc-properties/icons/icons-selector.css.ts +++ b/packages/frontend/core/src/components/doc-properties/icons/icons-selector.css.ts @@ -51,8 +51,14 @@ export const iconSelectorButton = style({ border: `1px solid ${cssVar('borderColor')}`, background: cssVar('backgroundSecondaryColor'), cursor: 'pointer', - ':hover': { - backgroundColor: cssVar('backgroundTertiaryColor'), + + selectors: { + '&:hover:not([data-readonly=true])': { + backgroundColor: cssVar('backgroundTertiaryColor'), + }, + '&[data-readonly=true]': { + cursor: 'default', + }, }, }); diff --git a/packages/frontend/core/src/components/doc-properties/icons/icons-selector.tsx b/packages/frontend/core/src/components/doc-properties/icons/icons-selector.tsx index ab37ad1e8c..229c528492 100644 --- a/packages/frontend/core/src/components/doc-properties/icons/icons-selector.tsx +++ b/packages/frontend/core/src/components/doc-properties/icons/icons-selector.tsx @@ -55,11 +55,20 @@ const IconsSelectorPanel = ({ export const DocPropertyIconSelector = ({ propertyInfo, + readonly, onSelectedChange, }: { propertyInfo: DocCustomPropertyInfo; + readonly?: boolean; onSelectedChange: (icon: DocPropertyIconName) => void; }) => { + if (readonly) { + return ( +
+ +
+ ); + } return ( void; }) => { const t = useI18n(); + const guardService = useService(GuardService); const workspaceService = useService(WorkspaceService); const docsService = useService(DocsService); const [moreMenuOpen, setMoreMenuOpen] = useState(defaultOpenEditMenu); + const canEditPropertyInfo = useLiveData( + guardService.can$('Workspace_Properties_Update') + ); const typeInfo = isSupportedDocPropertyType(propertyInfo.type) ? DocPropertyTypes[propertyInfo.type] @@ -51,6 +56,7 @@ const PropertyItem = ({ const { dragRef } = useDraggable( () => ({ + canDrag: canEditPropertyInfo, data: { entity: { type: 'custom-property', @@ -62,13 +68,14 @@ const PropertyItem = ({ }, }, }), - [propertyInfo, workspaceService] + [propertyInfo, workspaceService, canEditPropertyInfo] ); const { dropTargetRef, closestEdge } = useDropTarget( () => ({ canDrop(data) { return ( + canEditPropertyInfo && data.source.data.entity?.type === 'custom-property' && data.source.data.from?.at === 'doc-property:manager' && data.source.data.from?.workspaceId === @@ -97,7 +104,7 @@ const PropertyItem = ({ }); }, }), - [docsService, propertyInfo, workspaceService] + [docsService, propertyInfo, workspaceService, canEditPropertyInfo] ); return ( @@ -139,6 +146,7 @@ const PropertyItem = ({ } > diff --git a/packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.tsx b/packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.tsx index a2a4a471d0..aa7a802bf7 100644 --- a/packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.tsx +++ b/packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.tsx @@ -28,8 +28,10 @@ import * as styles from './edit-doc-property.css'; export const EditDocPropertyMenuItems = ({ propertyId, onPropertyInfoChange, + readonly, }: { propertyId: string; + readonly?: boolean; onPropertyInfoChange?: ( field: keyof DocCustomPropertyInfo, value: string @@ -142,9 +144,10 @@ export const EditDocPropertyMenuItems = ({ > - {typeInfo?.renameable === false ? ( + {typeInfo?.renameable === false || readonly ? ( {name} ) : ( {t['com.affine.page-properties.property.always-show']()} @@ -188,6 +192,7 @@ export const EditDocPropertyMenuItems = ({ onClick={handleClickHideWhenEmpty} selected={propertyInfo.show === 'hide-when-empty'} data-property-visibility="hide-when-empty" + disabled={readonly} > {t['com.affine.page-properties.property.hide-when-empty']()} @@ -196,6 +201,7 @@ export const EditDocPropertyMenuItems = ({ onClick={handleClickAlwaysHide} selected={propertyInfo.show === 'always-hide'} data-property-visibility="always-hide" + disabled={readonly} > {t['com.affine.page-properties.property.always-hide']()} @@ -203,6 +209,7 @@ export const EditDocPropertyMenuItems = ({ } type="danger" + disabled={readonly} onClick={() => { confirmModal.openConfirmModal({ title: diff --git a/packages/frontend/core/src/components/doc-properties/sidebar/index.tsx b/packages/frontend/core/src/components/doc-properties/sidebar/index.tsx index 96a407cf28..c527cea0c6 100644 --- a/packages/frontend/core/src/components/doc-properties/sidebar/index.tsx +++ b/packages/frontend/core/src/components/doc-properties/sidebar/index.tsx @@ -1,6 +1,7 @@ import { Divider, IconButton, Tooltip } from '@affine/component'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; import { DocsService } from '@affine/core/modules/doc'; +import { GuardService } from '@affine/core/modules/permissions'; import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name'; import { useI18n } from '@affine/i18n'; import track from '@affine/track'; @@ -28,9 +29,12 @@ export const DocPropertySidebar = () => { const [newPropertyId, setNewPropertyId] = useState(); const docsService = useService(DocsService); + const guardService = useService(GuardService); const propertyList = docsService.propertyList; const properties = useLiveData(propertyList.properties$); - + const canEditPropertyInfo = useLiveData( + guardService.can$('Workspace_Properties_Update') + ); const onAddProperty = useCallback( (option: { type: string; name: string }) => { if (!isSupportedDocPropertyType(option.type)) { @@ -104,12 +108,15 @@ export const DocPropertySidebar = () => {
{ + if (!canEditPropertyInfo) { + return; + } onAddProperty({ type: key, name, }); }} - data-disabled={isUniqueExist} + data-disabled={isUniqueExist || !canEditPropertyInfo} > {t.t(value.name)} diff --git a/packages/frontend/core/src/components/doc-properties/table.tsx b/packages/frontend/core/src/components/doc-properties/table.tsx index 7dc73b7b00..600bc7b4b4 100644 --- a/packages/frontend/core/src/components/doc-properties/table.tsx +++ b/packages/frontend/core/src/components/doc-properties/table.tsx @@ -16,6 +16,7 @@ import type { DatabaseValueCell, } from '@affine/core/modules/doc-info/types'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; +import { GuardService } from '@affine/core/modules/permissions'; import { ViewService, WorkbenchService } from '@affine/core/modules/workbench'; import type { AffineDNDData } from '@affine/core/types/dnd'; import { useI18n } from '@affine/i18n'; @@ -111,6 +112,8 @@ interface DocPropertyRowProps { propertyInfo: DocCustomPropertyInfo; showAll?: boolean; defaultOpenEditMenu?: boolean; + propertyInfoReadonly?: boolean; + readonly?: boolean; onChange?: (value: unknown) => void; onPropertyInfoChange?: ( field: keyof DocCustomPropertyInfo, @@ -122,6 +125,8 @@ export const DocPropertyRow = ({ propertyInfo, defaultOpenEditMenu, onChange, + propertyInfoReadonly, + readonly, onPropertyInfoChange, }: DocPropertyRowProps) => { const t = useI18n(); @@ -160,6 +165,7 @@ export const DocPropertyRow = ({ const docId = docService.doc.id; const { dragRef } = useDraggable( () => ({ + canDrag: !propertyInfoReadonly, data: { entity: { type: 'custom-property', @@ -171,7 +177,7 @@ export const DocPropertyRow = ({ }, }, }), - [docId, propertyInfo.id] + [docId, propertyInfo.id, propertyInfoReadonly] ); const { dropTargetRef, closestEdge } = useDropTarget( () => ({ @@ -180,6 +186,7 @@ export const DocPropertyRow = ({ }, canDrop: data => { return ( + !propertyInfoReadonly && data.source.data.entity?.type === 'custom-property' && data.source.data.entity.id !== propertyInfo.id && data.source.data.from?.at === 'doc-property:table' && @@ -204,7 +211,7 @@ export const DocPropertyRow = ({ }); }, }), - [docId, docsService.propertyList, propertyInfo.id] + [docId, docsService.propertyList, propertyInfo.id, propertyInfoReadonly] ); if (!ValueRenderer || typeof ValueRenderer !== 'function') return null; @@ -221,6 +228,8 @@ export const DocPropertyRow = ({ dropIndicatorEdge={closestEdge} hideEmpty={hideEmpty} hide={hide} + data-property-info-readonly={propertyInfoReadonly} + data-readonly={readonly} data-testid="doc-property-row" data-info-id={propertyInfo.id} > @@ -235,6 +244,7 @@ export const DocPropertyRow = ({ } data-testid="doc-property-name" @@ -243,6 +253,7 @@ export const DocPropertyRow = ({ propertyInfo={propertyInfo} onChange={handleChange} value={customPropertyValue} + readonly={readonly} /> ); @@ -284,11 +295,20 @@ const DocWorkspacePropertiesTableBody = forwardRef< const docsService = useService(DocsService); const workbenchService = useService(WorkbenchService); const viewService = useServiceOptional(ViewService); + const docService = useService(DocService); + const guardService = useService(GuardService); const properties = useLiveData(docsService.propertyList.sortedProperties$); const [addMoreCollapsed, setAddMoreCollapsed] = useState(true); const [newPropertyId, setNewPropertyId] = useState(null); + const canEditProperty = useLiveData( + guardService.can$('Doc_Update', docService.doc.id) + ); + const canEditPropertyInfo = useLiveData( + guardService.can$('Workspace_Properties_Update') + ); + const handlePropertyAdded = useCallback( (property: DocCustomPropertyInfo) => { setNewPropertyId(property.id); @@ -340,34 +360,48 @@ const DocWorkspacePropertiesTableBody = forwardRef< propertyInfo={property} defaultOpenEditMenu={newPropertyId === property.id} onChange={value => onChange?.(property, value)} + readonly={!canEditProperty} + propertyInfoReadonly={!canEditPropertyInfo} onPropertyInfoChange={(...args) => onPropertyInfoChange?.(property, ...args) } /> ))}
- - } - contentOptions={{ - onClick(e) { - e.stopPropagation(); - }, - }} - > + {!canEditPropertyInfo ? ( - + ) : ( + + } + contentOptions={{ + onClick(e) { + e.stopPropagation(); + }, + }} + > + + + )} {viewService ? (