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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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']()

View File

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

View File

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

View File

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