feat(core): add doc grant feature to share menu (#9672)

This commit is contained in:
JimmFly
2025-02-07 13:05:58 +00:00
parent 459972fe6c
commit 5ae5fd88f1
57 changed files with 3188 additions and 697 deletions

View File

@@ -1,10 +1,16 @@
export type { Member } from './entities/members';
export {
DocGrantedUsersService,
type GrantedUser,
} from './services/doc-granted-users';
export { MemberSearchService } from './services/member-search';
export { WorkspaceMembersService } from './services/members';
export { WorkspacePermissionService } from './services/permission';
import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { DocScope, DocService } from '../doc';
import {
WorkspaceScope,
WorkspaceService,
@@ -12,8 +18,12 @@ import {
} from '../workspace';
import { WorkspaceMembers } from './entities/members';
import { WorkspacePermission } from './entities/permission';
import { DocGrantedUsersService } from './services/doc-granted-users';
import { MemberSearchService } from './services/member-search';
import { WorkspaceMembersService } from './services/members';
import { WorkspacePermissionService } from './services/permission';
import { DocGrantedUsersStore } from './stores/doc-granted-users';
import { MemberSearchStore } from './stores/member-search';
import { WorkspaceMembersStore } from './stores/members';
import { WorkspacePermissionStore } from './stores/permission';
@@ -29,5 +39,17 @@ export function configurePermissionsModule(framework: Framework) {
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
.service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService])
.store(WorkspaceMembersStore, [WorkspaceServerService])
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService]);
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService])
.service(MemberSearchService, [MemberSearchStore, WorkspaceService])
.store(MemberSearchStore, [WorkspaceServerService]);
framework
.scope(WorkspaceScope)
.scope(DocScope)
.service(DocGrantedUsersService, [
DocGrantedUsersStore,
WorkspaceService,
DocService,
])
.store(DocGrantedUsersStore, [WorkspaceServerService]);
}

View File

