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:
JimmFly
2024-12-10 06:31:35 +00:00
parent 5d25580eff
commit 612310bc26
77 changed files with 3788 additions and 1044 deletions
@@ -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() {