mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-26 14:58:55 +08:00
feat(core): impl team workspace (#8920)
AF-1738 AF-1735 AF-1731 AF-1721 AF-1717 AF-1736 AF-1727 AF-1719 AF-1877 UI for team workspaces : - add upgrade to team & successful upgrade page ( `/upgrade-to-team` & `/upgrade-success/team`) - update team plans on pricing page ( settings —> pricing plans ) - update reaching the usage/member limit modal - update invite member modal - update member CRUD options
This commit is contained in:
@@ -27,6 +27,9 @@ export class SubscriptionPrices extends Entity {
|
||||
aiPrice$ = this.prices$.map(prices =>
|
||||
prices ? prices.find(price => price.plan === 'AI') : null
|
||||
);
|
||||
teamPrice$ = this.prices$.map(prices =>
|
||||
prices ? prices.find(price => price.plan === 'Team') : null
|
||||
);
|
||||
|
||||
readableLifetimePrice$ = this.proPrice$.map(price =>
|
||||
price?.lifetimeAmount
|
||||
|
||||
@@ -42,6 +42,11 @@ export class Subscription extends Entity {
|
||||
? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI)
|
||||
: null
|
||||
);
|
||||
team$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.find(sub => sub.plan === SubscriptionPlan.Team)
|
||||
: null
|
||||
);
|
||||
isBeliever$ = this.pro$.map(
|
||||
sub => sub?.recurring === SubscriptionRecurring.Lifetime
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export type SettingTab =
|
||||
| 'experimental-features'
|
||||
| 'editor'
|
||||
| 'account'
|
||||
| `workspace:${'preference' | 'properties'}`;
|
||||
| `workspace:${'preference' | 'properties' | 'billing' | 'license'}`;
|
||||
|
||||
export type GLOBAL_DIALOG_SCHEMA = {
|
||||
'create-workspace': (props: { serverId?: string; forcedCloud?: boolean }) => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type {
|
||||
Permission,
|
||||
WorkspaceInviteLinkExpireTime,
|
||||
} from '@affine/graphql';
|
||||
import type { WorkspaceService } from '@toeverything/infra';
|
||||
import {
|
||||
backoffRetry,
|
||||
@@ -7,11 +11,10 @@ import {
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { exhaustMap } from 'rxjs';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspacePermissionStore } from '../stores/permission';
|
||||
@@ -20,6 +23,8 @@ 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);
|
||||
|
||||
@@ -34,12 +39,18 @@ export class WorkspacePermission extends Entity {
|
||||
exhaustMap(() => {
|
||||
return fromPromise(async signal => {
|
||||
if (this.workspaceService.workspace.flavour !== 'local') {
|
||||
return await this.store.fetchIsOwner(
|
||||
const info = await this.store.fetchWorkspaceInfo(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
);
|
||||
|
||||
return {
|
||||
isOwner: info.isOwner,
|
||||
isAdmin: info.isAdmin,
|
||||
isTeam: info.workspace.team,
|
||||
};
|
||||
} else {
|
||||
return true;
|
||||
return { isOwner: true, isAdmin: false, isTeam: false };
|
||||
}
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
@@ -49,7 +60,12 @@ export class WorkspacePermission extends Entity {
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mapInto(this.isOwner$),
|
||||
mergeMap(({ isOwner, isAdmin, isTeam }) => {
|
||||
this.isAdmin$.next(isAdmin);
|
||||
this.isOwner$.next(isOwner);
|
||||
this.isTeam$.next(isTeam);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to fetch isOwner', error);
|
||||
}),
|
||||
@@ -58,4 +74,89 @@ export class WorkspacePermission extends Entity {
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
async inviteMember(
|
||||
email: string,
|
||||
permission: Permission,
|
||||
sendInviteMail?: boolean
|
||||
) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to invite members');
|
||||
}
|
||||
return await this.store.inviteMember(
|
||||
this.workspaceService.workspace.id,
|
||||
email,
|
||||
permission,
|
||||
sendInviteMail
|
||||
);
|
||||
}
|
||||
|
||||
async inviteMembers(emails: string[], sendInviteMail?: boolean) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to invite members');
|
||||
}
|
||||
return await this.store.inviteBatch(
|
||||
this.workspaceService.workspace.id,
|
||||
emails,
|
||||
sendInviteMail
|
||||
);
|
||||
}
|
||||
|
||||
async generateInviteLink(expireTime: WorkspaceInviteLinkExpireTime) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to generate invite link');
|
||||
}
|
||||
return await this.store.generateInviteLink(
|
||||
this.workspaceService.workspace.id,
|
||||
expireTime
|
||||
);
|
||||
}
|
||||
|
||||
async revokeInviteLink() {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to revoke invite link');
|
||||
}
|
||||
return await this.store.revokeInviteLink(
|
||||
this.workspaceService.workspace.id
|
||||
);
|
||||
}
|
||||
|
||||
async revokeMember(userId: string) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to revoke members');
|
||||
}
|
||||
return await this.store.revokeMemberPermission(
|
||||
this.workspaceService.workspace.id,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async acceptInvite(inviteId: string, sendAcceptMail?: boolean) {
|
||||
return await this.store.acceptInvite(
|
||||
this.workspaceService.workspace.id,
|
||||
inviteId,
|
||||
sendAcceptMail
|
||||
);
|
||||
}
|
||||
|
||||
async approveMember(userId: string) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to accept invite');
|
||||
}
|
||||
return await this.store.approveMember(
|
||||
this.workspaceService.workspace.id,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async adjustMemberPermission(userId: string, permission: Permission) {
|
||||
if (!this.isAdmin$.value) {
|
||||
throw new Error('User has no permission to adjust member permissions');
|
||||
}
|
||||
return await this.store.adjustMemberPermission(
|
||||
this.workspaceService.workspace.id,
|
||||
userId,
|
||||
permission
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import { getIsOwnerQuery, leaveWorkspaceMutation } from '@affine/graphql';
|
||||
import {
|
||||
acceptInviteByInviteIdMutation,
|
||||
approveWorkspaceTeamMemberMutation,
|
||||
getWorkspaceInfoQuery,
|
||||
grantWorkspaceTeamMemberMutation,
|
||||
inviteByEmailMutation,
|
||||
inviteByEmailsMutation,
|
||||
inviteLinkMutation,
|
||||
leaveWorkspaceMutation,
|
||||
type Permission,
|
||||
revokeInviteLinkMutation,
|
||||
revokeMemberPermissionMutation,
|
||||
type WorkspaceInviteLinkExpireTime,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class WorkspacePermissionStore extends Store {
|
||||
@@ -7,19 +20,161 @@ export class WorkspacePermissionStore extends Store {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchIsOwner(workspaceId: string, signal?: AbortSignal) {
|
||||
async fetchWorkspaceInfo(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const isOwner = await this.workspaceServerService.server.gql({
|
||||
query: getIsOwnerQuery,
|
||||
const info = await this.workspaceServerService.server.gql({
|
||||
query: getWorkspaceInfoQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return isOwner.isOwner;
|
||||
return info;
|
||||
}
|
||||
|
||||
async inviteMember(
|
||||
workspaceId: string,
|
||||
email: string,
|
||||
permission: Permission,
|
||||
sendInviteMail = false
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const invite = await this.workspaceServerService.server.gql({
|
||||
query: inviteByEmailMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
email,
|
||||
permission,
|
||||
sendInviteMail,
|
||||
},
|
||||
});
|
||||
return invite.invite;
|
||||
}
|
||||
|
||||
async inviteBatch(
|
||||
workspaceId: string,
|
||||
emails: string[],
|
||||
sendInviteMail = false
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const inviteBatch = await this.workspaceServerService.server.gql({
|
||||
query: inviteByEmailsMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
emails,
|
||||
sendInviteMail,
|
||||
},
|
||||
});
|
||||
return inviteBatch.inviteBatch;
|
||||
}
|
||||
|
||||
async generateInviteLink(
|
||||
workspaceId: string,
|
||||
expireTime: WorkspaceInviteLinkExpireTime
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const inviteLink = await this.workspaceServerService.server.gql({
|
||||
query: inviteLinkMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
expireTime,
|
||||
},
|
||||
});
|
||||
return inviteLink.inviteLink;
|
||||
}
|
||||
|
||||
async revokeInviteLink(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const revoke = await this.workspaceServerService.server.gql({
|
||||
query: revokeInviteLinkMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
return revoke.revokeInviteLink;
|
||||
}
|
||||
|
||||
async revokeMemberPermission(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const revoke = await this.workspaceServerService.server.gql({
|
||||
query: revokeMemberPermissionMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
return revoke.revoke;
|
||||
}
|
||||
|
||||
async acceptInvite(
|
||||
workspaceId: string,
|
||||
inviteId: string,
|
||||
sendAcceptMail = false
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const accept = await this.workspaceServerService.server.gql({
|
||||
query: acceptInviteByInviteIdMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
inviteId,
|
||||
sendAcceptMail,
|
||||
},
|
||||
});
|
||||
return accept.acceptInviteById;
|
||||
}
|
||||
|
||||
async approveMember(workspaceId: string, userId: string) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const member = await this.workspaceServerService.server.gql({
|
||||
query: approveWorkspaceTeamMemberMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return member.approveMember;
|
||||
}
|
||||
|
||||
async adjustMemberPermission(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
permission: Permission
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const member = await this.workspaceServerService.server.gql({
|
||||
query: grantWorkspaceTeamMemberMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
userId,
|
||||
permission,
|
||||
},
|
||||
});
|
||||
return member.grantMember;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,6 +81,14 @@ export class WorkspaceShareSetting extends Entity {
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async setEnableAi(enableAi: EnableAi) {
|
||||
await this.store.updateWorkspaceEnableAi(
|
||||
this.workspaceService.workspace.id,
|
||||
enableAi
|
||||
);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DebugLogger } from '@affine/debug';
|
||||
import {
|
||||
createWorkspaceMutation,
|
||||
deleteWorkspaceMutation,
|
||||
getIsOwnerQuery,
|
||||
getWorkspaceInfoQuery,
|
||||
getWorkspacesQuery,
|
||||
} from '@affine/graphql';
|
||||
import { DocCollection } from '@blocksuite/affine/store';
|
||||
@@ -212,9 +212,11 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
error$ = new LiveData<any>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
workspaces$ = new LiveData<WorkspaceMetadata[]>([]);
|
||||
|
||||
async getWorkspaceProfile(
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
@@ -228,11 +230,13 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
const localData = await docStorage.doc.get(id);
|
||||
const cloudData = await cloudStorage.pull(id);
|
||||
|
||||
const isOwner = await this.getIsOwner(id, signal);
|
||||
const info = await this.getWorkspaceInfo(id, signal);
|
||||
|
||||
if (!cloudData && !localData) {
|
||||
return {
|
||||
isOwner,
|
||||
isOwner: info.isOwner,
|
||||
isAdmin: info.isAdmin,
|
||||
isTeam: info.workspace.team,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,7 +251,9 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
isOwner,
|
||||
isOwner: info.isOwner,
|
||||
isAdmin: info.isAdmin,
|
||||
isTeam: info.workspace.team,
|
||||
};
|
||||
}
|
||||
async getWorkspaceBlob(id: string, blob: string): Promise<Blob | null> {
|
||||
@@ -300,16 +306,14 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
workspace.scope.get(WorkspaceServerService).bindServer(this.server);
|
||||
}
|
||||
|
||||
private async getIsOwner(workspaceId: string, signal?: AbortSignal) {
|
||||
return (
|
||||
await this.graphqlService.gql({
|
||||
query: getIsOwnerQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
})
|
||||
).isOwner;
|
||||
private async getWorkspaceInfo(workspaceId: string, signal?: AbortSignal) {
|
||||
return await this.graphqlService.gql({
|
||||
query: getWorkspaceInfoQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
}
|
||||
|
||||
private waitForLoaded() {
|
||||
|
||||
Reference in New Issue
Block a user