mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(core): guard service (#9816)
This commit is contained in:
@@ -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)
|
||||
|
||||
181
packages/frontend/core/src/modules/permissions/services/guard.ts
Normal file
181
packages/frontend/core/src/modules/permissions/services/guard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user