diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.css.ts b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.css.ts
index d0e11c55d..40dae9f1a 100644
--- a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.css.ts
+++ b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.css.ts
@@ -110,6 +110,13 @@ globalStyle(`${linkPreview} *`, {
cursor: 'default',
});
+export const notFound = style({
+ color: cssVarV2('text/secondary'),
+ fontSize: '12px',
+ lineHeight: '16px',
+ textAlign: 'center',
+});
+
export const linkPreviewRenderer = style({
cursor: 'pointer',
});
diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx
index a56eab5d1..177c8911c 100644
--- a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx
+++ b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx
@@ -7,6 +7,7 @@ import {
type Link,
} from '@affine/core/modules/doc-link';
import { toURLSearchParams } from '@affine/core/modules/navigation';
+import { GuardService } from '@affine/core/modules/permissions';
import { GlobalSessionStateService } from '@affine/core/modules/storage';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import {
@@ -15,13 +16,17 @@ import {
} from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
-import type { TransformerMiddleware } from '@blocksuite/affine/store';
+import type {
+ ExtensionType,
+ TransformerMiddleware,
+} from '@blocksuite/affine/store';
import { ToggleDownIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import {
LiveData,
useFramework,
useLiveData,
+ useService,
useServices,
} from '@toeverything/infra';
import {
@@ -46,6 +51,18 @@ import * as styles from './bi-directional-link-panel.css';
const PREFIX = 'bi-directional-link-panel-collapse:';
+type BacklinkGroups = {
+ docId: string;
+ title: string;
+ links: Backlink[];
+};
+
+type TextRendererOptions = {
+ customHeading: boolean;
+ extensions: ExtensionType[];
+ additionalMiddlewares: TransformerMiddleware[];
+};
+
const useBiDirectionalLinkPanelCollapseState = (
docId: string,
linkDocId?: string
@@ -155,7 +172,7 @@ const usePreviewExtensions = () => {
return [extensions, portals] as const;
};
-const useBacklinkGroups = () => {
+const useBacklinkGroups: () => BacklinkGroups[] = () => {
const { docLinksService } = useServices({
DocLinksService,
});
@@ -226,72 +243,10 @@ export const BacklinkGroups = () => {
docId={docService.doc.id}
linkDocId={linkGroup.docId}
>
-
- {linkGroup.links.map(link => {
- if (!link.markdownPreview) {
- return null;
- }
- const searchParams = new URLSearchParams();
- const displayMode = link.displayMode || 'page';
- searchParams.set('mode', displayMode);
-
- let blockId = link.blockId;
- if (
- link.parentFlavour === 'affine:database' &&
- link.parentBlockId
- ) {
- // if parentBlockFlavour is 'affine:database',
- // we will fallback to the database block instead
- blockId = link.parentBlockId;
- } else if (displayMode === 'edgeless' && link.noteBlockId) {
- // if note has displayMode === 'edgeless' && has noteBlockId,
- // set noteBlockId as blockId
- blockId = link.noteBlockId;
- }
-
- searchParams.set('blockIds', blockId);
-
- const to = {
- pathname: '/' + linkGroup.docId,
- search: '?' + searchParams.toString(),
- hash: '',
- };
-
- // if this backlink has no noteBlock && displayMode is edgeless, we will render
- // the link as a page link
- const edgelessLink =
- displayMode === 'edgeless' && !link.noteBlockId;
-
- return (
-
{
- track.doc.biDirectionalLinksPanel.backlinkPreview.navigate();
- }}
- >
- {edgelessLink ? (
- <>
- [Edgeless]
-
- >
- ) : (
-
- )}
-
- );
- })}
-
+
))}
<>
@@ -303,6 +258,90 @@ export const BacklinkGroups = () => {
);
};
+export const LinkPreview = ({
+ linkGroup,
+ textRendererOptions,
+}: {
+ linkGroup: BacklinkGroups;
+ textRendererOptions: TextRendererOptions;
+}) => {
+ const guardService = useService(GuardService);
+ const canAccess = useLiveData(guardService.can$('Doc_Read', linkGroup.docId));
+ const t = useI18n();
+
+ if (!canAccess) {
+ return (
+
+ {t['com.affine.share-menu.option.permission.no-access']()}
+
+ );
+ }
+ return (
+
+ {linkGroup.links.map(link => {
+ if (!link.markdownPreview) {
+ return null;
+ }
+ const searchParams = new URLSearchParams();
+ const displayMode = link.displayMode || 'page';
+ searchParams.set('mode', displayMode);
+
+ let blockId = link.blockId;
+ if (link.parentFlavour === 'affine:database' && link.parentBlockId) {
+ // if parentBlockFlavour is 'affine:database',
+ // we will fallback to the database block instead
+ blockId = link.parentBlockId;
+ } else if (displayMode === 'edgeless' && link.noteBlockId) {
+ // if note has displayMode === 'edgeless' && has noteBlockId,
+ // set noteBlockId as blockId
+ blockId = link.noteBlockId;
+ }
+
+ searchParams.set('blockIds', blockId);
+
+ const to = {
+ pathname: '/' + linkGroup.docId,
+ search: '?' + searchParams.toString(),
+ hash: '',
+ };
+
+ // if this backlink has no noteBlock && displayMode is edgeless, we will render
+ // the link as a page link
+ const edgelessLink = displayMode === 'edgeless' && !link.noteBlockId;
+
+ return (
+
{
+ track.doc.biDirectionalLinksPanel.backlinkPreview.navigate();
+ }}
+ >
+ {edgelessLink ? (
+ <>
+ [Edgeless]
+
+ >
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ );
+};
+
export const BiDirectionalLinkPanel = () => {
const { docLinksService, docService } = useServices({
DocLinksService,
diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-wrapper.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-wrapper.tsx
index 5b08c0c22..6956083ca 100644
--- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-wrapper.tsx
+++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-wrapper.tsx
@@ -68,10 +68,12 @@ export const DetailPageWrapper = ({
children,
skeleton,
notFound,
+ canAccess,
}: PropsWithChildren<{
pageId: string;
skeleton: ReactNode;
notFound: ReactNode;
+ canAccess?: boolean;
}>) => {
const { doc, editor, docListReady } = useLoadDoc(pageId);
// if sync engine has been synced and the page is null, show 404 page.
@@ -79,8 +81,10 @@ export const DetailPageWrapper = ({
return notFound;
}
- if (!doc || !editor) {
+ if (canAccess === undefined || !doc || !editor) {
return skeleton;
+ } else if (!canAccess) {
+ return notFound;
}
return (
diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx
index 568be593c..d90735cd4 100644
--- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx
+++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx
@@ -368,6 +368,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
export const Component = () => {
const params = useParams();
const recentPages = useService(RecentDocsService);
+ const guardService = useService(GuardService);
useEffect(() => {
if (params.pageId) {
@@ -379,10 +380,14 @@ export const Component = () => {
}, [params, recentPages]);
const pageId = params.pageId;
+ const canAccess = useLiveData(
+ pageId ? guardService.can$('Doc_Read', pageId) : undefined
+ );
return pageId ? (
}
notFound={}
>
diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx
index 1d572d4b0..1140962fe 100644
--- a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx
+++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx
@@ -57,6 +57,7 @@ export const ExplorerDocNode = ({
reference: isLinked,
})
);
+
const docTitle = useLiveData(docDisplayMetaService.title$(docId));
const isInTrash = useLiveData(docRecord?.trash$);
const enableEmojiIcon = useLiveData(
@@ -133,10 +134,19 @@ export const ExplorerDocNode = ({
operations={finalOperations}
data-testid={`explorer-doc-${docId}`}
>
- {children?.map(child => (
-
- ))}
-
+
+ {canRead =>
+ canRead
+ ? children?.map((child, index) => (
+
+ ))
+ : null
+ }
+
{canEdit =>
canEdit ? (
diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx
index ae0389910..31ac8f76d 100644
--- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx
+++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx
@@ -254,6 +254,9 @@ const MobileDetailPage = ({
const [showTitle, setShowTitle] = useState(checkShowTitle);
const title = useLiveData(docDisplayMetaService.title$(pageId));
+ const guardService = useService(GuardService);
+ const canAccess = useLiveData(guardService.can$('Doc_Read', pageId));
+
const allJournalDates = useLiveData(journalService.allJournalDates$);
const location = useLiveData(workbench.location$);
@@ -281,6 +284,7 @@ const MobileDetailPage = ({
skeleton={date ? skeleton : skeletonWithBack}
notFound={date ? notFound : notFoundWithBack}
pageId={pageId}
+ canAccess={canAccess}
>
) => void;
+ noAccessible?: boolean;
}) => {
const { dropTargetRef } = useDropTarget(
() => ({
@@ -19,7 +21,9 @@ export const Empty = ({
return (
- {t['com.affine.rootAppSidebar.docs.no-subdoc']()}
+ {noAccessible
+ ? t['com.affine.share-menu.option.permission.no-access']()
+ : t['com.affine.rootAppSidebar.docs.no-subdoc']()}
);
};
diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/doc/index.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/doc/index.tsx
index 9125dd803..1493403a7 100644
--- a/packages/frontend/core/src/modules/explorer/views/nodes/doc/index.tsx
+++ b/packages/frontend/core/src/modules/explorer/views/nodes/doc/index.tsx
@@ -5,6 +5,7 @@ import {
toast,
Tooltip,
} from '@affine/component';
+import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc';
@@ -43,6 +44,7 @@ export const ExplorerDocNode = ({
}: {
docId: string;
isLinked?: boolean;
+ forwardKey?: string;
} & GenericExplorerNode) => {
const t = useI18n();
const {
@@ -261,24 +263,35 @@ export const ExplorerDocNode = ({
}}
onRename={handleRename}
childrenPlaceholder={
- searching ? null :
+ searching ? null : (
+ 0}
+ />
+ )
}
operations={finalOperations}
dropEffect={handleDropEffectOnDoc}
data-testid={`explorer-doc-${docId}`}
>
- {children?.map(child => (
-
- ))}
+
+ {canRead =>
+ canRead
+ ? children?.map((child, index) => (
+
+ ))
+ : null
+ }
+
);
};
diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx
index 80a7b2797..e3ce2870b 100644
--- a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx
+++ b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx
@@ -73,6 +73,7 @@ export interface BaseExplorerTreeNodeProps {
operations?: NodeOperation[];
childrenOperations?: NodeOperation[];
childrenPlaceholder?: React.ReactNode;
+
linkComponent?: React.ComponentType<
React.PropsWithChildren<{ to: To; className?: string }> &
RefAttributes & { draggable?: boolean }
@@ -492,7 +493,7 @@ export const ExplorerTreeNode = ({
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
- {childCount === 0 && !collapsed && childrenPlaceholder}
+ {childCount === 0 && !collapsed ? childrenPlaceholder : null}
{collapsed ? null : children}
diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx
index 25a86422a..58f4b99fd 100644
--- a/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx
+++ b/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx
@@ -220,9 +220,12 @@ export function DocPeekPreview({
!animating
);
+ const guardService = useService(GuardService);
+ const canAccess = useLiveData(guardService.can$('Doc_Read', docId));
+
// if sync engine has been synced and the page is null, show 404 page.
- if (!doc || !editor) {
- return loading ? (
+ if (!doc || !editor || !canAccess) {
+ return loading || canAccess === undefined ? (
) : (
diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/members-permission.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/members-permission.tsx
index f2e3b2d1c..8fe34e642 100644
--- a/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/members-permission.tsx
+++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/members-permission.tsx
@@ -28,6 +28,8 @@ const getRoleName = (t: ReturnType, role?: DocRole) => {
return t['com.affine.share-menu.option.permission.can-edit']();
case DocRole.Reader:
return t['com.affine.share-menu.option.permission.can-read']();
+ case DocRole.None:
+ return t['com.affine.share-menu.option.permission.no-access']();
default:
return '';
}
@@ -53,7 +55,9 @@ export const MembersPermission = ({
[docDefaultRole, t]
);
const showTips =
- docDefaultRole === DocRole.Reader || docDefaultRole === DocRole.Editor;
+ docDefaultRole === DocRole.Reader ||
+ docDefaultRole === DocRole.Editor ||
+ docDefaultRole === DocRole.None;
const changePermission = useAsyncCallback(
async (docRole: DocRole) => {
try {
@@ -93,6 +97,14 @@ export const MembersPermission = ({
changePermission(DocRole.Reader);
}, [changePermission, hittingPaywall, openPaywallModal]);
+ const selectNone = useCallback(() => {
+ if (hittingPaywall) {
+ openPaywallModal?.();
+ return;
+ }
+ changePermission(DocRole.None);
+ }, [changePermission, hittingPaywall, openPaywallModal]);
+
return (
@@ -141,6 +153,17 @@ export const MembersPermission = ({
+
>
}
>
diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts
index 311c2678c..846f830aa 100644
--- a/packages/frontend/i18n/src/i18n.gen.ts
+++ b/packages/frontend/i18n/src/i18n.gen.ts
@@ -6004,6 +6004,10 @@ export function useAFFiNEI18N(): {
* `Can read`
*/
["com.affine.share-menu.option.permission.can-read"](): string;
+ /**
+ * `No access`
+ */
+ ["com.affine.share-menu.option.permission.no-access"](): string;
/**
* `Members in workspace`
*/
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index e54775d4c..f48785451 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -1498,6 +1498,7 @@
"com.affine.share-menu.option.permission.can-manage": "Can manage",
"com.affine.share-menu.option.permission.can-edit": "Can edit",
"com.affine.share-menu.option.permission.can-read": "Can read",
+ "com.affine.share-menu.option.permission.no-access": "No access",
"com.affine.share-menu.option.permission.label": "Members in workspace",
"com.affine.share-menu.option.permission.tips": "Workspace admins and owner automatically have Can manage permissions.",
"com.affine.share-menu.publish-to-web": "Publish to web",