@@ -0,0 +1,142 @@
import type { DocRole, GetPageGrantedUsersListQuery } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
effect,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
} from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { DocService } from '../../doc';
import type { WorkspaceService } from '../../workspace';
import type { DocGrantedUsersStore } from '../stores/doc-granted-users';
export type GrantedUser =
GetPageGrantedUsersListQuery['workspace']['doc']['grantedUsersList']['edges'][number]['node'];
export class DocGrantedUsersService extends Service {
constructor(
private readonly store: DocGrantedUsersStore,
private readonly workspaceService: WorkspaceService,
private readonly docService: DocService
) {
super();
}
readonly PAGE_SIZE = 8;
nextCursor$ = new LiveData<string | undefined>(undefined);
hasMore$ = new LiveData(true);
grantedUserCount$ = new LiveData(0);
grantedUsers$ = new LiveData<GrantedUser[]>([]);
isLoading$ = new LiveData(false);
error$ = new LiveData<any>(null);
readonly loadMore = effect(
exhaustMap(() => {
if (!this.hasMore$.value) {
return EMPTY;
}
return fromPromise(async signal => {
return await this.store.fetchDocGrantedUsersList(
this.workspaceService.workspace.id,
this.docService.doc.id,
{
first: this.PAGE_SIZE,
after: this.nextCursor$.value,
},
signal
);
}).pipe(
mergeMap(({ edges, pageInfo, totalCount }) => {
this.grantedUsers$.next([
...this.grantedUsers$.value,
...edges.map(edge => edge.node),
]);
this.grantedUserCount$.next(totalCount);
this.hasMore$.next(pageInfo.hasNextPage);
this.nextCursor$.next(pageInfo.endCursor ?? undefined);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$),
onStart(() => {
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
reset() {
this.grantedUsers$.setValue([]);
this.grantedUserCount$.setValue(0);
this.hasMore$.setValue(true);
this.nextCursor$.setValue(undefined);
this.isLoading$.setValue(false);
this.error$.setValue(null);
this.loadMore.reset();
}
async grantUsersRole(userIds: string[], role: DocRole) {
await this.store.grantDocUserRoles({
docId: this.docService.doc.id,
workspaceId: this.workspaceService.workspace.id,
userIds,
role,
});
this.grantedUsers$.next(
this.grantedUsers$.value.map(user => {
if (userIds.includes(user.user.id)) {
return { ...user, role };
}
return user;
})
);
}
async revokeUsersRole(userId: string) {
await this.store.revokeDocUserRoles(
this.workspaceService.workspace.id,
this.docService.doc.id,
userId
);
this.grantedUsers$.next(
this.grantedUsers$.value.filter(user => user.user.id !== userId)
);
}
async updateUserRole(userId: string, role: DocRole) {
await this.store.updateDocUserRole(
this.workspaceService.workspace.id,
this.docService.doc.id,
userId,
role
);
this.grantedUsers$.next(
this.grantedUsers$.value.map(user => {
if (user.user.id === userId) {
return { ...user, role };
}
return user;
})
);
}
override dispose(): void {
this.loadMore.unsubscribe();
}
}

View File

@@ -0,0 +1,85 @@
import {
backoffRetry,
catchErrorInto,
effect,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
} from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace';
import type { Member } from '../entities/members';
import type { MemberSearchStore } from '../stores/member-search';
export class MemberSearchService extends Service {
constructor(
private readonly store: MemberSearchStore,
private readonly workspaceService: WorkspaceService
) {
super();
}
readonly PAGE_SIZE = 8;
readonly searchText$ = new LiveData<string>('');
readonly isLoading$ = new LiveData(false);
readonly error$ = new LiveData<any>(null);
readonly result$ = new LiveData<Member[]>([]);
readonly hasMore$ = new LiveData(true);
readonly loadMore = effect(
exhaustMap(() => {
if (!this.hasMore$.value) {
return EMPTY;
}
return fromPromise(async signal => {
return this.store.getMembersByEmailOrName(
this.workspaceService.workspace.id,
this.searchText$.value || undefined,
this.result$.value.length,
this.PAGE_SIZE,
signal
);
}).pipe(
mergeMap(data => {
this.result$.setValue([...this.result$.value, ...data.members]);
this.hasMore$.setValue(data.members.length === this.PAGE_SIZE);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$),
onStart(() => {
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
reset() {
this.result$.setValue([]);
this.hasMore$.setValue(true);
this.searchText$.setValue('');
this.error$.setValue(null);
this.loadMore.reset();
}
search(searchText?: string) {
if (this.searchText$.value === searchText) {
return;
}
this.reset();
this.searchText$.setValue(searchText ?? '');
this.loadMore();
}
}

View File

@@ -0,0 +1,95 @@
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import {
type DocRole,
getPageGrantedUsersListQuery,
type GrantDocUserRolesInput,
grantDocUserRolesMutation,
type PaginationInput,
revokeDocUserRolesMutation,
updateDocUserRoleMutation,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
export class DocGrantedUsersStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
super();
}
async fetchDocGrantedUsersList(
workspaceId: string,
docId: string,
pagination: PaginationInput,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: getPageGrantedUsersListQuery,
variables: {
workspaceId,
docId,
pagination,
},
context: { signal },
});
return res.workspace.doc.grantedUsersList;
}
async grantDocUserRoles(input: GrantDocUserRolesInput) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: grantDocUserRolesMutation,
variables: {
input,
},
});
return res.grantDocUserRoles;
}
async revokeDocUserRoles(workspaceId: string, docId: string, userId: string) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: revokeDocUserRolesMutation,
variables: {
input: {
workspaceId,
docId,
userId,
},
},
});
return res.revokeDocUserRoles;
}
async updateDocUserRole(
workspaceId: string,
docId: string,
userId: string,
role: DocRole
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: updateDocUserRoleMutation,
variables: {
input: {
workspaceId,
docId,
userId,
role,
},
},
});
return res.updateDocUserRole;
}
}

View File

@@ -0,0 +1,36 @@
import { getMembersByWorkspaceIdQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { WorkspaceServerService } from '../../cloud';
export class MemberSearchStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
super();
}
async getMembersByEmailOrName(
workspaceId: string,
query?: string,
skip?: number,
take?: number,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
skip,
take,
query,
},
context: {
signal,
},
});
return data.workspace;
}
}