feat(core): add no access to share menu (#10927)

This commit is contained in:
JimmFly
2025-03-20 07:31:11 +00:00
parent c16ae2d5b4
commit d47bb64597
13 changed files with 209 additions and 91 deletions

View File

@@ -110,6 +110,13 @@ globalStyle(`${linkPreview} *`, {
cursor: 'default', cursor: 'default',
}); });
export const notFound = style({
color: cssVarV2('text/secondary'),
fontSize: '12px',
lineHeight: '16px',
textAlign: 'center',
});
export const linkPreviewRenderer = style({ export const linkPreviewRenderer = style({
cursor: 'pointer', cursor: 'pointer',
}); });

View File

@@ -7,6 +7,7 @@ import {
type Link, type Link,
} from '@affine/core/modules/doc-link'; } from '@affine/core/modules/doc-link';
import { toURLSearchParams } from '@affine/core/modules/navigation'; import { toURLSearchParams } from '@affine/core/modules/navigation';
import { GuardService } from '@affine/core/modules/permissions';
import { GlobalSessionStateService } from '@affine/core/modules/storage'; import { GlobalSessionStateService } from '@affine/core/modules/storage';
import { WorkbenchLink } from '@affine/core/modules/workbench'; import { WorkbenchLink } from '@affine/core/modules/workbench';
import { import {
@@ -15,13 +16,17 @@ import {
} from '@affine/core/modules/workspace'; } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; 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 { ToggleDownIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible'; import * as Collapsible from '@radix-ui/react-collapsible';
import { import {
LiveData, LiveData,
useFramework, useFramework,
useLiveData, useLiveData,
useService,
useServices, useServices,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { import {
@@ -46,6 +51,18 @@ import * as styles from './bi-directional-link-panel.css';
const PREFIX = 'bi-directional-link-panel-collapse:'; 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 = ( const useBiDirectionalLinkPanelCollapseState = (
docId: string, docId: string,
linkDocId?: string linkDocId?: string
@@ -155,7 +172,7 @@ const usePreviewExtensions = () => {
return [extensions, portals] as const; return [extensions, portals] as const;
}; };
const useBacklinkGroups = () => { const useBacklinkGroups: () => BacklinkGroups[] = () => {
const { docLinksService } = useServices({ const { docLinksService } = useServices({
DocLinksService, DocLinksService,
}); });
@@ -226,72 +243,10 @@ export const BacklinkGroups = () => {
docId={docService.doc.id} docId={docService.doc.id}
linkDocId={linkGroup.docId} linkDocId={linkGroup.docId}
> >
<div className={styles.linkPreviewContainer}> <LinkPreview
{linkGroup.links.map(link => { textRendererOptions={textRendererOptions}
if (!link.markdownPreview) { linkGroup={linkGroup}
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 (
<WorkbenchLink
to={to}
key={link.blockId}
className={styles.linkPreview}
onClick={() => {
track.doc.biDirectionalLinksPanel.backlinkPreview.navigate();
}}
>
{edgelessLink ? (
<>
[Edgeless]
<AffinePageReference
key={link.blockId}
pageId={linkGroup.docId}
params={searchParams}
/>
</>
) : (
<LitTextRenderer
className={styles.linkPreviewRenderer}
answer={link.markdownPreview}
schema={getAFFiNEWorkspaceSchema()}
options={textRendererOptions}
/>
)}
</WorkbenchLink>
);
})}
</div>
</CollapsibleSection> </CollapsibleSection>
))} ))}
<> <>
@@ -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 (
<span className={styles.notFound}>
{t['com.affine.share-menu.option.permission.no-access']()}
</span>
);
}
return (
<div className={styles.linkPreviewContainer}>
{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 (
<WorkbenchLink
to={to}
key={link.blockId}
className={styles.linkPreview}
onClick={() => {
track.doc.biDirectionalLinksPanel.backlinkPreview.navigate();
}}
>
{edgelessLink ? (
<>
[Edgeless]
<AffinePageReference
key={link.blockId}
pageId={linkGroup.docId}
params={searchParams}
/>
</>
) : (
<LitTextRenderer
className={styles.linkPreviewRenderer}
answer={link.markdownPreview}
schema={getAFFiNEWorkspaceSchema()}
options={textRendererOptions}
/>
)}
</WorkbenchLink>
);
})}
</div>
);
};
export const BiDirectionalLinkPanel = () => { export const BiDirectionalLinkPanel = () => {
const { docLinksService, docService } = useServices({ const { docLinksService, docService } = useServices({
DocLinksService, DocLinksService,

View File

@@ -68,10 +68,12 @@ export const DetailPageWrapper = ({
children, children,
skeleton, skeleton,
notFound, notFound,
canAccess,
}: PropsWithChildren<{ }: PropsWithChildren<{
pageId: string; pageId: string;
skeleton: ReactNode; skeleton: ReactNode;
notFound: ReactNode; notFound: ReactNode;
canAccess?: boolean;
}>) => { }>) => {
const { doc, editor, docListReady } = useLoadDoc(pageId); const { doc, editor, docListReady } = useLoadDoc(pageId);
// if sync engine has been synced and the page is null, show 404 page. // if sync engine has been synced and the page is null, show 404 page.
@@ -79,8 +81,10 @@ export const DetailPageWrapper = ({
return notFound; return notFound;
} }
if (!doc || !editor) { if (canAccess === undefined || !doc || !editor) {
return skeleton; return skeleton;
} else if (!canAccess) {
return notFound;
} }
return ( return (

View File

@@ -368,6 +368,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
export const Component = () => { export const Component = () => {
const params = useParams(); const params = useParams();
const recentPages = useService(RecentDocsService); const recentPages = useService(RecentDocsService);
const guardService = useService(GuardService);
useEffect(() => { useEffect(() => {
if (params.pageId) { if (params.pageId) {
@@ -379,10 +380,14 @@ export const Component = () => {
}, [params, recentPages]); }, [params, recentPages]);
const pageId = params.pageId; const pageId = params.pageId;
const canAccess = useLiveData(
pageId ? guardService.can$('Doc_Read', pageId) : undefined
);
return pageId ? ( return pageId ? (
<DetailPageWrapper <DetailPageWrapper
pageId={pageId} pageId={pageId}
canAccess={canAccess}
skeleton={<PageDetailSkeleton />} skeleton={<PageDetailSkeleton />}
notFound={<PageNotFound noPermission />} notFound={<PageNotFound noPermission />}
> >

View File

@@ -57,6 +57,7 @@ export const ExplorerDocNode = ({
reference: isLinked, reference: isLinked,
}) })
); );
const docTitle = useLiveData(docDisplayMetaService.title$(docId)); const docTitle = useLiveData(docDisplayMetaService.title$(docId));
const isInTrash = useLiveData(docRecord?.trash$); const isInTrash = useLiveData(docRecord?.trash$);
const enableEmojiIcon = useLiveData( const enableEmojiIcon = useLiveData(
@@ -133,10 +134,19 @@ export const ExplorerDocNode = ({
operations={finalOperations} operations={finalOperations}
data-testid={`explorer-doc-${docId}`} data-testid={`explorer-doc-${docId}`}
> >
{children?.map(child => ( <DocPermissionGuard docId={docId} permission="Doc_Read">
<ExplorerDocNode key={child.docId} docId={child.docId} isLinked /> {canRead =>
))} canRead
? children?.map((child, index) => (
<ExplorerDocNode
key={`${child.docId}-${index}`}
docId={child.docId}
isLinked
/>
))
: null
}
</DocPermissionGuard>
<DocPermissionGuard docId={docId} permission="Doc_Update"> <DocPermissionGuard docId={docId} permission="Doc_Update">
{canEdit => {canEdit =>
canEdit ? ( canEdit ? (

View File

@@ -254,6 +254,9 @@ const MobileDetailPage = ({
const [showTitle, setShowTitle] = useState(checkShowTitle); const [showTitle, setShowTitle] = useState(checkShowTitle);
const title = useLiveData(docDisplayMetaService.title$(pageId)); const title = useLiveData(docDisplayMetaService.title$(pageId));
const guardService = useService(GuardService);
const canAccess = useLiveData(guardService.can$('Doc_Read', pageId));
const allJournalDates = useLiveData(journalService.allJournalDates$); const allJournalDates = useLiveData(journalService.allJournalDates$);
const location = useLiveData(workbench.location$); const location = useLiveData(workbench.location$);
@@ -281,6 +284,7 @@ const MobileDetailPage = ({
skeleton={date ? skeleton : skeletonWithBack} skeleton={date ? skeleton : skeletonWithBack}
notFound={date ? notFound : notFoundWithBack} notFound={date ? notFound : notFoundWithBack}
pageId={pageId} pageId={pageId}
canAccess={canAccess}
> >
<PageHeader <PageHeader
back={!fromTab} back={!fromTab}

View File

@@ -6,8 +6,10 @@ import { EmptyNodeChildren } from '../../layouts/empty-node-children';
export const Empty = ({ export const Empty = ({
onDrop, onDrop,
noAccessible = false,
}: { }: {
onDrop: (data: DropTargetDropEvent<AffineDNDData>) => void; onDrop: (data: DropTargetDropEvent<AffineDNDData>) => void;
noAccessible?: boolean;
}) => { }) => {
const { dropTargetRef } = useDropTarget<AffineDNDData>( const { dropTargetRef } = useDropTarget<AffineDNDData>(
() => ({ () => ({
@@ -19,7 +21,9 @@ export const Empty = ({
return ( return (
<EmptyNodeChildren ref={dropTargetRef}> <EmptyNodeChildren ref={dropTargetRef}>
{t['com.affine.rootAppSidebar.docs.no-subdoc']()} {noAccessible
? t['com.affine.share-menu.option.permission.no-access']()
: t['com.affine.rootAppSidebar.docs.no-subdoc']()}
</EmptyNodeChildren> </EmptyNodeChildren>
); );
}; };

View File

@@ -5,6 +5,7 @@ import {
toast, toast,
Tooltip, Tooltip,
} from '@affine/component'; } from '@affine/component';
import { DocPermissionGuard } from '@affine/core/components/guard/doc-guard';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc'; import { DocsService } from '@affine/core/modules/doc';
@@ -43,6 +44,7 @@ export const ExplorerDocNode = ({
}: { }: {
docId: string; docId: string;
isLinked?: boolean; isLinked?: boolean;
forwardKey?: string;
} & GenericExplorerNode) => { } & GenericExplorerNode) => {
const t = useI18n(); const t = useI18n();
const { const {
@@ -261,24 +263,35 @@ export const ExplorerDocNode = ({
}} }}
onRename={handleRename} onRename={handleRename}
childrenPlaceholder={ childrenPlaceholder={
searching ? null : <Empty onDrop={handleDropOnPlaceholder} /> searching ? null : (
<Empty
onDrop={handleDropOnPlaceholder}
noAccessible={!!children && children.length > 0}
/>
)
} }
operations={finalOperations} operations={finalOperations}
dropEffect={handleDropEffectOnDoc} dropEffect={handleDropEffectOnDoc}
data-testid={`explorer-doc-${docId}`} data-testid={`explorer-doc-${docId}`}
> >
{children?.map(child => ( <DocPermissionGuard docId={docId} permission="Doc_Read">
<ExplorerDocNode {canRead =>
key={child.docId} canRead
docId={child.docId} ? children?.map((child, index) => (
reorderable={false} <ExplorerDocNode
location={{ key={`${child.docId}-${index}`}
at: 'explorer:doc:linked-docs', docId={child.docId}
docId, reorderable={false}
}} location={{
isLinked at: 'explorer:doc:linked-docs',
/> docId,
))} }}
isLinked
/>
))
: null
}
</DocPermissionGuard>
</ExplorerTreeNode> </ExplorerTreeNode>
); );
}; };

View File

@@ -73,6 +73,7 @@ export interface BaseExplorerTreeNodeProps {
operations?: NodeOperation[]; operations?: NodeOperation[];
childrenOperations?: NodeOperation[]; childrenOperations?: NodeOperation[];
childrenPlaceholder?: React.ReactNode; childrenPlaceholder?: React.ReactNode;
linkComponent?: React.ComponentType< linkComponent?: React.ComponentType<
React.PropsWithChildren<{ to: To; className?: string }> & React.PropsWithChildren<{ to: To; className?: string }> &
RefAttributes<any> & { draggable?: boolean } RefAttributes<any> & { draggable?: boolean }
@@ -492,7 +493,7 @@ export const ExplorerTreeNode = ({
<Collapsible.Content style={{ display: dragging ? 'none' : undefined }}> <Collapsible.Content style={{ display: dragging ? 'none' : undefined }}>
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */} {/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
<div className={styles.collapseContentPlaceholder}> <div className={styles.collapseContentPlaceholder}>
{childCount === 0 && !collapsed && childrenPlaceholder} {childCount === 0 && !collapsed ? childrenPlaceholder : null}
</div> </div>
<ExplorerTreeContext.Provider value={contextValue}> <ExplorerTreeContext.Provider value={contextValue}>
{collapsed ? null : children} {collapsed ? null : children}

View File

@@ -220,9 +220,12 @@ export function DocPeekPreview({
!animating !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 sync engine has been synced and the page is null, show 404 page.
if (!doc || !editor) { if (!doc || !editor || !canAccess) {
return loading ? ( return loading || canAccess === undefined ? (
<PageDetailSkeleton key="current-page-is-null" /> <PageDetailSkeleton key="current-page-is-null" />
) : ( ) : (
<PageNotFound noPermission /> <PageNotFound noPermission />

View File

@@ -28,6 +28,8 @@ const getRoleName = (t: ReturnType<typeof useI18n>, role?: DocRole) => {
return t['com.affine.share-menu.option.permission.can-edit'](); return t['com.affine.share-menu.option.permission.can-edit']();
case DocRole.Reader: case DocRole.Reader:
return t['com.affine.share-menu.option.permission.can-read'](); return t['com.affine.share-menu.option.permission.can-read']();
case DocRole.None:
return t['com.affine.share-menu.option.permission.no-access']();
default: default:
return ''; return '';
} }
@@ -53,7 +55,9 @@ export const MembersPermission = ({
[docDefaultRole, t] [docDefaultRole, t]
); );
const showTips = const showTips =
docDefaultRole === DocRole.Reader || docDefaultRole === DocRole.Editor; docDefaultRole === DocRole.Reader ||
docDefaultRole === DocRole.Editor ||
docDefaultRole === DocRole.None;
const changePermission = useAsyncCallback( const changePermission = useAsyncCallback(
async (docRole: DocRole) => { async (docRole: DocRole) => {
try { try {
@@ -93,6 +97,14 @@ export const MembersPermission = ({
changePermission(DocRole.Reader); changePermission(DocRole.Reader);
}, [changePermission, hittingPaywall, openPaywallModal]); }, [changePermission, hittingPaywall, openPaywallModal]);
const selectNone = useCallback(() => {
if (hittingPaywall) {
openPaywallModal?.();
return;
}
changePermission(DocRole.None);
}, [changePermission, hittingPaywall, openPaywallModal]);
return ( return (
<div className={styles.rowContainerStyle}> <div className={styles.rowContainerStyle}>
<div className={styles.labelStyle}> <div className={styles.labelStyle}>
@@ -141,6 +153,17 @@ export const MembersPermission = ({
</div> </div>
</div> </div>
</MenuItem> </MenuItem>
<MenuItem
onSelect={selectNone}
selected={docDefaultRole === DocRole.None}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.no-access']()}
{hittingPaywall ? <PlanTag /> : null}
</div>
</div>
</MenuItem>
</> </>
} }
> >

View File

@@ -6004,6 +6004,10 @@ export function useAFFiNEI18N(): {
* `Can read` * `Can read`
*/ */
["com.affine.share-menu.option.permission.can-read"](): string; ["com.affine.share-menu.option.permission.can-read"](): string;
/**
* `No access`
*/
["com.affine.share-menu.option.permission.no-access"](): string;
/** /**
* `Members in workspace` * `Members in workspace`
*/ */

View File

@@ -1498,6 +1498,7 @@
"com.affine.share-menu.option.permission.can-manage": "Can manage", "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-edit": "Can edit",
"com.affine.share-menu.option.permission.can-read": "Can read", "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.label": "Members in workspace",
"com.affine.share-menu.option.permission.tips": "Workspace admins and owner automatically have Can manage permissions.", "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", "com.affine.share-menu.publish-to-web": "Publish to web",