feat(core): guard service (#9816)

This commit is contained in:
EYHN
2025-02-10 07:26:38 +08:00
committed by GitHub
parent 879157b938
commit 92f4f0c2d9
89 changed files with 1520 additions and 522 deletions
@@ -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';
@@ -146,12 +146,6 @@ type ResourceActionName<T extends keyof typeof Actions> =
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<object, any>();
const buildPathReader = (
@@ -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<DocActionPermissions>(
const DocPermissions = registerObjectType<
Record<DotToUnderline<DocAction>, boolean>
>(
Object.fromEntries(
DOC_ACTIONS.map(action => [
action,
action.replaceAll('.', '_'),
{
type: () => Boolean,
options: {
@@ -143,15 +146,6 @@ const DocPermissions = registerObjectType<DocActionPermissions>(
{ 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<DocRolePermissions> {
): Promise<InstanceType<typeof DocPermissions>> {
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, {
@@ -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 string> =
T extends `${infer Prefix}.${infer Suffix}`
? `${Prefix}_${DotToUnderline<Suffix>}`
: T;
export function mapPermissionToGraphqlPermissions<A extends string>(
permission: Record<A, boolean>
): Record<DotToUnderline<A>, boolean> {
return Object.fromEntries(
Object.entries(permission).map(([key, value]) => [
key.replaceAll('.', '_'),
value,
])
) as Record<DotToUnderline<A>, boolean>;
}
@ObjectType()
export class EditorType implements Partial<Editor> {
@Field()
@@ -75,10 +91,12 @@ class WorkspacePageMeta {
updatedBy!: EditorType | null;
}
const WorkspacePermissions = registerObjectType<WorkspaceActionPermissions>(
const WorkspacePermissions = registerObjectType<
Record<DotToUnderline<WorkspaceAction>, 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<DotToUnderline<WorkspaceAction>, boolean>;
}
/**
@@ -342,7 +360,7 @@ export class WorkspaceResolver {
async workspaceRolePermissions(
@CurrentUser() user: CurrentUser,
@Args('id') id: string
) {
): Promise<WorkspaceRolePermissions> {
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)
),
};
}
@@ -134,7 +134,7 @@ summary {
}
a,
button {
button:not([disabled]) {
cursor: pointer;
}
@@ -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: '""',
@@ -316,7 +316,11 @@ export const useDropTarget = <D extends DNDData = DNDData>(
// 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 = <D extends DNDData = DNDData>(
}
if (args.location.current.dropTargets[0]?.element === element) {
options.onDrop?.({
...args,
treeInstruction: extractInstruction(args.self.data),
closestEdge: extractClosestEdge(args.self.data),
} as DropTargetDropEvent<D>);
options.onDrop?.(args as DropTargetDropEvent<D>);
}
},
getData: (args: DropTargetGetFeedback<D>) => {
@@ -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'),
@@ -16,13 +16,15 @@ export const useMenuItem = <T extends MenuItemProps>({
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,
@@ -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'),
},
},
});
@@ -83,6 +83,7 @@ export const RadioGroup = memo(function RadioGroup({
indicatorStyle,
iconMode,
onChange,
disabled,
}: RadioProps) {
const animationTImerRef = useRef<ReturnType<typeof setTimeout> | 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}
>
<RadixRadioGroup.Indicator
forceMount
@@ -25,7 +25,7 @@ export const radioButton = style({
'&[data-state="checked"]': {
color: cssVarV2('switch/fontColor/primary'),
},
'&[data-state="unchecked"]:hover': {
'&[data-state="unchecked"]:hover:not([disabled])': {
background: cssVarV2('switch/buttonBackground/hover'),
},
'[data-icon-mode=true] &': {
@@ -199,6 +199,9 @@ const REPLACE_SELECTION = {
icon: ReplaceIcon,
title: 'Replace selection',
showWhen: (host: EditorHost) => {
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');
@@ -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<string>();
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 = ({
<Button
variant="primary"
onClick={onConfirmRestore}
disabled={isMutating || !activeVersion}
disabled={isMutating || !activeVersion || !canEdit}
>
{t['com.affine.history.restore-current-version']()}
</Button>
@@ -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<HTMLDivElement>(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 (
<div
@@ -161,6 +162,7 @@ export const BlocksuiteEditorContainer = forwardRef<
shared={shared}
page={page}
ref={docRef}
readonly={readonly}
titleRef={docTitleRef}
onClickBlank={handleClickPageModeBlank}
defaultOpenProperty={defaultOpenProperty}
@@ -22,6 +22,7 @@ export type EditorProps = {
page: Store;
mode: DocMode;
shared?: boolean;
readonly?: boolean;
defaultOpenProperty?: DefaultOpenProperty;
// on Editor ready
onEditorReady?: (editor: AffineEditorContainer) => (() => 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}
@@ -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}
></div>
<StarterBar doc={page} />
{!readonly && <StarterBar doc={page} />}
{!shared && displayBiDirectionalLink ? (
<BiDirectionalLinkPanel />
) : null}
@@ -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 (
<>
<Menu
items={
<PageHeaderMenuItem
page={page}
containerWidth={containerWidth}
rename={rename}
isJournal={isJournal}
openHistoryModal={openHistoryModal}
/>
}
contentOptions={{
align: 'center',
}}
rootOptions={{
onOpenChange: handleMenuOpenChange,
}}
>
<HeaderDropDownButton />
</Menu>
{workspace.flavour !== 'local' ? (
<PageHistoryModal
docCollection={workspace.docCollection}
open={historyModalOpen}
pageId={page.id}
onOpenChange={setHistoryModalOpen}
/>
) : null}
<HistoryTipsModal
open={openHistoryTipsModal}
setOpen={setOpenHistoryTipsModal}
/>
</>
);
};
// 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 = (
<MenuItem
prefixIcon={isEditing ? <SaveIcon /> : <EditIcon />}
onSelect={handleToggleEdit}
>
{t[isEditing ? 'Save' : 'Edit']()}
</MenuItem>
);
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 && (
<MenuItem
prefixIcon={<EditIcon />}
data-testid="editor-option-menu-rename"
onSelect={handleRename}
disabled={!canEdit}
>
{t['Rename']()}
</MenuItem>
@@ -310,6 +353,7 @@ export const PageHeaderMenuButton = ({
prefixIcon={primaryMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
data-testid="editor-option-menu-edgeless"
onSelect={handleSwitchMode}
disabled={!canEdit}
>
{primaryMode === 'page'
? t['com.affine.editorDefaultMode.edgeless']()
@@ -396,6 +440,7 @@ export const PageHeaderMenuButton = ({
<MoveToTrash
data-testid="editor-option-menu-delete"
onSelect={handleOpenTrashModal}
disabled={!canMoveToTrash}
/>
{BUILD_CONFIG.isWeb && workspace.flavour === 'affine-cloud' ? (
<MenuItem
@@ -408,34 +453,4 @@ export const PageHeaderMenuButton = ({
) : null}
</>
);
if (isInTrash) {
return null;
}
return (
<>
<Menu
items={EditMenu}
contentOptions={{
align: 'center',
}}
rootOptions={{
onOpenChange: handleMenuOpenChange,
}}
>
<HeaderDropDownButton />
</Menu>
{workspace.flavour !== 'local' ? (
<PageHistoryModal
docCollection={workspace.docCollection}
open={historyModalOpen}
pageId={pageId}
onOpenChange={setHistoryModalOpen}
/>
) : null}
<HistoryTipsModal
open={openHistoryTipsModal}
setOpen={setOpenHistoryTipsModal}
/>
</>
);
};
@@ -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<HTMLInputElement>;
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"
@@ -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',
},
},
});
@@ -55,11 +55,20 @@ const IconsSelectorPanel = ({
export const DocPropertyIconSelector = ({
propertyInfo,
readonly,
onSelectedChange,
}: {
propertyInfo: DocCustomPropertyInfo;
readonly?: boolean;
onSelectedChange: (icon: DocPropertyIconName) => void;
}) => {
if (readonly) {
return (
<div className={styles.iconSelectorButton} data-readonly={readonly}>
<DocPropertyIcon propertyInfo={propertyInfo} />
</div>
);
}
return (
<Menu
items={
@@ -8,6 +8,7 @@ import {
} 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 { WorkspaceService } from '@affine/core/modules/workspace';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
@@ -37,9 +38,13 @@ const PropertyItem = ({
) => 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<AffineDNDData>(
() => ({
canDrag: canEditPropertyInfo,
data: {
entity: {
type: 'custom-property',
@@ -62,13 +68,14 @@ const PropertyItem = ({
},
},
}),
[propertyInfo, workspaceService]
[propertyInfo, workspaceService, canEditPropertyInfo]
);
const { dropTargetRef, closestEdge } = useDropTarget<AffineDNDData>(
() => ({
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 = ({
<EditDocPropertyMenuItems
propertyId={propertyInfo.id}
onPropertyInfoChange={onPropertyInfoChange}
readonly={!canEditPropertyInfo}
/>
}
>
@@ -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 = ({
>
<DocPropertyIconSelector
propertyInfo={propertyInfo}
readonly={readonly}
onSelectedChange={handleIconChange}
/>
{typeInfo?.renameable === false ? (
{typeInfo?.renameable === false || readonly ? (
<span className={styles.propertyName}>{name}</span>
) : (
<Input
@@ -180,6 +183,7 @@ export const EditDocPropertyMenuItems = ({
propertyInfo.show !== 'always-hide'
}
data-property-visibility="always-show"
disabled={readonly}
>
{t['com.affine.page-properties.property.always-show']()}
</MenuItem>
@@ -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']()}
</MenuItem>
@@ -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']()}
</MenuItem>
@@ -203,6 +209,7 @@ export const EditDocPropertyMenuItems = ({
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
disabled={readonly}
onClick={() => {
confirmModal.openConfirmModal({
title:
@@ -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<string>();
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 = () => {
<div
className={styles.itemContainer}
onClick={() => {
if (!canEditPropertyInfo) {
return;
}
onAddProperty({
type: key,
name,
});
}}
data-disabled={isUniqueExist}
data-disabled={isUniqueExist || !canEditPropertyInfo}
>
<Icon className={styles.itemIcon} />
<span className={styles.itemName}>{t.t(value.name)}</span>
@@ -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<AffineDNDData>(
() => ({
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<AffineDNDData>(
() => ({
@@ -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 = ({
<EditDocPropertyMenuItems
propertyId={propertyInfo.id}
onPropertyInfoChange={onPropertyInfoChange}
readonly={propertyInfoReadonly}
/>
}
data-testid="doc-property-name"
@@ -243,6 +253,7 @@ export const DocPropertyRow = ({
propertyInfo={propertyInfo}
onChange={handleChange}
value={customPropertyValue}
readonly={readonly}
/>
</PropertyRoot>
);
@@ -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<string | null>(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)
}
/>
))}
<div className={styles.actionContainer}>
<Menu
items={
<CreatePropertyMenuItems
at="after"
onCreated={handlePropertyAdded}
/>
}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
{!canEditPropertyInfo ? (
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.propertyActionButton}
data-testid="add-property-button"
disabled={!canEditPropertyInfo}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
) : (
<Menu
items={
<CreatePropertyMenuItems
at="after"
onCreated={handlePropertyAdded}
/>
}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.propertyActionButton}
data-testid="add-property-button"
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
)}
{viewService ? (
<Button
variant="plain"
@@ -4,14 +4,21 @@ import { useCallback } from 'react';
import * as styles from './checkbox.css';
import type { PropertyValueProps } from './types';
export const CheckboxValue = ({ value, onChange }: PropertyValueProps) => {
export const CheckboxValue = ({
value,
onChange,
readonly,
}: PropertyValueProps) => {
const parsedValue = value === 'true' ? true : false;
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (readonly) {
return;
}
onChange(parsedValue ? 'false' : 'true');
},
[onChange, parsedValue]
[onChange, parsedValue, readonly]
);
return (
<PropertyValue onClick={handleClick} className={styles.container}>
@@ -19,6 +26,7 @@ export const CheckboxValue = ({ value, onChange }: PropertyValueProps) => {
className={styles.checkboxProperty}
checked={parsedValue}
onChange={() => {}}
disabled={readonly}
/>
</PropertyValue>
);
@@ -77,14 +77,14 @@ export const CreatedByValue = () => {
if (!isCloud) {
return (
<PropertyValue>
<PropertyValue readonly>
<LocalUserValue />
</PropertyValue>
);
}
return (
<PropertyValue>
<PropertyValue readonly>
<CloudUserAvatar type="CreatedBy" />
</PropertyValue>
);
@@ -96,14 +96,14 @@ export const UpdatedByValue = () => {
if (!isCloud) {
return (
<PropertyValue>
<PropertyValue readonly>
<LocalUserValue />
</PropertyValue>
);
}
return (
<PropertyValue>
<PropertyValue readonly>
<CloudUserAvatar type="UpdatedBy" />
</PropertyValue>
);
@@ -23,9 +23,25 @@ const useParsedDate = (value: string) => {
};
};
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
export const DateValue = ({
value,
onChange,
readonly,
}: PropertyValueProps) => {
const { parsedValue, displayValue } = useParsedDate(value);
if (readonly) {
return (
<PropertyValue
className={parsedValue ? '' : styles.empty}
isEmpty={!parsedValue}
readonly
>
{displayValue}
</PropertyValue>
);
}
return (
<Menu
contentOptions={{
@@ -13,7 +13,10 @@ import { useCallback, useMemo } from 'react';
import * as styles from './doc-primary-mode.css';
import type { PropertyValueProps } from './types';
export const DocPrimaryModeValue = ({ onChange }: PropertyValueProps) => {
export const DocPrimaryModeValue = ({
onChange,
readonly,
}: PropertyValueProps) => {
const t = useI18n();
const doc = useService(DocService).doc;
@@ -51,13 +54,18 @@ export const DocPrimaryModeValue = ({ onChange }: PropertyValueProps) => {
[doc, t, onChange]
);
return (
<PropertyValue className={styles.container} hoverable={false}>
<PropertyValue
className={styles.container}
hoverable={false}
readonly={readonly}
>
<RadioGroup
width={BUILD_CONFIG.isMobileEdition ? '100%' : 194}
itemHeight={24}
value={primaryMode}
onChange={handleChange}
items={DocModeItems}
disabled={readonly}
className={styles.radioGroup}
/>
</PropertyValue>
@@ -23,7 +23,10 @@ const getThemeOptions = (t: ReturnType<typeof useI18n>) =>
},
] satisfies RadioItem[];
export const EdgelessThemeValue = ({ onChange }: PropertyValueProps) => {
export const EdgelessThemeValue = ({
onChange,
readonly,
}: PropertyValueProps) => {
const t = useI18n();
const doc = useService(DocService).doc;
const edgelessTheme = useLiveData(doc.properties$).edgelessColorTheme;
@@ -38,13 +41,18 @@ export const EdgelessThemeValue = ({ onChange }: PropertyValueProps) => {
const themeItems = useMemo<RadioItem[]>(() => getThemeOptions(t), [t]);
return (
<PropertyValue className={styles.container} hoverable={false}>
<PropertyValue
className={styles.container}
hoverable={false}
readonly={readonly}
>
<RadioGroup
width={BUILD_CONFIG.isMobileEdition ? '100%' : 194}
itemHeight={24}
value={edgelessTheme || 'system'}
onChange={handleChange}
items={themeItems}
disabled={readonly}
/>
</PropertyValue>
);
@@ -25,8 +25,11 @@ export const date = style({
padding: '0 4px',
borderRadius: 4,
whiteSpace: 'nowrap',
':hover': {
background: cssVarV2('layer/background/hoverOverlay'),
selectors: {
'&:hover:not([data-disabled])': {
background: cssVarV2('layer/background/hoverOverlay'),
},
},
});
@@ -17,7 +17,7 @@ import * as styles from './journal.css';
import type { PropertyValueProps } from './types';
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
export const JournalValue = ({ onChange }: PropertyValueProps) => {
export const JournalValue = ({ readonly }: PropertyValueProps) => {
const t = useI18n();
const journalService = useService(JournalService);
@@ -53,9 +53,8 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
const date = dayjs(day).format('YYYY-MM-DD');
setSelectedDate(date);
journalService.setJournalDate(doc.id, date);
onChange?.(date, true);
},
[journalService, doc.id, onChange]
[journalService, doc.id]
);
const handleCheck = useCallback(
@@ -63,12 +62,11 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
if (!v) {
journalService.removeJournalDate(doc.id);
setShowDatePicker(false);
onChange?.(null, true);
} else {
handleDateSelect(selectedDate);
}
},
[onChange, journalService, doc.id, handleDateSelect, selectedDate]
[journalService, doc.id, handleDateSelect, selectedDate]
);
const workbench = useService(WorkbenchService).workbench;
@@ -88,11 +86,12 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
const toggle = useCallback(
(e: React.MouseEvent) => {
if (readonly) return;
if (propertyRef.current?.contains(e.target as Node)) {
handleCheck(null, !checked);
}
},
[checked, handleCheck]
[checked, handleCheck, readonly]
);
return (
@@ -100,9 +99,14 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
ref={propertyRef}
className={styles.property}
onClick={toggle}
readonly={readonly}
>
<div className={styles.root}>
<Checkbox className={styles.checkbox} checked={checked} />
<Checkbox
className={styles.checkbox}
checked={checked}
disabled={readonly}
/>
{checked ? (
<Menu
contentOptions={{
@@ -113,7 +117,7 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
}}
rootOptions={{
modal: true,
open: showDatePicker,
open: !readonly && showDatePicker,
onOpenChange: setShowDatePicker,
}}
items={
@@ -132,6 +136,7 @@ export const JournalValue = ({ onChange }: PropertyValueProps) => {
onClick={e => {
e.stopPropagation();
}}
data-disabled={readonly ? 'true' : undefined}
>
{displayDate}
</div>
@@ -10,7 +10,11 @@ import {
import * as styles from './number.css';
import type { PropertyValueProps } from './types';
export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
export const NumberValue = ({
value,
onChange,
readonly,
}: PropertyValueProps) => {
const parsedValue = isNaN(Number(value)) ? null : value;
const [tempValue, setTempValue] = useState(parsedValue);
const handleBlur = useCallback(
@@ -33,6 +37,7 @@ export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
<PropertyValue
className={styles.numberPropertyValueContainer}
isEmpty={!parsedValue}
readonly={readonly}
>
<input
className={styles.numberPropertyValueInput}
@@ -45,6 +50,7 @@ export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
disabled={readonly}
/>
</PropertyValue>
);
@@ -8,7 +8,7 @@ import { useCallback, useMemo } from 'react';
import { container } from './page-width.css';
import type { PageLayoutMode, PropertyValueProps } from './types';
export const PageWidthValue = ({ onChange }: PropertyValueProps) => {
export const PageWidthValue = ({ readonly }: PropertyValueProps) => {
const t = useI18n();
const editorSetting = useService(EditorSettingService).editorSetting;
const defaultPageWidth = useLiveData(editorSetting.settings$).fullWidthLayout;
@@ -45,18 +45,18 @@ export const PageWidthValue = ({ onChange }: PropertyValueProps) => {
const handleChange = useCallback(
(value: PageLayoutMode) => {
doc.record.setProperty('pageWidth', value);
onChange?.(value, true);
},
[doc, onChange]
[doc]
);
return (
<PropertyValue className={container} hoverable={false}>
<PropertyValue className={container} hoverable={false} readonly={readonly}>
<RadioGroup
width={BUILD_CONFIG.isMobileEdition ? '100%' : 194}
itemHeight={24}
value={radioValue}
onChange={handleChange}
items={radioItems}
disabled={readonly}
/>
</PropertyValue>
);
@@ -8,7 +8,7 @@ import { TagsInlineEditor } from '../tags-inline-editor';
import * as styles from './tags.css';
import type { PropertyValueProps } from './types';
export const TagsValue = ({ onChange }: PropertyValueProps) => {
export const TagsValue = ({ readonly }: PropertyValueProps) => {
const t = useI18n();
const doc = useService(DocService).doc;
@@ -22,6 +22,7 @@ export const TagsValue = ({ onChange }: PropertyValueProps) => {
className={styles.container}
isEmpty={empty}
data-testid="property-tags-value"
readonly={readonly}
>
<TagsInlineEditor
className={styles.tagInlineEditor}
@@ -29,7 +30,8 @@ export const TagsValue = ({ onChange }: PropertyValueProps) => {
'com.affine.page-properties.property-value-placeholder'
]()}
pageId={doc.id}
onChange={value => onChange(value, true)}
onChange={() => {}}
readonly={readonly}
/>
</PropertyValue>
);
@@ -6,9 +6,7 @@ import { type ChangeEvent, useCallback } from 'react';
import * as styles from './template.css';
import type { PropertyValueProps } from './types';
export const TemplateValue = ({
onChange: propOnChange,
}: PropertyValueProps) => {
export const TemplateValue = ({ readonly }: PropertyValueProps) => {
const docService = useService(DocService);
const isTemplate = useLiveData(
@@ -17,24 +15,26 @@ export const TemplateValue = ({
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (readonly) return;
const value = e.target.checked;
docService.doc.record.setProperty('isTemplate', value);
propOnChange?.(value, true);
},
[docService.doc.record, propOnChange]
[docService.doc.record, readonly]
);
const toggle = useCallback(() => {
if (readonly) return;
docService.doc.record.setProperty('isTemplate', !isTemplate);
}, [docService.doc.record, isTemplate]);
}, [docService.doc.record, isTemplate, readonly]);
return (
<PropertyValue className={styles.property} onClick={toggle}>
<PropertyValue className={styles.property} onClick={toggle} readonly>
<Checkbox
data-testid="toggle-template-checkbox"
checked={!!isTemplate}
onChange={onChange}
className={styles.checkbox}
disabled={readonly}
/>
</PropertyValue>
);
@@ -13,7 +13,11 @@ import { ConfigModal } from '../../mobile';
import * as styles from './text.css';
import type { PropertyValueProps } from './types';
const DesktopTextValue = ({ value, onChange }: PropertyValueProps) => {
const DesktopTextValue = ({
value,
onChange,
readonly,
}: PropertyValueProps) => {
const [tempValue, setTempValue] = useState<string>(value);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -50,6 +54,7 @@ const DesktopTextValue = ({ value, onChange }: PropertyValueProps) => {
className={styles.textPropertyValueContainer}
onClick={handleClick}
isEmpty={!value}
readonly={readonly}
>
<textarea
ref={ref}
@@ -62,6 +67,7 @@ const DesktopTextValue = ({ value, onChange }: PropertyValueProps) => {
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
disabled={readonly}
/>
<div className={styles.textInvisible}>
{tempValue}
@@ -3,6 +3,7 @@ import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
export interface PropertyValueProps {
propertyInfo?: DocCustomPropertyInfo;
value: any;
readonly?: boolean;
onChange: (value: any, skipCommit?: boolean) => void; // if skipCommit is true, the change will be handled in the component itself
}
@@ -0,0 +1,24 @@
import {
type DocPermissionActions,
GuardService,
} from '@affine/core/modules/permissions';
import { useLiveData, useService } from '@toeverything/infra';
import type React from 'react';
export const DocPermissionGuard = ({
docId,
children,
permission,
}: {
docId: string;
permission: DocPermissionActions;
children: (can: boolean) => React.ReactNode;
}) => {
const guardService = useService(GuardService);
const can = useLiveData(guardService.can$(permission, docId));
if (typeof children === 'function') {
return children(can);
}
throw new Error('children must be a function');
};
@@ -12,8 +12,7 @@ import { useNavigateHelper } from '../use-navigate-helper';
export function useBlockSuiteMetaHelper() {
const workspace = useService(WorkspaceService).workspace;
const { setDocMeta, getDocMeta, setDocTitle, setDocReadonly } =
useDocMetaHelper();
const { setDocMeta, getDocMeta, setDocTitle } = useDocMetaHelper();
const { createDoc } = useDocCollectionHelper(workspace.docCollection);
const { openPage } = useNavigateHelper();
const docRecordList = useService(DocsService).list;
@@ -25,10 +24,9 @@ export function useBlockSuiteMetaHelper() {
const docRecord = docRecordList.doc$(docId).value;
if (docRecord) {
docRecord.moveToTrash();
setDocReadonly(docId, true);
}
},
[docRecordList, setDocReadonly]
[docRecordList]
);
const restoreFromTrash = useCallback(
@@ -36,10 +34,9 @@ export function useBlockSuiteMetaHelper() {
const docRecord = docRecordList.doc$(docId).value;
if (docRecord) {
docRecord.restoreFromTrash();
setDocReadonly(docId, false);
}
},
[docRecordList, setDocReadonly]
[docRecordList]
);
const permanentlyDeletePage = useCallback(
@@ -9,7 +9,9 @@ import type { Editor } from '@affine/core/modules/editor';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { OpenInAppService } from '@affine/core/modules/open-in-app';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
@@ -35,6 +37,7 @@ export function useRegisterBlocksuiteEditorCommands(
active: boolean
) {
const doc = useService(DocService).doc;
const guardService = useService(GuardService);
const docId = doc.id;
const mode = useLiveData(editor.mode$);
const t = useI18n();
@@ -74,11 +77,22 @@ export function useRegisterBlocksuiteEditorCommands(
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
onConfirm: () => {
doc.moveToTrash();
onConfirm: async () => {
try {
const canTrash = await guardService.can('Doc_Trash', docId);
if (!canTrash) {
toast(t['com.affine.no-permission']());
return;
}
doc.moveToTrash();
} catch (error) {
console.error(error);
const userFriendlyError = UserFriendlyError.fromAnyError(error);
toast(t[`error.${userFriendlyError.name}`](userFriendlyError.data));
}
},
});
}, [doc, openConfirmModal, t]);
}, [doc, docId, guardService, openConfirmModal, t]);
const isCloudWorkspace = workspace.flavour !== 'local';
@@ -190,6 +204,11 @@ export function useRegisterBlocksuiteEditorCommands(
? t['com.affine.cmdk.affine.current-page-width-layout.standard']()
: t['com.affine.cmdk.affine.current-page-width-layout.full-width'](),
async run() {
const canEdit = await guardService.can('Doc_Update', docId);
if (!canEdit) {
toast(t['com.affine.no-permission']());
return;
}
doc.record.setProperty(
'pageWidth',
checked ? 'standard' : 'fullWidth'
@@ -306,7 +325,12 @@ export function useRegisterBlocksuiteEditorCommands(
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.cmdk.affine.editor.restore-from-trash'](),
run() {
async run() {
const canRestore = await guardService.can('Doc_Restore', docId);
if (!canRestore) {
toast(t['com.affine.no-permission']());
return;
}
track.$.cmdk.editor.restoreDoc();
doc.restoreFromTrash();
@@ -383,5 +407,6 @@ export function useRegisterBlocksuiteEditorCommands(
checked,
openInAppService,
active,
guardService,
]);
}
@@ -1,5 +1,4 @@
import { DocsService } from '@affine/core/modules/doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { DocMeta, Workspace } from '@blocksuite/affine/store';
import { useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
@@ -26,7 +25,6 @@ export function useBlockSuiteDocMeta(docCollection: Workspace) {
}
export function useDocMetaHelper() {
const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService);
const setDocTitle = useAsyncCallback(
@@ -53,23 +51,13 @@ export function useDocMetaHelper() {
},
[docsService]
);
const setDocReadonly = useCallback(
(docId: string, readonly: boolean) => {
const doc = workspaceService.workspace.docCollection.getDoc(docId);
if (doc) {
doc.readonly = readonly;
}
},
[workspaceService]
);
return useMemo(
() => ({
setDocTitle,
setDocMeta,
getDocMeta,
setDocReadonly,
}),
[getDocMeta, setDocMeta, setDocReadonly, setDocTitle]
[getDocMeta, setDocMeta, setDocTitle]
);
}
@@ -3,6 +3,7 @@ import './page-detail-editor.css';
import type { AffineEditorContainer } from '@blocksuite/affine/presets';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useEffect } from 'react';
import { DocService } from '../modules/doc';
import { EditorService } from '../modules/editor';
@@ -24,9 +25,13 @@ export type OnLoadEditor = (
export interface PageDetailEditorProps {
onLoad?: OnLoadEditor;
readonly?: boolean;
}
export const PageDetailEditor = ({ onLoad }: PageDetailEditorProps) => {
export const PageDetailEditor = ({
onLoad,
readonly,
}: PageDetailEditorProps) => {
const editor = useService(EditorService).editor;
const mode = useLiveData(editor.mode$);
const defaultOpenProperty = useLiveData(editor.defaultOpenProperty$);
@@ -47,6 +52,10 @@ export const PageDetailEditor = ({ onLoad }: PageDetailEditorProps) => {
? pageWidth === 'fullWidth'
: settings.fullWidthLayout;
useEffect(() => {
editor.doc.blockSuiteDoc.readonly = readonly ?? false;
}, [editor, readonly]);
return (
<CustomEditorWrapper>
<Editor
@@ -58,6 +67,7 @@ export const PageDetailEditor = ({ onLoad }: PageDetailEditorProps) => {
defaultOpenProperty={defaultOpenProperty}
page={editor.doc.blockSuiteDoc}
shared={isSharedMode}
readonly={readonly}
onEditorReady={onLoad}
/>
</CustomEditorWrapper>
@@ -26,7 +26,7 @@ export const ListFloatingToolbar = ({
<FloatingToolbar className={styles.floatingToolbar} open={open}>
<FloatingToolbar.Item>{content}</FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
<FloatingToolbar.Separator />
{(!!onRestore || !!onDelete) && <FloatingToolbar.Separator />}
{!!onRestore && (
<FloatingToolbar.Button
onClick={onRestore}
@@ -1,11 +1,19 @@
import { Checkbox, Tooltip, useDraggable } from '@affine/component';
import { type Doc, DocsService } from '@affine/core/modules/doc';
import { TagService } from '@affine/core/modules/tag';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { stopPropagation } from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import type { ForwardedRef, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useEffect, useMemo } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
import {
@@ -142,7 +150,7 @@ const PageListOperationsCell = ({
) : null;
};
export const PageListItem = (props: PageListItemProps) => {
const PagelistItemInner = (props: PageListItemProps) => {
const [displayProperties] = useAllDocDisplayProperties();
const pageTitleElement = useMemo(() => {
return (
@@ -249,6 +257,29 @@ export const PageListItem = (props: PageListItemProps) => {
);
};
export const PageListItem = (props: PageListItemProps) => {
const docsService = useService(DocsService);
const [doc, setDoc] = useState<Doc | null>(null);
useLayoutEffect(() => {
const { doc, release } = docsService.open(props.pageId);
setDoc(doc);
return () => {
release();
};
}, [props.pageId, docsService.list, docsService]);
if (!doc) {
return null;
}
return (
<FrameworkScope scope={doc.scope}>
<PagelistItemInner {...props} />
</FrameworkScope>
);
};
type PageListWrapperProps = PropsWithChildren<
Pick<PageListItemProps, 'to' | 'pageId' | 'onClick' | 'draggable'> & {
isDragging: boolean;
@@ -56,12 +56,14 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
filters,
listItem,
setHideHeaderCreateNewPage,
disableMultiDelete,
}: {
tag?: Tag;
collection?: Collection;
filters?: Filter[];
listItem?: DocMeta[];
setHideHeaderCreateNewPage?: (hide: boolean) => void;
disableMultiDelete?: boolean;
}) {
const t = useI18n();
const listRef = useRef<ItemListHandle>(null);
@@ -186,7 +188,7 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
/>
<ListFloatingToolbar
open={showFloatingToolbar}
onDelete={handleMultiDelete}
onDelete={disableMultiDelete ? undefined : handleMultiDelete}
onClose={hideFloatingToolbar}
content={
<Trans
@@ -14,6 +14,7 @@ import {
CompatibleFavoriteItemsAdapter,
FavoriteService,
} from '@affine/core/modules/favorite';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
@@ -57,7 +58,7 @@ export interface PageOperationCellProps {
onRemoveFromAllowList?: () => void;
}
export const PageOperationCell = ({
const PageOperationCellMenuItem = ({
isInAllowList,
page,
onRemoveFromAllowList,
@@ -67,12 +68,15 @@ export const PageOperationCell = ({
workspaceService,
compatibleFavoriteItemsAdapter: favAdapter,
workbenchService,
guardService,
} = useServices({
WorkspaceService,
CompatibleFavoriteItemsAdapter,
WorkbenchService,
GuardService,
});
const canMoveToTrash = useLiveData(guardService.can$('Doc_Trash', page.id));
const currentWorkspace = workspaceService.workspace;
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const workbench = workbenchService.workbench;
@@ -159,7 +163,7 @@ export const PageOperationCell = ({
}
}, [onRemoveFromAllowList]);
const OperationMenu = (
return (
<>
{page.isPublic && (
<DisablePublicSharing
@@ -199,9 +203,36 @@ export const PageOperationCell = ({
{t['com.affine.header.option.duplicate']()}
</MenuItem>
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
<MoveToTrash
data-testid="move-to-trash"
onSelect={onRemoveToTrash}
disabled={!canMoveToTrash}
/>
</>
);
};
export const PageOperationCell = ({
isInAllowList,
page,
onRemoveFromAllowList,
}: PageOperationCellProps) => {
const t = useI18n();
const { compatibleFavoriteItemsAdapter: favAdapter } = useServices({
CompatibleFavoriteItemsAdapter,
});
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const onToggleFavoritePage = useCallback(() => {
const status = favAdapter.isFavorite(page.id, 'doc');
favAdapter.toggle(page.id, 'doc');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [page.id, favAdapter, t]);
return (
<>
<ColWrapper
@@ -214,7 +245,13 @@ export const PageOperationCell = ({
</ColWrapper>
<ColWrapper alignment="start">
<Menu
items={OperationMenu}
items={
<PageOperationCellMenuItem
page={page}
isInAllowList={isInAllowList}
onRemoveFromAllowList={onRemoveFromAllowList}
/>
}
contentOptions={{
align: 'end',
}}
@@ -1,6 +1,7 @@
import { toast, useConfirmModal } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
@@ -16,8 +17,15 @@ import type { ItemListHandle, ListItem } from './types';
import { useFilteredPageMetas } from './use-filtered-page-metas';
import { VirtualizedList } from './virtualized-list';
export const VirtualizedTrashList = () => {
export const VirtualizedTrashList = ({
disableMultiDelete,
disableMultiRestore,
}: {
disableMultiDelete?: boolean;
disableMultiRestore?: boolean;
}) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const guardService = useService(GuardService);
const docCollection = currentWorkspace.docCollection;
const { restoreFromTrash, permanentlyDeletePage } = useBlockSuiteMetaHelper();
const pageMetas = useBlockSuiteDocMeta(docCollection);
@@ -82,16 +90,38 @@ export const VirtualizedTrashList = () => {
(item: ListItem) => {
const page = item as DocMeta;
const onRestorePage = () => {
restoreFromTrash(page.id);
toast(
t['com.affine.toastMessage.restored']({
title: page.title || 'Untitled',
guardService
.can('Doc_Delete', page.id)
.then(can => {
if (can) {
restoreFromTrash(page.id);
toast(
t['com.affine.toastMessage.restored']({
title: page.title || 'Untitled',
})
);
} else {
toast(t['com.affine.no-permission']());
}
})
);
.catch(e => {
console.error(e);
});
};
const onPermanentlyDeletePage = () => {
permanentlyDeletePage(page.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
guardService
.can('Doc_Delete', page.id)
.then(can => {
if (can) {
permanentlyDeletePage(page.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
} else {
toast(t['com.affine.no-permission']());
}
})
.catch(e => {
console.error(e);
});
};
return (
@@ -102,7 +132,7 @@ export const VirtualizedTrashList = () => {
);
},
[permanentlyDeletePage, restoreFromTrash, t]
[guardService, permanentlyDeletePage, restoreFromTrash, t]
);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
@@ -128,9 +158,9 @@ export const VirtualizedTrashList = () => {
/>
<ListFloatingToolbar
open={showFloatingToolbar}
onDelete={onConfirmPermanentlyDelete}
onDelete={disableMultiDelete ? undefined : onConfirmPermanentlyDelete}
onClose={hideFloatingToolbar}
onRestore={handleMultiRestore}
onRestore={disableMultiRestore ? undefined : handleMultiRestore}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
@@ -1,12 +1,15 @@
import {
AnimatedDeleteIcon,
toast,
useConfirmModal,
useDropTarget,
} from '@affine/component';
import { MenuLinkItem } from '@affine/core/modules/app-sidebar/views';
import { DocsService } from '@affine/core/modules/doc';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { GuardService } from '@affine/core/modules/permissions';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
@@ -16,6 +19,7 @@ export const TrashButton = () => {
const { openConfirmModal } = useConfirmModal();
const globalContextService = useService(GlobalContextService);
const trashActive = useLiveData(globalContextService.globalContext.isTrash.$);
const guardService = useService(GuardService);
const { dropTargetRef, draggedOver } = useDropTarget<AffineDNDData>(
() => ({
@@ -41,15 +45,32 @@ export const TrashButton = () => {
confirmButtonOptions: {
variant: 'error',
},
onConfirm() {
docRecord.moveToTrash();
async onConfirm() {
try {
const canTrash = await guardService.can(
'Doc_Trash',
docRecord.id
);
if (!canTrash) {
toast(t['com.affine.no-permission']());
return;
}
docRecord.moveToTrash();
} catch (error) {
console.error(error);
const userFriendlyError =
UserFriendlyError.fromAnyError(error);
toast(
t[`error.${userFriendlyError.name}`](userFriendlyError.data)
);
}
},
});
}
}
},
}),
[docsService.list, openConfirmModal, t]
[docsService.list, guardService, openConfirmModal, t]
);
return (
@@ -7,6 +7,9 @@ export const tagsInlineEditor = style({
'&[data-empty=true]': {
color: cssVar('placeholderColor'),
},
'&[data-readonly="true"]': {
pointerEvents: 'none',
},
},
});
@@ -47,10 +47,7 @@ export const DocInfoDialog = ({
className={styles.titleContainer}
data-testid="info-modal-title"
>
<BlocksuiteHeaderTitle
docId={docId}
className={styles.titleStyle}
/>
<BlocksuiteHeaderTitle className={styles.titleStyle} />
</div>
<InfoTable docId={docId} onClose={() => close()} />
</Scrollable.Viewport>
@@ -16,6 +16,7 @@ import type {
DatabaseValueCell,
} from '@affine/core/modules/doc-info/types';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { GuardService } from '@affine/core/modules/permissions';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc';
@@ -33,10 +34,15 @@ export const InfoTable = ({
onClose: () => void;
}) => {
const t = useI18n();
const { docsSearchService, docsService } = useServices({
const { docsSearchService, docsService, guardService } = useServices({
DocsSearchService,
DocsService,
GuardService,
});
const canEditPropertyInfo = useLiveData(
guardService.can$('Workspace_Properties_Update')
);
const canEditProperty = useLiveData(guardService.can$('Doc_Update', docId));
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const properties = useLiveData(docsService.propertyList.sortedProperties$);
const links = useLiveData(
@@ -133,6 +139,8 @@ export const InfoTable = ({
<DocPropertyRow
key={property.id}
propertyInfo={property}
readonly={!canEditProperty}
propertyInfoReadonly={!canEditPropertyInfo}
defaultOpenEditMenu={newPropertyId === property.id}
onChange={value => onPropertyChange(property, value)}
onPropertyInfoChange={(...args) =>
@@ -140,22 +148,33 @@ export const InfoTable = ({
}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={onPropertyAdded} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
{!canEditPropertyInfo ? (
<Button
disabled
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
) : (
<Menu
items={<CreatePropertyMenuItems onCreated={onPropertyAdded} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
)}
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
<Divider size="thinner" />
@@ -5,10 +5,11 @@ import {
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Filter } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import {
@@ -26,8 +27,11 @@ import { AllPageHeader } from './all-page-header';
export const AllPage = () => {
const currentWorkspace = useService(WorkspaceService).workspace;
const globalContext = useService(GlobalContextService).globalContext;
const permissionService = useService(WorkspacePermissionService);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const [filters, setFilters] = useState<Filter[]>([]);
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
@@ -65,6 +69,7 @@ export const AllPage = () => {
<FilterContainer filters={filters} onChangeFilters={setFilters} />
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
disableMultiDelete={!isAdmin && !isOwner}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
filters={filters}
/>
@@ -4,6 +4,7 @@ import { VirtualizedPageList } from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
@@ -30,6 +31,9 @@ export const CollectionDetail = ({
const { workspaceDialogService } = useServices({
WorkspaceDialogService,
});
const permissionService = useService(WorkspacePermissionService);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const handleEditCollection = useCallback(() => {
@@ -50,6 +54,7 @@ export const CollectionDetail = ({
<VirtualizedPageList
collection={collection}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
disableMultiDelete={!isAdmin && !isOwner}
/>
</ViewBody>
</>
@@ -158,10 +158,7 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
<ViewTitle title={title} />
<ViewIcon icon={currentMode ?? 'page'} />
<EditorModeSwitch />
<BlocksuiteHeaderTitle
docId={page.id}
inputHandleRef={titleInputHandleRef}
/>
<BlocksuiteHeaderTitle inputHandleRef={titleInputHandleRef} />
<TemplateMark />
<div className={styles.iconButtonContainer}>
{hideCollect ? null : (
@@ -6,12 +6,12 @@ import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
import { DocPropertySidebar } from '@affine/core/components/doc-properties/sidebar';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { DocService } from '@affine/core/modules/doc';
import { EditorService } from '@affine/core/modules/editor';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { GuardService } from '@affine/core/modules/permissions';
import { RecentDocsService } from '@affine/core/modules/quicksearch';
import { ViewService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
@@ -73,6 +73,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
workspaceService,
globalContextService,
featureFlagService,
guardService,
} = useServices({
WorkbenchService,
ViewService,
@@ -81,6 +82,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
WorkspaceService,
GlobalContextService,
FeatureFlagService,
GuardService,
});
const workbench = workbenchService.workbench;
const editor = editorService.editor;
@@ -98,7 +100,6 @@ const DetailPageImpl = memo(function DetailPageImpl() {
const isSideBarOpen = useLiveData(workbench.sidebarOpen$);
const { appSettings } = useAppSettingHelper();
const chatPanelRef = useRef<ChatPanel | null>(null);
const { setDocReadonly } = useDocMetaHelper();
const peekView = useService(PeekViewService).peekView;
@@ -149,12 +150,6 @@ const DetailPageImpl = memo(function DetailPageImpl() {
return;
}, [doc, globalContext, isActiveView, mode]);
useEffect(() => {
if ('isMobile' in environment && environment.isMobile) {
setDocReadonly(doc.id, true);
}
}, [doc.id, setDocReadonly]);
useEffect(() => {
if (isActiveView) {
globalContext.isTrashDoc.set(!!isInTrash);
@@ -273,6 +268,10 @@ const DetailPageImpl = memo(function DetailPageImpl() {
const [dragging, setDragging] = useState(false);
const canEdit = useLiveData(guardService.can$('Doc_Update', doc.id));
const readonly = !canEdit || isInTrash;
return (
<FrameworkScope scope={editor.scope}>
<ViewHeader>
@@ -302,7 +301,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
styles.editorContainer
)}
>
<PageDetailEditor onLoad={onLoad} />
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
</Scrollable.Viewport>
<Scrollable.Scrollbar
className={clsx({
@@ -8,6 +8,7 @@ import {
Scrollable,
useConfirmModal,
} from '@affine/component';
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { MoveToTrash } from '@affine/core/components/page-list';
import {
@@ -362,22 +363,38 @@ const ConflictList = ({
}}
items={
<>
<MenuItem
prefixIcon={<CalendarXmarkIcon />}
onClick={e => {
e.stopPropagation();
handleRemoveJournalMark(docRecord.id);
}}
data-testid="journal-conflict-remove-mark"
<DocPermissionGuard
docId={docRecord.id}
permission="Doc_Update"
>
{t[
'com.affine.page-properties.property.journal-remove'
]()}
</MenuItem>
{canEdit => (
<MenuItem
prefixIcon={<CalendarXmarkIcon />}
onClick={e => {
e.stopPropagation();
handleRemoveJournalMark(docRecord.id);
}}
data-testid="journal-conflict-remove-mark"
disabled={!canEdit}
>
{t[
'com.affine.page-properties.property.journal-remove'
]()}
</MenuItem>
)}
</DocPermissionGuard>
<MenuSeparator />
<MoveToTrash
onSelect={() => handleOpenTrashModal(docRecord)}
/>
<DocPermissionGuard
docId={docRecord.id}
permission="Doc_Trash"
>
{canTrash => (
<MoveToTrash
onSelect={() => handleOpenTrashModal(docRecord)}
disabled={!canTrash}
/>
)}
</DocPermissionGuard>
</>
}
>
@@ -6,7 +6,6 @@ import type { DocMode } from '@blocksuite/affine/blocks';
import * as styles from './share-header.css';
export function ShareHeader({
pageId,
publishMode,
isTemplate,
templateName,
@@ -21,7 +20,7 @@ export function ShareHeader({
return (
<div className={styles.header}>
<EditorModeSwitch />
<BlocksuiteHeaderTitle docId={pageId} />
<BlocksuiteHeaderTitle />
<div className={styles.spacer} />
<ShareHeaderRightItem
publishMode={publishMode}
@@ -260,7 +260,7 @@ const SharePageInner = ({
styles.editorContainer
)}
>
<PageDetailEditor onLoad={onEditorLoad} />
<PageDetailEditor onLoad={onEditorLoad} readonly />
{publishMode === 'page' && !BUILD_CONFIG.isElectron ? (
<ShareFooter />
) : null}
@@ -4,6 +4,7 @@ import {
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { TagService } from '@affine/core/modules/tag';
import {
useIsActiveView,
@@ -26,6 +27,9 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
const globalContext = useService(GlobalContextService).globalContext;
const currentWorkspace = useService(WorkspaceService).workspace;
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const permissionService = useService(WorkspacePermissionService);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const tagList = useService(TagService).tagList;
const currentTag = useLiveData(tagList.tagByTagId$(tagId));
@@ -72,6 +76,7 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
<VirtualizedPageList
tag={currentTag}
listItem={filteredPageMetas}
disableMultiDelete={!isAdmin && !isOwner}
/>
) : (
<EmptyPageList
@@ -5,11 +5,12 @@ import {
} from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { assertExists } from '@blocksuite/affine/global/utils';
import { DeleteIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import {
@@ -39,6 +40,9 @@ const TrashHeader = () => {
export const TrashPage = () => {
const globalContextService = useService(GlobalContextService);
const currentWorkspace = useService(WorkspaceService).workspace;
const permissionService = useService(WorkspacePermissionService);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const docCollection = currentWorkspace.docCollection;
assertExists(docCollection);
@@ -71,7 +75,10 @@ export const TrashPage = () => {
<ViewBody>
<div className={styles.body}>
{filteredPageMetas.length > 0 ? (
<VirtualizedTrashList />
<VirtualizedTrashList
disableMultiDelete={!isAdmin && !isOwner}
disableMultiRestore={!isAdmin && !isOwner}
/>
) : (
<EmptyPageList type="trash" />
)}
@@ -17,6 +17,7 @@ import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { DocsService } from '@affine/core/modules/doc';
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { GuardService } from '@affine/core/modules/permissions';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useServices } from '@toeverything/infra';
@@ -30,12 +31,17 @@ export const DocInfoSheet = ({
docId: string;
defaultOpenProperty?: DefaultOpenProperty;
}) => {
const { docsSearchService, docsService } = useServices({
const { docsSearchService, docsService, guardService } = useServices({
DocsSearchService,
DocsService,
GuardService,
});
const t = useI18n();
const canEditPropertyInfo = useLiveData(
guardService.can$('Workspace_Properties_Update')
);
const canEditProperty = useLiveData(guardService.can$('Doc_Update', docId));
const links = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
@@ -102,16 +108,11 @@ export const DocInfoSheet = ({
key={property.id}
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
propertyInfoReadonly={!canEditPropertyInfo}
readonly={!canEditProperty}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={onPropertyAdded} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
{!canEditPropertyInfo ? (
<Button
variant="plain"
prefix={<PlusIcon />}
@@ -119,7 +120,26 @@ export const DocInfoSheet = ({
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
) : (
<Menu
items={
<CreatePropertyMenuItems onCreated={onPropertyAdded} />
}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
)}
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
<Divider size="thinner" />
@@ -1,4 +1,5 @@
import { Loading } from '@affine/component';
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
@@ -136,10 +137,16 @@ export const ExplorerDocNode = ({
<ExplorerDocNode key={child.docId} docId={child.docId} isLinked />
))}
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleAddLinkedPage}
/>
<DocPermissionGuard docId={docId} permission="Doc_Update">
{canEdit =>
canEdit ? (
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleAddLinkedPage}
/>
) : null
}
</DocPermissionGuard>
</ExplorerTreeNode>
);
};
@@ -1,5 +1,4 @@
import {
IconButton,
MenuItem,
MenuSeparator,
MenuSub,
@@ -7,6 +6,7 @@ import {
useConfirmModal,
} from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
@@ -24,8 +24,6 @@ import {
InformationIcon,
LinkedPageIcon,
OpenInNewIcon,
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
@@ -121,7 +119,7 @@ export const useExplorerDocNodeOperations = (
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
options.openNodeCollapsed();
}, [createPage, docsService, docId, options]);
}, [createPage, docId, docsService, options]);
const handleToggleFavoriteDoc = useCallback(() => {
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
@@ -175,7 +173,6 @@ export const useExplorerDocNodeOperationsMenu = (
handleAddLinkedPage,
handleDuplicate,
handleToggleFavoriteDoc,
handleOpenInSplitView,
handleOpenInNewTab,
handleMoveToTrash,
handleRename,
@@ -187,21 +184,19 @@ export const useExplorerDocNodeOperationsMenu = (
return useMemo(
() => [
{
index: 0,
inline: true,
view: (
<IconButton
size="16"
icon={<PlusIcon />}
tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleAddLinkedPage}
/>
),
},
{
index: 10,
view: <DocRenameSubMenu onConfirm={handleRename} initialName={title} />,
view: (
<DocPermissionGuard docId={docId} permission="Doc_Update">
{canEdit => (
<DocRenameSubMenu
onConfirm={handleRename}
initialName={title}
disabled={!canEdit}
/>
)}
</DocPermissionGuard>
),
},
{
index: 11,
@@ -229,12 +224,17 @@ export const useExplorerDocNodeOperationsMenu = (
{
index: 97,
view: (
<MenuItem
prefixIcon={<LinkedPageIcon />}
onClick={handleAddLinkedPage}
>
{t['com.affine.page-operation.add-linked-page']()}
</MenuItem>
<DocPermissionGuard docId={docId} permission="Doc_Update">
{canEdit => (
<MenuItem
prefixIcon={<LinkedPageIcon />}
onClick={handleAddLinkedPage}
disabled={!canEdit}
>
{t['com.affine.page-operation.add-linked-page']()}
</MenuItem>
)}
</DocPermissionGuard>
),
},
{
@@ -253,21 +253,6 @@ export const useExplorerDocNodeOperationsMenu = (
</MenuItem>
),
},
...(BUILD_CONFIG.isElectron
? [
{
index: 100,
view: (
<MenuItem
prefixIcon={<SplitViewIcon />}
onClick={handleOpenInSplitView}
>
{t['com.affine.workbench.split-view.page-menu-open']()}
</MenuItem>
),
},
]
: []),
{
index: 199,
view: (
@@ -288,13 +273,18 @@ export const useExplorerDocNodeOperationsMenu = (
{
index: 10000,
view: (
<MenuItem
type={'danger'}
prefixIcon={<DeleteIcon />}
onClick={handleMoveToTrash}
>
{t['com.affine.moveToTrash.title']()}
</MenuItem>
<DocPermissionGuard docId={docId} permission="Doc_Trash">
{canMoveToTrash => (
<MenuItem
type={'danger'}
prefixIcon={<DeleteIcon />}
onClick={handleMoveToTrash}
disabled={!canMoveToTrash}
>
{t['com.affine.moveToTrash.title']()}
</MenuItem>
)}
</DocPermissionGuard>
),
},
],
@@ -305,7 +295,6 @@ export const useExplorerDocNodeOperationsMenu = (
handleDuplicate,
handleMoveToTrash,
handleOpenInNewTab,
handleOpenInSplitView,
handleRename,
handleToggleFavoriteDoc,
t,
@@ -14,6 +14,7 @@ export const RenameSubMenu = ({
children,
menuProps,
onConfirm,
disabled,
...props
}: RenameSubMenuProps) => {
const t = useI18n();
@@ -33,6 +34,7 @@ export const RenameSubMenu = ({
triggerOptions={{
prefixIcon: icon ?? <EditIcon />,
suffixIcon: null,
disabled,
...triggerOptions,
}}
items={
@@ -28,6 +28,7 @@ export interface RenameSubMenuProps
/** MenuItem.text */
text?: string;
menuProps?: Partial<MenuSubProps>;
disabled?: boolean;
}
export interface RenameDialogProps
@@ -5,6 +5,7 @@ import {
MobileMenuSub,
useConfirmModal,
} from '@affine/component';
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
import { MoveToTrash } from '@affine/core/components/page-list';
import {
type DocRecord,
@@ -58,16 +59,28 @@ export const ResolveConflictOperations = ({
return (
<>
<MobileMenuItem
prefixIcon={<CalendarXmarkIcon />}
onClick={() => {
handleRemoveJournalMark(docRecord.id);
}}
data-testid="journal-conflict-remove-mark"
>
{t['com.affine.page-properties.property.journal-remove']()}
</MobileMenuItem>
<MoveToTrash onSelect={() => handleOpenTrashModal(docRecord)} />
<DocPermissionGuard docId={docRecord.id} permission="Doc_Update">
{canEdit => (
<MobileMenuItem
prefixIcon={<CalendarXmarkIcon />}
onClick={() => {
handleRemoveJournalMark(docRecord.id);
}}
data-testid="journal-conflict-remove-mark"
disabled={!canEdit}
>
{t['com.affine.page-properties.property.journal-remove']()}
</MobileMenuItem>
)}
</DocPermissionGuard>
<DocPermissionGuard docId={docRecord.id} permission="Doc_Trash">
{canTrash => (
<MoveToTrash
onSelect={() => handleOpenTrashModal(docRecord)}
disabled={!canTrash}
/>
)}
</DocPermissionGuard>
</>
);
};
@@ -2,7 +2,6 @@ import { useThemeColorV2 } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor';
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
@@ -17,6 +16,7 @@ import { EditorService } from '@affine/core/modules/editor';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { JournalService } from '@affine/core/modules/journal';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { WorkspaceService } from '@affine/core/modules/workspace';
@@ -56,6 +56,7 @@ const DetailPageImpl = () => {
globalContextService,
featureFlagService,
aIButtonService,
guardService,
} = useServices({
WorkbenchService,
ViewService,
@@ -65,6 +66,7 @@ const DetailPageImpl = () => {
GlobalContextService,
FeatureFlagService,
AIButtonService,
GuardService,
});
const editor = editorService.editor;
const workspace = workspaceService.workspace;
@@ -84,7 +86,6 @@ const DetailPageImpl = () => {
featureFlagService.flags.enable_mobile_keyboard_toolbar.value;
const enableEdgelessEditing =
featureFlagService.flags.enable_mobile_edgeless_editing.value;
const { setDocReadonly } = useDocMetaHelper();
// TODO(@eyhn): remove jotai here
const [_, setActiveBlockSuiteEditor] = useActiveBlocksuiteEditor();
@@ -111,19 +112,6 @@ const DetailPageImpl = () => {
};
}, [doc, globalContext, mode]);
useEffect(() => {
setDocReadonly(
doc.id,
!enableKeyboardToolbar || (mode === 'edgeless' && !enableEdgelessEditing)
);
}, [
enableKeyboardToolbar,
doc.id,
setDocReadonly,
mode,
enableEdgelessEditing,
]);
useEffect(() => {
aIButtonService.presentAIButton(true);
@@ -202,6 +190,14 @@ const DetailPageImpl = () => {
[docCollection.id, editor, jumpToPageBlock, openPage, server]
);
const canEdit = useLiveData(guardService.can$('Doc_Update', doc.id));
const readonly =
!canEdit ||
isInTrash ||
!enableKeyboardToolbar ||
(mode === 'edgeless' && !enableEdgelessEditing);
return (
<FrameworkScope scope={editor.scope}>
<div className={styles.mainContainer}>
@@ -216,7 +212,7 @@ const DetailPageImpl = () => {
>
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
<AffineErrorBoundary key={doc.id} className={styles.errorBoundary}>
<PageDetailEditor onLoad={onLoad} />
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
</AffineErrorBoundary>
</div>
</div>
@@ -11,6 +11,7 @@ import { DocInfoSheet } from '@affine/core/mobile/components';
import { MobileTocMenu } from '@affine/core/mobile/components/toc-menu';
import { DocService } from '@affine/core/modules/doc';
import { EditorService } from '@affine/core/modules/editor';
import { GuardService } from '@affine/core/modules/permissions';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { preventDefault } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
@@ -34,6 +35,8 @@ export const PageHeaderMenuButton = () => {
const t = useI18n();
const docId = useService(DocService).doc.id;
const guardService = useService(GuardService);
const canEdit = useLiveData(guardService.can$('Doc_Update', docId));
const editorService = useService(EditorService);
const editorContainer = useLiveData(editorService.editor.editorContainer$);
@@ -94,6 +97,7 @@ export const PageHeaderMenuButton = () => {
prefixIcon={primaryMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
data-testid="editor-option-menu-mode-switch"
onSelect={handleSwitchMode}
disabled={!canEdit}
>
{primaryMode === 'page'
? t['com.affine.editorDefaultMode.edgeless']()
@@ -12,6 +12,7 @@ import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { GuardService } from '@affine/core/modules/permissions';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
@@ -50,12 +51,14 @@ export const ExplorerDocNode = ({
globalContextService,
docDisplayMetaService,
featureFlagService,
guardService,
} = useServices({
DocsSearchService,
DocsService,
GlobalContextService,
DocDisplayMetaService,
FeatureFlagService,
GuardService,
});
const active =
@@ -134,6 +137,11 @@ export const ExplorerDocNode = ({
async (data: DropTargetDropEvent<AffineDNDData>) => {
if (data.treeInstruction?.type === 'make-child') {
if (data.source.data.entity?.type === 'doc') {
const canEdit = await guardService.can('Doc_Update', docId);
if (!canEdit) {
toast(t['com.affine.no-permission']());
return;
}
await docsService.addLinkedDoc(docId, data.source.data.entity.id);
track.$.navigationPanel.docs.linkDoc({
control: 'drag',
@@ -148,7 +156,7 @@ export const ExplorerDocNode = ({
onDrop?.(data);
}
},
[docId, docsService, onDrop, t]
[docId, docsService, guardService, onDrop, t]
);
const handleDropEffectOnDoc = useCallback<ExplorerTreeNodeDropEffect>(
@@ -168,6 +176,11 @@ export const ExplorerDocNode = ({
const handleDropOnPlaceholder = useAsyncCallback(
async (data: DropTargetDropEvent<AffineDNDData>) => {
if (data.source.data.entity?.type === 'doc') {
const canEdit = await guardService.can('Doc_Update', docId);
if (!canEdit) {
toast(t['com.affine.no-permission']());
return;
}
// TODO(eyhn): timeout&error handling
await docsService.addLinkedDoc(docId, data.source.data.entity.id);
track.$.navigationPanel.docs.linkDoc({
@@ -180,7 +193,7 @@ export const ExplorerDocNode = ({
toast(t['com.affine.rootAppSidebar.doc.link-doc-only']());
}
},
[docId, docsService, t]
[docId, docsService, guardService, t]
);
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
@@ -242,6 +255,10 @@ export const ExplorerDocNode = ({
)
}
reorderable={reorderable}
renameableGuard={{
docId,
action: 'Doc_Update',
}}
onRename={handleRename}
childrenPlaceholder={
searching ? null : <Empty onDrop={handleDropOnPlaceholder} />
@@ -1,16 +1,17 @@
import {
IconButton,
MenuItem,
MenuSeparator,
toast,
useConfirmModal,
} from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { DocsService } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
@@ -21,7 +22,6 @@ import {
InformationIcon,
LinkedPageIcon,
OpenInNewIcon,
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
@@ -42,11 +42,13 @@ export const useExplorerDocNodeOperations = (
workspaceService,
docsService,
compatibleFavoriteItemsAdapter,
guardService,
} = useServices({
DocsService,
WorkbenchService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
GuardService,
});
const { openConfirmModal } = useConfirmModal();
@@ -115,13 +117,18 @@ export const useExplorerDocNodeOperations = (
}, [docId, workbenchService.workbench]);
const handleAddLinkedPage = useAsyncCallback(async () => {
const canEdit = await guardService.can('Doc_Update', docId);
if (!canEdit) {
toast(t['com.affine.no-permission']());
return;
}
const newDoc = createPage();
// TODO: handle timeout & error
await docsService.addLinkedDoc(docId, newDoc.id);
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
options.openNodeCollapsed();
}, [createPage, docsService, docId, options]);
}, [createPage, guardService, docId, docsService, options, t]);
const handleToggleFavoriteDoc = useCallback(() => {
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
@@ -132,18 +139,6 @@ export const useExplorerDocNodeOperations = (
return useMemo(
() => [
{
index: 0,
inline: true,
view: (
<IconButton
size="16"
icon={<PlusIcon />}
tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleAddLinkedPage}
/>
),
},
{
index: 50,
view: (
@@ -158,12 +153,17 @@ export const useExplorerDocNodeOperations = (
{
index: 99,
view: (
<MenuItem
prefixIcon={<LinkedPageIcon />}
onClick={handleAddLinkedPage}
>
{t['com.affine.page-operation.add-linked-page']()}
</MenuItem>
<DocPermissionGuard docId={docId} permission="Doc_Update">
{canEdit => (
<MenuItem
prefixIcon={<LinkedPageIcon />}
onClick={handleAddLinkedPage}
disabled={!canEdit}
>
{t['com.affine.page-operation.add-linked-page']()}
</MenuItem>
)}
</DocPermissionGuard>
),
},
{
@@ -217,17 +217,23 @@ export const useExplorerDocNodeOperations = (
{
index: 10000,
view: (
<MenuItem
type={'danger'}
prefixIcon={<DeleteIcon />}
onClick={handleMoveToTrash}
>
{t['com.affine.moveToTrash.title']()}
</MenuItem>
<DocPermissionGuard docId={docId} permission="Doc_Trash">
{canMoveToTrash => (
<MenuItem
type={'danger'}
prefixIcon={<DeleteIcon />}
onClick={handleMoveToTrash}
disabled={!canMoveToTrash}
>
{t['com.affine.moveToTrash.title']()}
</MenuItem>
)}
</DocPermissionGuard>
),
},
],
[
docId,
favorite,
handleAddLinkedPage,
handleDuplicate,
@@ -10,7 +10,9 @@ import {
useDropTarget,
} from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import type { DocPermissionActions } from '@affine/core/modules/permissions';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { extractEmojiIcon } from '@affine/core/utils';
@@ -81,6 +83,7 @@ export interface BaseExplorerTreeNodeProps {
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
renameable?: boolean;
onRename?: (newName: string) => void;
renameableGuard?: { docId: string; action: DocPermissionActions };
defaultRenaming?: boolean;
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
@@ -127,6 +130,7 @@ export const ExplorerTreeNode = ({
active,
defaultRenaming,
renameable,
renameableGuard,
onRename,
disabled,
collapsed,
@@ -279,7 +283,24 @@ export const ExplorerTreeNode = ({
renameable
? {
index: 0,
view: (
view: renameableGuard ? (
<DocPermissionGuard
permission={renameableGuard.action}
docId={renameableGuard.docId}
>
{can => (
<MenuItem
key={'explorer-tree-rename'}
type={'default'}
prefixIcon={<EditIcon />}
onClick={() => setRenaming(true)}
disabled={!can}
>
{t['com.affine.menu.rename']()}
</MenuItem>
)}
</DocPermissionGuard>
) : (
<MenuItem
key={'explorer-tree-rename'}
type={'default'}
@@ -293,7 +314,7 @@ export const ExplorerTreeNode = ({
: null,
] as (NodeOperation | null)[]
).filter((t): t is NodeOperation => t !== null),
[renameable, t]
[renameable, renameableGuard, t]
);
const { menuOperations, inlineOperations } = useMemo(() => {
@@ -1,7 +1,5 @@
import { DebugLogger } from '@affine/debug';
import {
backoffRetry,
catchErrorInto,
effect,
Entity,
exhaustMapWithTrailing,
@@ -12,18 +10,18 @@ import {
} from '@toeverything/infra';
import { EMPTY, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace';
import type { WorkspacePermissionStore } from '../stores/permission';
const logger = new DebugLogger('affine:workspace-permission');
export class WorkspacePermission extends Entity {
isOwner$ = new LiveData<boolean | null>(null);
isAdmin$ = new LiveData<boolean | null>(null);
isTeam$ = new LiveData<boolean | null>(null);
isLoading$ = new LiveData(false);
error$ = new LiveData<any>(null);
private readonly cache$ = LiveData.from(
this.store.watchWorkspacePermissionCache(),
undefined
);
isOwner$ = this.cache$.map(cache => cache?.isOwner ?? null);
isAdmin$ = this.cache$.map(cache => cache?.isAdmin ?? null);
isTeam$ = this.cache$.map(cache => cache?.isTeam ?? null);
isRevalidating$ = new LiveData(false);
constructor(
private readonly workspaceService: WorkspaceService,
@@ -51,27 +49,30 @@ export class WorkspacePermission extends Entity {
}
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(({ isOwner, isAdmin, isTeam }) => {
this.isAdmin$.next(isAdmin);
this.isOwner$.next(isOwner);
this.isTeam$.next(isTeam);
this.store.setWorkspacePermissionCache({
isOwner,
isAdmin,
isTeam,
});
return EMPTY;
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch isOwner', error);
}),
onStart(() => this.isLoading$.setValue(true)),
onComplete(() => this.isLoading$.setValue(false))
onStart(() => this.isRevalidating$.setValue(true)),
onComplete(() => this.isRevalidating$.setValue(false))
);
})
);
async waitForRevalidation(signal?: AbortSignal) {
this.revalidate();
await this.isRevalidating$.waitFor(
isRevalidating => !isRevalidating,
signal
);
}
override dispose(): void {
this.revalidate.unsubscribe();
}
@@ -3,15 +3,21 @@ export {
DocGrantedUsersService,
type GrantedUser,
} from './services/doc-granted-users';
export { GuardService } from './services/guard';
export { MemberSearchService } from './services/member-search';
export { WorkspaceMembersService } from './services/members';
export { WorkspacePermissionService } from './services/permission';
export {
type DocPermissionActions,
type WorkspacePermissionActions,
} from './stores/guard';
import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { DocScope, DocService } from '../doc';
import {
WorkspaceLocalState,
WorkspaceScope,
WorkspaceService,
WorkspacesService,
@@ -19,10 +25,12 @@ import {
import { WorkspaceMembers } from './entities/members';
import { WorkspacePermission } from './entities/permission';
import { DocGrantedUsersService } from './services/doc-granted-users';
import { GuardService } from './services/guard';
import { MemberSearchService } from './services/member-search';
import { WorkspaceMembersService } from './services/members';
import { WorkspacePermissionService } from './services/permission';
import { DocGrantedUsersStore } from './stores/doc-granted-users';
import { GuardStore } from './stores/guard';
import { MemberSearchStore } from './stores/member-search';
import { WorkspaceMembersStore } from './stores/members';
import { WorkspacePermissionStore } from './stores/permission';
@@ -35,13 +43,22 @@ export function configurePermissionsModule(framework: Framework) {
WorkspacesService,
WorkspacePermissionStore,
])
.store(WorkspacePermissionStore, [WorkspaceServerService])
.store(WorkspacePermissionStore, [
WorkspaceServerService,
WorkspaceLocalState,
])
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
.service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService])
.store(WorkspaceMembersStore, [WorkspaceServerService])
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService])
.service(MemberSearchService, [MemberSearchStore, WorkspaceService])
.store(MemberSearchStore, [WorkspaceServerService]);
.store(MemberSearchStore, [WorkspaceServerService])
.service(GuardService, [
GuardStore,
WorkspaceService,
WorkspacePermissionService,
])
.store(GuardStore, [WorkspaceService, WorkspaceServerService]);
framework
.scope(WorkspaceScope)
@@ -0,0 +1,181 @@
import {
backoffRetry,
effect,
exhaustMapWithTrailing,
fromPromise,
LiveData,
Service,
} from '@toeverything/infra';
import {
combineLatest,
EMPTY,
exhaustMap,
groupBy,
map,
mergeMap,
Observable,
} from 'rxjs';
import type { WorkspaceService } from '../../workspace';
import type {
DocPermissionActions,
GuardStore,
WorkspacePermissionActions,
} from '../stores/guard';
import type { WorkspacePermissionService } from './permission';
export class GuardService extends Service {
constructor(
private readonly guardStore: GuardStore,
private readonly workspaceService: WorkspaceService,
private readonly workspacePermissionService: WorkspacePermissionService
) {
super();
}
private readonly workspacePermissions$ = new LiveData<
Partial<Record<WorkspacePermissionActions, boolean>>
>({});
private readonly docPermissions$ = new LiveData<
Record<string, Partial<Record<DocPermissionActions, boolean>>>
>({});
private readonly isAdmin$ = LiveData.computed(get => {
const isOwner = get(this.workspacePermissionService.permission.isOwner$);
const isAdmin = get(this.workspacePermissionService.permission.isAdmin$);
if (isOwner === null && isAdmin === null) {
return null;
}
return isOwner || isAdmin;
});
/**
* @example
* ```ts
* guardService.can$('Workspace_Properties_Update');
* guardService.can$('Doc_Update', docId);
* ```
*/
can$<T extends WorkspacePermissionActions | DocPermissionActions>(
action: T,
...args: T extends DocPermissionActions ? [string] : []
): LiveData<boolean> {
const docId = args[0];
return LiveData.from(
new Observable(subscriber => {
// revalidate permission
if (docId) {
this.revalidateDocPermission(docId);
} else {
this.revalidateWorkspacePermission();
}
// revalidate workspace permission if it's not initialized
if (this.isAdmin$.value === null) {
this.workspacePermissionService.permission.revalidate();
}
let prev = false;
const subscription = combineLatest([
(docId
? this.docPermissions$.pipe(
map(permissions => permissions[docId] ?? false)
)
: this.workspacePermissions$) as Observable<
Record<string, boolean>
>,
this.isAdmin$,
]).subscribe(([permissions, isAdmin]) => {
if (isAdmin) {
return subscriber.next(true);
}
const current = permissions[action] ?? false;
if (current !== prev) {
prev = current;
subscriber.next(current);
}
});
return () => {
subscription.unsubscribe();
};
}),
false
);
}
async can<T extends WorkspacePermissionActions | DocPermissionActions>(
action: T,
...args: T extends DocPermissionActions ? [string] : []
): Promise<boolean> {
const docId = args[0];
if (this.isAdmin$.value === null) {
await this.workspacePermissionService.permission.waitForRevalidation();
}
if (this.isAdmin$.value === true) {
return true;
}
const permissions = await (docId
? this.loadDocPermission(docId)
: this.loadWorkspacePermission());
return permissions[action as keyof typeof permissions] ?? false;
}
private readonly revalidateWorkspacePermission = effect(
exhaustMapWithTrailing(() =>
fromPromise(() => this.guardStore.getWorkspacePermissions()).pipe(
backoffRetry({
count: Infinity,
}),
mergeMap(() => EMPTY)
)
)
);
private readonly revalidateDocPermission = effect(
groupBy((docId: string) => docId),
mergeMap(doc$ =>
doc$.pipe(
exhaustMap((docId: string) =>
fromPromise(() => this.loadDocPermission(docId)).pipe(
backoffRetry({
count: Infinity,
}),
mergeMap(() => EMPTY)
)
)
)
)
);
private readonly loadWorkspacePermission = async () => {
if (this.workspaceService.workspace.flavour === 'local') {
return {} as Record<WorkspacePermissionActions, boolean>;
}
const permissions = await this.guardStore.getWorkspacePermissions();
this.workspacePermissions$.next(permissions);
return permissions;
};
private readonly loadDocPermission = async (docId: string) => {
if (this.workspaceService.workspace.flavour === 'local') {
return {} as Record<DocPermissionActions, boolean>;
}
const permissions = await this.guardStore.getDocPermissions(docId);
this.docPermissions$.next({
...this.docPermissions$.value,
[docId]: permissions,
});
return permissions;
};
override dispose() {
this.revalidateWorkspacePermission.unsubscribe();
this.revalidateDocPermission.unsubscribe();
}
}
@@ -0,0 +1,60 @@
import {
type GetDocRolePermissionsQuery,
getDocRolePermissionsQuery,
getWorkspaceRolePermissionsQuery,
type WorkspacePermissions,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { WorkspaceServerService } from '../../cloud';
import type { WorkspaceService } from '../../workspace';
export type WorkspacePermissionActions = keyof Omit<
WorkspacePermissions,
'__typename'
>;
export type DocPermissionActions = keyof Omit<
GetDocRolePermissionsQuery['workspace']['doc']['permissions'],
'__typename'
>;
export class GuardStore extends Store {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly workspaceServerService: WorkspaceServerService
) {
super();
}
async getWorkspacePermissions(): Promise<
Record<WorkspacePermissionActions, boolean>
> {
if (!this.workspaceServerService.server) {
throw new Error('No server');
}
const data = await this.workspaceServerService.server.gql({
query: getWorkspaceRolePermissionsQuery,
variables: {
id: this.workspaceService.workspace.id,
},
});
return data.workspaceRolePermissions.permissions;
}
async getDocPermissions(
docId: string
): Promise<Record<DocPermissionActions, boolean>> {
if (!this.workspaceServerService.server) {
throw new Error('No server');
}
const data = await this.workspaceServerService.server.gql({
query: getDocRolePermissionsQuery,
variables: {
workspaceId: this.workspaceService.workspace.id,
docId,
},
});
return data.workspace.doc.permissions;
}
}
@@ -2,8 +2,13 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import { getWorkspaceInfoQuery, leaveWorkspaceMutation } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { WorkspaceLocalState } from '../../workspace';
export class WorkspacePermissionStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
constructor(
private readonly workspaceServerService: WorkspaceServerService,
private readonly workspaceLocalState: WorkspaceLocalState
) {
super();
}
@@ -36,4 +41,20 @@ export class WorkspacePermissionStore extends Store {
},
});
}
watchWorkspacePermissionCache() {
return this.workspaceLocalState.watch<{
isOwner: boolean;
isAdmin: boolean;
isTeam: boolean;
}>('permission');
}
setWorkspacePermissionCache(permission: {
isOwner: boolean;
isAdmin: boolean;
isTeam: boolean;
}) {
this.workspaceLocalState.set('permission', permission);
}
}
@@ -22,9 +22,11 @@ const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
export const MembersPermission = ({
openPaywallModal,
hittingPaywall,
disabled,
}: {
hittingPaywall: boolean;
openPaywallModal?: () => void;
disabled?: boolean;
}) => {
const t = useI18n();
const [docRole, setDocRole] = useState<DocRole>(DocRole.Manager);
@@ -63,38 +65,40 @@ export const MembersPermission = ({
align: 'end',
}}
items={
<>
<MenuItem
onSelect={selectManage}
selected={docRole === DocRole.Manager}
>
<div className={styles.publicItemRowStyle}>
{t['com.affine.share-menu.option.permission.can-manage']()}
</div>
</MenuItem>
<MenuItem
onSelect={selectEdit}
selected={docRole === DocRole.Editor}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.can-edit']()}
<PlanTag />
disabled ? null : (
<>
<MenuItem
onSelect={selectManage}
selected={docRole === DocRole.Manager}
>
<div className={styles.publicItemRowStyle}>
{t['com.affine.share-menu.option.permission.can-manage']()}
</div>
</div>
</MenuItem>
<MenuItem
onSelect={selectRead}
selected={docRole === DocRole.Reader}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.can-read']()}
<PlanTag />
</MenuItem>
<MenuItem
onSelect={selectEdit}
selected={docRole === DocRole.Editor}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.can-edit']()}
<PlanTag />
</div>
</div>
</div>
</MenuItem>
</>
</MenuItem>
<MenuItem
onSelect={selectRead}
selected={docRole === DocRole.Reader}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.can-read']()}
<PlanTag />
</div>
</div>
</MenuItem>
</>
)
}
>
<MenuTrigger
@@ -103,6 +107,7 @@ export const MembersPermission = ({
contentStyle={{
width: '100%',
}}
disabled={disabled}
>
{currentRoleName}
</MenuTrigger>
@@ -15,7 +15,7 @@ import { useEffect } from 'react';
import * as styles from './styles.css';
export const PublicDoc = () => {
export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
const t = useI18n();
const shareInfoService = useService(ShareInfoService);
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
@@ -98,27 +98,31 @@ export const PublicDoc = () => {
align: 'end',
}}
items={
<>
<MenuItem
prefixIcon={<LockIcon />}
onSelect={onDisablePublic}
selected={!isSharedPage}
>
<div className={styles.publicItemRowStyle}>
<div>{t['com.affine.share-menu.option.link.no-access']()}</div>
</div>
</MenuItem>
<MenuItem
prefixIcon={<ViewIcon />}
onSelect={onClickAnyoneReadOnlyShare}
data-testid="share-link-menu-enable-share"
selected={!!isSharedPage}
>
<div className={styles.publicItemRowStyle}>
<div>{t['com.affine.share-menu.option.link.readonly']()}</div>
</div>
</MenuItem>
</>
disabled ? null : (
<>
<MenuItem
prefixIcon={<LockIcon />}
onSelect={onDisablePublic}
selected={!isSharedPage}
>
<div className={styles.publicItemRowStyle}>
<div>
{t['com.affine.share-menu.option.link.no-access']()}
</div>
</div>
</MenuItem>
<MenuItem
prefixIcon={<ViewIcon />}
onSelect={onClickAnyoneReadOnlyShare}
data-testid="share-link-menu-enable-share"
selected={!!isSharedPage}
>
<div className={styles.publicItemRowStyle}>
<div>{t['com.affine.share-menu.option.link.readonly']()}</div>
</div>
</MenuItem>
</>
)
}
>
<MenuTrigger
@@ -128,6 +132,7 @@ export const PublicDoc = () => {
contentStyle={{
width: '100%',
}}
disabled={disabled}
>
{isSharedPage
? t['com.affine.share-menu.option.link.readonly']()
@@ -8,13 +8,15 @@ import {
Tooltip,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocService } from '@affine/core/modules/doc';
import {
DocGrantedUsersService,
type GrantedUser,
GuardService,
} from '@affine/core/modules/permissions';
import { DocRole, UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { PlanTag } from '../plan-tag';
@@ -77,7 +79,6 @@ export const MemberItem = ({
</div>
</div>
{/* TODO(@eyhn): add guard here */}
<Menu
items={
<Options
@@ -118,6 +119,15 @@ const Options = ({
}) => {
const t = useI18n();
const docGrantedUsersService = useService(DocGrantedUsersService);
const docService = useService(DocService);
const guardService = useService(GuardService);
const canTransferOwner = useLiveData(
guardService.can$('Doc_TransferOwner', docService.doc.id)
);
const canManageUsers = useLiveData(
guardService.can$('Doc_Users_Manage', docService.doc.id)
);
const changeToManager = useAsyncCallback(async () => {
try {
@@ -188,20 +198,17 @@ const Options = ({
label: t['com.affine.share-menu.option.permission.can-manage'](),
onClick: changeToManager,
role: DocRole.Manager,
show: true, // TODO(@eyhn): add guard here
},
{
label: t['com.affine.share-menu.option.permission.can-edit'](),
onClick: changeToEditor,
role: DocRole.Editor,
show: true, // TODO(@eyhn): add guard here
showPlanTag: true,
},
{
label: t['com.affine.share-menu.option.permission.can-read'](),
onClick: changeToReader,
role: DocRole.Reader,
show: true, // TODO(@eyhn): add guard here
showPlanTag: true,
},
];
@@ -209,26 +216,28 @@ const Options = ({
return (
<>
{operationButtonInfo.map(item =>
item.show ? (
<MenuItem
key={item.label}
onSelect={item.onClick}
selected={memberRole === item.role}
>
<div className={styles.planTagContainer}>
{item.label} {item.showPlanTag ? <PlanTag /> : null}
</div>
</MenuItem>
) : null
)}
{/* TODO(@eyhn): add guard here */}
<MenuItem onSelect={changeToOwner}>
{operationButtonInfo.map(item => (
<MenuItem
key={item.label}
onSelect={item.onClick}
selected={memberRole === item.role}
disabled={!canManageUsers}
>
<div className={styles.planTagContainer}>
{item.label} {item.showPlanTag ? <PlanTag /> : null}
</div>
</MenuItem>
))}
<MenuItem onSelect={changeToOwner} disabled={!canTransferOwner}>
{t['com.affine.share-menu.member-management.set-as-owner']()}
</MenuItem>
{/* TODO(@eyhn): add guard here */}
<MenuSeparator />
<MenuItem onSelect={removeMember} type="danger" className={styles.remove}>
<MenuItem
onSelect={removeMember}
type="danger"
className={styles.remove}
disabled={!canManageUsers}
>
{t['com.affine.share-menu.member-management.remove']()}
</MenuItem>
</>
@@ -1,7 +1,9 @@
import { Skeleton } from '@affine/component';
import { DocService } from '@affine/core/modules/doc';
import {
DocGrantedUsersService,
type GrantedUser,
GuardService,
} from '@affine/core/modules/permissions';
import { useI18n } from '@affine/i18n';
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
@@ -30,6 +32,12 @@ export const MemberManagement = ({
const grantedUserCount = useLiveData(
docGrantedUsersService.grantedUserCount$
);
const docService = useService(DocService);
const guardService = useService(GuardService);
const canManageUsers = useLiveData(
guardService.can$('Doc_Users_Manage', docService.doc.id)
);
const t = useI18n();
@@ -62,11 +70,15 @@ export const MemberManagement = ({
) : (
<Skeleton className={styles.scrollableRootStyle} />
)}
{/* TODO(@eyhn): add guard here */}
<div className={styles.footerStyle}>
<span className={styles.addCollaboratorsStyle} onClick={onClickInvite}>
{t['com.affine.share-menu.member-management.add-collaborators']()}
</span>
{canManageUsers ? (
<span
className={styles.addCollaboratorsStyle}
onClick={onClickInvite}
>
{t['com.affine.share-menu.member-management.add-collaborators']()}
</span>
) : null}
</div>
</div>
);
@@ -1,6 +1,8 @@
import { Skeleton } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { ServerService } from '@affine/core/modules/cloud';
import { DocService } from '@affine/core/modules/doc';
import { GuardService } from '@affine/core/modules/permissions';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
@@ -61,6 +63,16 @@ export const AFFiNESharePage = (
} = props;
const shareInfoService = useService(ShareInfoService);
const serverService = useService(ServerService);
const docService = useService(DocService);
const guardService = useService(GuardService);
const canManageUsers = useLiveData(
guardService.can$('Doc_Users_Manage', docService.doc.id)
);
const canPublish = useLiveData(
guardService.can$('Doc_Publish', docService.doc.id)
);
useEffect(() => {
shareInfoService.shareInfo.revalidate();
@@ -85,7 +97,7 @@ export const AFFiNESharePage = (
return (
<div className={styles.content}>
<div className={styles.columnContainerStyle}>
<InviteInput onFocus={props.onClickInvite} />
{canManageUsers && <InviteInput onFocus={props.onClickInvite} />}
<MembersRow onClick={props.onClickMembers} />
<div className={styles.generalAccessStyle}>
{t['com.affine.share-menu.generalAccess']()}
@@ -93,8 +105,9 @@ export const AFFiNESharePage = (
<MembersPermission
openPaywallModal={props.openPaywallModal}
hittingPaywall={!!props.hittingPaywall}
disabled={!canManageUsers}
/>
<PublicDoc />
<PublicDoc disabled={!canPublish} />
</div>
<CopyLinkButton workspaceId={workspaceId} />
</div>
+8 -1
View File
@@ -19,11 +19,18 @@ export class UserFriendlyError
readonly status = this.response.status;
readonly code = this.response.code;
readonly type = this.response.type;
override readonly name = this.response.name;
readonly rawName = this.response.name;
override readonly message = this.response.message;
readonly data = this.response.data;
readonly stacktrace = this.response.stacktrace;
override get name() {
if (this.rawName in ErrorNames) {
return this.rawName;
}
return ErrorNames.INTERNAL_SERVER_ERROR;
}
static fromAnyError(response: any) {
if (response instanceof GraphQLError) {
return new UserFriendlyError(response.extensions);
@@ -0,0 +1,21 @@
query getDocRolePermissions($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
permissions {
Doc_Copy
Doc_Delete
Doc_Duplicate
Doc_Properties_Read
Doc_Properties_Update
Doc_Publish
Doc_Read
Doc_Restore
Doc_TransferOwner
Doc_Trash
Doc_Update
Doc_Users_Manage
Doc_Users_Read
}
}
}
}
@@ -318,6 +318,35 @@ mutation deleteWorkspace($id: String!) {
}`,
};
export const getDocRolePermissionsQuery = {
id: 'getDocRolePermissionsQuery' as const,
operationName: 'getDocRolePermissions',
definitionName: 'workspace',
containsFile: false,
query: `
query getDocRolePermissions($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
permissions {
Doc_Copy
Doc_Delete
Doc_Duplicate
Doc_Properties_Read
Doc_Properties_Update
Doc_Publish
Doc_Read
Doc_Restore
Doc_TransferOwner
Doc_Trash
Doc_Update
Doc_Users_Manage
Doc_Users_Read
}
}
}
}`,
};
export const forkCopilotSessionMutation = {
id: 'forkCopilotSessionMutation' as const,
operationName: 'forkCopilotSession',
@@ -1551,6 +1580,33 @@ query workspaceQuota($id: String!) {
}`,
};
export const getWorkspaceRolePermissionsQuery = {
id: 'getWorkspaceRolePermissionsQuery' as const,
operationName: 'getWorkspaceRolePermissions',
definitionName: 'workspaceRolePermissions',
containsFile: false,
query: `
query getWorkspaceRolePermissions($id: String!) {
workspaceRolePermissions(id: $id) {
permissions {
Workspace_CreateDoc
Workspace_Delete
Workspace_Organize_Read
Workspace_Properties_Create
Workspace_Properties_Delete
Workspace_Properties_Read
Workspace_Properties_Update
Workspace_Settings_Read
Workspace_Settings_Update
Workspace_Sync
Workspace_TransferOwner
Workspace_Users_Manage
Workspace_Users_Read
}
}
}`,
};
export const approveWorkspaceTeamMemberMutation = {
id: 'approveWorkspaceTeamMemberMutation' as const,
operationName: 'approveWorkspaceTeamMember',
@@ -0,0 +1,19 @@
query getWorkspaceRolePermissions($id: String!) {
workspaceRolePermissions(id: $id) {
permissions {
Workspace_CreateDoc
Workspace_Delete
Workspace_Organize_Read
Workspace_Properties_Create
Workspace_Properties_Delete
Workspace_Properties_Read
Workspace_Properties_Update
Workspace_Settings_Read
Workspace_Settings_Update
Workspace_Sync
Workspace_TransferOwner
Workspace_Users_Manage
Workspace_Users_Read
}
}
}
+68
View File
@@ -1958,6 +1958,37 @@ export type DeleteWorkspaceMutation = {
deleteWorkspace: boolean;
};
export type GetDocRolePermissionsQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
docId: Scalars['String']['input'];
}>;
export type GetDocRolePermissionsQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
doc: {
__typename?: 'DocType';
permissions: {
__typename?: 'DocPermissions';
Doc_Copy: boolean;
Doc_Delete: boolean;
Doc_Duplicate: boolean;
Doc_Properties_Read: boolean;
Doc_Properties_Update: boolean;
Doc_Publish: boolean;
Doc_Read: boolean;
Doc_Restore: boolean;
Doc_TransferOwner: boolean;
Doc_Trash: boolean;
Doc_Update: boolean;
Doc_Users_Manage: boolean;
Doc_Users_Read: boolean;
};
};
};
};
export type ForkCopilotSessionMutationVariables = Exact<{
options: ForkChatSessionInput;
}>;
@@ -3100,6 +3131,33 @@ export type WorkspaceQuotaQuery = {
};
};
export type GetWorkspaceRolePermissionsQueryVariables = Exact<{
id: Scalars['String']['input'];
}>;
export type GetWorkspaceRolePermissionsQuery = {
__typename?: 'Query';
workspaceRolePermissions: {
__typename?: 'WorkspaceRolePermissions';
permissions: {
__typename?: 'WorkspacePermissions';
Workspace_CreateDoc: boolean;
Workspace_Delete: boolean;
Workspace_Organize_Read: boolean;
Workspace_Properties_Create: boolean;
Workspace_Properties_Delete: boolean;
Workspace_Properties_Read: boolean;
Workspace_Properties_Update: boolean;
Workspace_Settings_Read: boolean;
Workspace_Settings_Update: boolean;
Workspace_Sync: boolean;
Workspace_TransferOwner: boolean;
Workspace_Users_Manage: boolean;
Workspace_Users_Read: boolean;
};
};
};
export type ApproveWorkspaceTeamMemberMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
userId: Scalars['String']['input'];
@@ -3137,6 +3195,11 @@ export type Queries =
variables: CopilotQuotaQueryVariables;
response: CopilotQuotaQuery;
}
| {
name: 'getDocRolePermissionsQuery';
variables: GetDocRolePermissionsQueryVariables;
response: GetDocRolePermissionsQuery;
}
| {
name: 'getCopilotHistoriesQuery';
variables: GetCopilotHistoriesQueryVariables;
@@ -3331,6 +3394,11 @@ export type Queries =
name: 'workspaceQuotaQuery';
variables: WorkspaceQuotaQueryVariables;
response: WorkspaceQuotaQuery;
}
| {
name: 'getWorkspaceRolePermissionsQuery';
variables: GetWorkspaceRolePermissionsQueryVariables;
response: GetWorkspaceRolePermissionsQuery;
};
export type Mutations =
@@ -1730,6 +1730,7 @@
"com.affine.settings.workspace.template.page-desc": "New docs will use the specified template, ignoring default settings.",
"com.affine.settings.workspace.template.page-select": "Template for new doc",
"com.affine.settings.workspace.template.remove": "Remove template",
"com.affine.no-permission": "You don't have permission to do this",
"com.affine.settings.workspace.storage.unused-blobs": "Unused blobs",
"com.affine.settings.workspace.storage.unused-blobs.empty": "No unused blobs",
"com.affine.settings.workspace.storage.unused-blobs.selected": "Selected",
@@ -1,12 +1,14 @@
import { openHomePage } from '@affine-test/kit/utils/load-page';
import {
clickNewPageButton,
waitForEditorLoad,
waitForEmptyEditor,
} from '@affine-test/kit/utils/page-logic';
import test, { expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await openHomePage(page);
await waitForEditorLoad(page);
await clickNewPageButton(page);
await waitForEmptyEditor(page);
});