feat(core): optimize team workspace member management (#9737)

close AF-2106 AF-2077 AF-2089

feat(core): handle need more seat status

feat(core): prevent invite members when team plan is canceled
This commit is contained in:
JimmFly
2025-02-07 10:08:00 +00:00
parent 85d916f1eb
commit d5a626d9c3
18 changed files with 562 additions and 154 deletions

View File

@@ -1,7 +1,9 @@
import { notify } from '@affine/component';
import { useDowngradeNotify } from '@affine/core/components/affine/subscription-landing/notify';
import { getDowngradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
@@ -231,6 +233,7 @@ export const TeamResumeAction = ({
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setIsMutating] = useState(false);
const subscription = useService(WorkspaceSubscriptionService).subscription;
const t = useI18n();
const resume = useAsyncCallback(async () => {
try {
@@ -243,10 +246,14 @@ export const TeamResumeAction = ({
// refresh idempotency key
setIdempotencyKey(nanoid());
onOpenChange(false);
notify.success({
title: t['com.affine.payment.resume.success.title'](),
message: t['com.affine.payment.resume.success.team.message'](),
});
} finally {
setIsMutating(false);
}
}, [subscription, idempotencyKey, onOpenChange]);
}, [subscription, idempotencyKey, onOpenChange, t]);
return (
<>

View File

@@ -1,4 +1,4 @@
import { Button, Loading, notify } from '@affine/component';
import { Button, Loading, notify, useConfirmModal } from '@affine/component';
import {
InviteTeamMemberModal,
type InviteTeamMemberModalProps,
@@ -7,7 +7,11 @@ import {
import { SettingRow } from '@affine/component/setting-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { Upload } from '@affine/core/components/pure/file-upload';
import { ServerService, SubscriptionService } from '@affine/core/modules/cloud';
import {
ServerService,
SubscriptionService,
WorkspaceSubscriptionService,
} from '@affine/core/modules/cloud';
import {
WorkspaceMembersService,
WorkspacePermissionService,
@@ -17,11 +21,12 @@ import { WorkspaceShareSettingService } from '@affine/core/modules/share-setting
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
import { emailRegex } from '@affine/core/utils/email-regex';
import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
import { UserFriendlyError } from '@affine/graphql';
import { SubscriptionPlan, UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { ExportIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { SettingState } from '../../types';
@@ -51,6 +56,8 @@ export const CloudWorkspaceMembersPanel = ({
isTeam?: boolean;
}) => {
const workspaceShareSettingService = useService(WorkspaceShareSettingService);
const subscription = useService(WorkspaceSubscriptionService).subscription;
const workspaceSubscription = useLiveData(subscription.subscription$);
const inviteLink = useLiveData(
workspaceShareSettingService.sharePreview.inviteLink$
);
@@ -89,9 +96,77 @@ export const CloudWorkspaceMembersPanel = ({
const [openMemberLimit, setOpenMemberLimit] = useState(false);
const [isMutating, setIsMutating] = useState(false);
const { openConfirmModal, closeConfirmModal } = useConfirmModal();
const goToTeamBilling = useCallback(() => {
onChangeSettingState({
activeTab: 'workspace:billing',
});
}, [onChangeSettingState]);
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const resume = useAsyncCallback(async () => {
try {
setIsMutating(true);
await subscription.resumeSubscription(
idempotencyKey,
SubscriptionPlan.Team
);
await subscription.waitForRevalidation();
// refresh idempotency key
setIdempotencyKey(nanoid());
closeConfirmModal();
notify.success({
title: t['com.affine.payment.resume.success.title'](),
message: t['com.affine.payment.resume.success.team.message'](),
});
} catch (err) {
const error = UserFriendlyError.fromAnyError(err);
notify.error({
title: error.name,
message: error.message,
});
} finally {
setIsMutating(false);
}
}, [subscription, idempotencyKey, closeConfirmModal, t]);
const openInviteModal = useCallback(() => {
if (isTeam && workspaceSubscription?.canceledAt) {
openConfirmModal({
title: t['com.affine.payment.member.team.retry-payment.title'](),
description:
t[
`com.affine.payment.member.team.disabled-subscription.${isOwner ? 'owner' : 'admin'}.description`
](),
confirmText:
t[
isOwner
? 'com.affine.payment.member.team.disabled-subscription.resume-subscription'
: 'Got it'
](),
cancelText: t['Cancel'](),
cancelButtonOptions: {
style: {
visibility: isOwner ? 'visible' : 'hidden',
},
},
onConfirm: isOwner ? resume : undefined,
confirmButtonOptions: {
variant: 'primary',
loading: isMutating,
},
});
return;
}
setOpenInvite(true);
}, []);
}, [
isMutating,
isOwner,
isTeam,
openConfirmModal,
resume,
t,
workspaceSubscription?.canceledAt,
]);
const onGenerateInviteLink = useCallback(
async (expireTime: WorkspaceInviteLinkExpireTime) => {
@@ -253,7 +328,11 @@ export const CloudWorkspaceMembersPanel = ({
</SettingRow>
<div className={styles.membersPanel}>
<MemberList isOwner={!!isOwner} isAdmin={!!isAdmin} />
<MemberList
isOwner={!!isOwner}
isAdmin={!!isAdmin}
goToTeamBilling={goToTeamBilling}
/>
</div>
</>
);

View File

@@ -29,9 +29,11 @@ import * as styles from './styles.css';
export const MemberList = ({
isOwner,
isAdmin,
goToTeamBilling,
}: {
isOwner: boolean;
isAdmin: boolean;
goToTeamBilling: () => void;
}) => {
const membersService = useService(WorkspaceMembersService);
const memberCount = useLiveData(membersService.members.memberCount$);
@@ -85,6 +87,7 @@ export const MemberList = ({
member={member}
isOwner={isOwner}
isAdmin={isAdmin}
goToTeamBilling={goToTeamBilling}
/>
))
)}
@@ -130,11 +133,13 @@ const MemberItem = ({
isOwner,
isAdmin,
currentAccount,
goToTeamBilling,
}: {
member: Member;
isAdmin: boolean;
isOwner: boolean;
currentAccount: AuthAccountInfo;
goToTeamBilling: () => void;
}) => {
const t = useI18n();
const [open, setOpen] = useState(false);
@@ -208,7 +213,10 @@ const MemberItem = ({
</div>
<div
className={clsx(styles.roleOrStatus, {
pending: member.status !== WorkspaceMemberStatus.Accepted,
pending:
member.status !== WorkspaceMemberStatus.Accepted &&
member.status !== WorkspaceMemberStatus.NeedMoreSeat,
error: member.status === WorkspaceMemberStatus.NeedMoreSeat,
})}
>
{t.t(memberStatus)}
@@ -220,6 +228,7 @@ const MemberItem = ({
openAssignModal={handleOpenAssignModal}
isAdmin={isAdmin}
isOwner={isOwner}
goToTeamBilling={goToTeamBilling}
/>
}
>
@@ -249,9 +258,10 @@ const MemberItem = ({
const getMemberStatus = (member: Member): I18nString => {
switch (member.status) {
case WorkspaceMemberStatus.NeedMoreSeat:
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
return 'insufficient-team-seat';
case WorkspaceMemberStatus.Pending:
return 'Pending';
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
case WorkspaceMemberStatus.UnderReview:
return 'Under-Review';
case WorkspaceMemberStatus.Accepted:

View File

@@ -14,11 +14,13 @@ export const MemberOptions = ({
isOwner,
isAdmin,
openAssignModal,
goToTeamBilling,
}: {
member: Member;
isOwner: boolean;
isAdmin: boolean;
openAssignModal: () => void;
goToTeamBilling: () => void;
}) => {
const t = useI18n();
const membersService = useService(WorkspaceMembersService);
@@ -163,13 +165,49 @@ export const MemberOptions = ({
});
}, [member, membersService, t]);
const handleRetryPayment = useCallback(() => {
openConfirmModal({
title: t['com.affine.payment.member.team.retry-payment.title'](),
description:
t[
`com.affine.payment.member.team.retry-payment.${isOwner ? 'owner' : 'admin'}.description`
](),
confirmText:
t[
isOwner
? 'com.affine.payment.member.team.retry-payment.update-payment'
: 'Got it'
](),
confirmButtonOptions: {
variant: 'primary',
},
onConfirm: isOwner ? goToTeamBilling : undefined,
cancelText: t['Cancel'](),
cancelButtonOptions: {
style: {
visibility: isOwner ? 'visible' : 'hidden',
},
},
});
}, [goToTeamBilling, isOwner, openConfirmModal, t]);
const operationButtonInfo = useMemo(() => {
return [
{
label: t['com.affine.payment.member.team.retry-payment'](),
onClick: handleRetryPayment,
show: member.status === WorkspaceMemberStatus.NeedMoreSeat,
},
{
label: t['com.affine.payment.member.team.approve'](),
onClick: handleApprove,
show: member.status === WorkspaceMemberStatus.UnderReview,
},
{
label: t['com.affine.payment.member.team.approve'](),
onClick: handleRetryPayment,
show: member.status === WorkspaceMemberStatus.NeedMoreSeatAndReview,
},
{
label: t['com.affine.payment.member.team.decline'](),
onClick: handleDecline,
@@ -230,11 +268,13 @@ export const MemberOptions = ({
handleChangeToCollaborator,
handleDecline,
handleRemove,
handleRetryPayment,
handleRevoke,
isAdmin,
isOwner,
isTeam,
member,
member.permission,
member.status,
t,
]);

View File

@@ -97,6 +97,9 @@ export const roleOrStatus = style({
'&.pending': {
color: cssVarV2('text/emphasis'),
},
'&.error': {
color: cssVarV2('status/error'),
},
},
});

View File

@@ -1,43 +1,80 @@
import { AcceptInvitePage } from '@affine/component/member-components';
import type { GetInviteInfoQuery } from '@affine/graphql';
import {
acceptInviteByInviteIdMutation,
ErrorNames,
getInviteInfoQuery,
UserFriendlyError,
} from '@affine/graphql';
AcceptInvitePage,
JoinFailedPage,
} from '@affine/component/member-components';
import { ErrorNames, UserFriendlyError } from '@affine/graphql';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useCallback, useEffect } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import {
RouteLogic,
useNavigateHelper,
} from '../../../components/hooks/use-navigate-helper';
import { AuthService, GraphQLService } from '../../../modules/cloud';
import { AppContainer } from '../../components/app-container';
import { AcceptInviteService, AuthService } from '../../../modules/cloud';
const AcceptInvite = ({ inviteId }: { inviteId: string }) => {
const { jumpToPage } = useNavigateHelper();
const acceptInviteService = useService(AcceptInviteService);
const error = useLiveData(acceptInviteService.error$);
const inviteInfo = useLiveData(acceptInviteService.inviteInfo$);
const accepted = useLiveData(acceptInviteService.accepted$);
const loading = useLiveData(acceptInviteService.loading$);
const navigateHelper = useNavigateHelper();
const openWorkspace = useCallback(() => {
if (!inviteInfo?.workspace.id) {
return;
}
jumpToPage(inviteInfo.workspace.id, 'all', RouteLogic.REPLACE);
}, [jumpToPage, inviteInfo]);
useEffect(() => {
acceptInviteService.revalidate({
inviteId,
});
}, [acceptInviteService, inviteId]);
useEffect(() => {
if (error) {
const err = UserFriendlyError.fromAnyError(error);
if (err.name === ErrorNames.ALREADY_IN_SPACE) {
return navigateHelper.jumpToIndex();
}
}
}, [error, navigateHelper]);
if (loading) {
return null;
}
if (!inviteInfo) {
// if invite is expired
return <Navigate to="/expired" />;
}
if (error) {
return <JoinFailedPage inviteInfo={inviteInfo} error={error} />;
}
if (accepted) {
return (
<AcceptInvitePage
inviteInfo={inviteInfo}
onOpenWorkspace={openWorkspace}
/>
);
} else {
// invite is expired
return <Navigate to="/expired" />;
}
};
/**
* /invite/:inviteId page
*
* only for web
*/
const AcceptInvite = ({
inviteInfo,
}: {
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
}) => {
const { jumpToPage } = useNavigateHelper();
const openWorkspace = useCallback(() => {
jumpToPage(inviteInfo.workspace.id, 'all', RouteLogic.REPLACE);
}, [inviteInfo.workspace.id, jumpToPage]);
return (
<AcceptInvitePage inviteInfo={inviteInfo} onOpenWorkspace={openWorkspace} />
);
};
export const Component = () => {
const authService = useService(AuthService);
const isRevalidating = useLiveData(authService.session.isRevalidating$);
@@ -57,73 +94,13 @@ export const Component = () => {
}
}, [isRevalidating, jumpToSignIn, loginStatus, params.inviteId]);
if (!params.inviteId) {
return <Navigate to="/expired" />;
}
if (loginStatus === 'authenticated') {
return <Middle />;
return <AcceptInvite inviteId={params.inviteId} />;
}
return null;
};
export const Middle = () => {
const graphqlService = useService(GraphQLService);
const params = useParams<{ inviteId: string }>();
const navigateHelper = useNavigateHelper();
const [data, setData] = useState<{
inviteId: string;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
} | null>(null);
useEffect(() => {
(async () => {
setData(null);
const inviteId = params.inviteId || '';
const res = await graphqlService.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
// If the inviteId is invalid, redirect to 404 page
if (!res || !res?.getInviteInfo) {
return navigateHelper.jumpTo404();
}
// No mater sign in or not, we need to accept the invite
await graphqlService.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: res.getInviteInfo.workspace.id,
inviteId,
sendAcceptMail: true,
},
});
setData({
inviteId,
inviteInfo: res.getInviteInfo,
});
return;
})().catch(error => {
const userFriendlyError = UserFriendlyError.fromAnyError(error);
console.error(userFriendlyError);
if (userFriendlyError.name === ErrorNames.ALREADY_IN_SPACE) {
return navigateHelper.jumpToIndex();
}
if (
userFriendlyError.name === ErrorNames.USER_NOT_FOUND ||
userFriendlyError.name === ErrorNames.SPACE_OWNER_NOT_FOUND
) {
return navigateHelper.jumpToExpired();
}
return navigateHelper.jumpTo404();
});
}, [graphqlService, navigateHelper, params.inviteId]);
if (!data) {
return <AppContainer fallback />;
}
return <AcceptInvite inviteInfo={data.inviteInfo} />;
};

View File

@@ -12,6 +12,7 @@ export { AccountLoggedIn } from './events/account-logged-in';
export { AccountLoggedOut } from './events/account-logged-out';
export { ServerInitialized } from './events/server-initialized';
export { ValidatorProvider } from './provider/validator';
export { AcceptInviteService } from './services/accept-invite';
export { AuthService } from './services/auth';
export { CaptchaService } from './services/captcha';
export { DefaultServerService } from './services/default-server';
@@ -52,6 +53,7 @@ import { WorkspaceInvoices } from './entities/workspace-invoices';
import { WorkspaceSubscription } from './entities/workspace-subscription';
import { ValidatorProvider } from './provider/validator';
import { ServerScope } from './scopes/server';
import { AcceptInviteService } from './services/accept-invite';
import { AuthService } from './services/auth';
import { CaptchaService } from './services/captcha';
import { CloudDocMetaService } from './services/cloud-doc-meta';
@@ -71,8 +73,10 @@ import { UserQuotaService } from './services/user-quota';
import { WorkspaceInvoicesService } from './services/workspace-invoices';
import { WorkspaceServerService } from './services/workspace-server';
import { WorkspaceSubscriptionService } from './services/workspace-subscription';
import { AcceptInviteStore } from './stores/accept-invite';
import { AuthStore } from './stores/auth';
import { CloudDocMetaStore } from './stores/cloud-doc-meta';
import { InviteInfoStore } from './stores/invite-info';
import { InvoicesStore } from './stores/invoices';
import { SelfhostGenerateLicenseStore } from './stores/selfhost-generate-license';
import { SelfhostLicenseStore } from './stores/selfhost-license';
@@ -136,7 +140,10 @@ export function configureCloudModule(framework: Framework) {
.store(InvoicesStore, [GraphQLService])
.entity(Invoices, [InvoicesStore])
.service(SelfhostGenerateLicenseService, [SelfhostGenerateLicenseStore])
.store(SelfhostGenerateLicenseStore, [GraphQLService]);
.store(SelfhostGenerateLicenseStore, [GraphQLService])
.store(InviteInfoStore, [GraphQLService])
.service(AcceptInviteService, [AcceptInviteStore, InviteInfoStore])
.store(AcceptInviteStore, [GraphQLService]);
framework
.scope(WorkspaceScope)

View File

@@ -0,0 +1,78 @@
import type { GetInviteInfoQuery } 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 '../error';
import type { AcceptInviteStore } from '../stores/accept-invite';
import type { InviteInfoStore } from '../stores/invite-info';
export type InviteInfo = GetInviteInfoQuery['getInviteInfo'];
export class AcceptInviteService extends Service {
constructor(
private readonly store: AcceptInviteStore,
private readonly inviteInfoStore: InviteInfoStore
) {
super();
}
inviteInfo$ = new LiveData<InviteInfo | undefined>(undefined);
accepted$ = new LiveData<boolean>(false);
loading$ = new LiveData(false);
error$ = new LiveData<any>(null);
readonly revalidate = effect(
exhaustMap(({ inviteId }: { inviteId: string }) => {
if (!inviteId) {
return EMPTY;
}
return fromPromise(async () => {
return await this.inviteInfoStore.getInviteInfo(inviteId);
}).pipe(
mergeMap(res => {
this.inviteInfo$.setValue(res);
return fromPromise(async () => {
return await this.store.acceptInvite(
res.workspace.id,
inviteId,
true
);
});
}),
mergeMap(res => {
this.accepted$.next(res);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
count: 3,
}),
catchErrorInto(this.error$),
onStart(() => {
this.loading$.setValue(true);
this.inviteInfo$.setValue(undefined);
this.accepted$.setValue(false);
}),
onComplete(() => {
this.loading$.setValue(false);
})
);
})
);
override dispose(): void {
this.revalidate.unsubscribe();
}
}

View File

@@ -0,0 +1,30 @@
import { acceptInviteByInviteIdMutation } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
export class AcceptInviteStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
super();
}
async acceptInvite(
workspaceId: string,
inviteId: string,
sendAcceptMail?: boolean,
signal?: AbortSignal
) {
const data = await this.gqlService.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId,
inviteId,
sendAcceptMail,
},
context: { signal },
});
return data.acceptInviteById;
}
}

View File

@@ -0,0 +1,25 @@
import { getInviteInfoQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
export class InviteInfoStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
super();
}
async getInviteInfo(inviteId?: string, signal?: AbortSignal) {
if (!inviteId) {
throw new Error('No inviteId');
}
const data = await this.gqlService.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
context: { signal },
});
return data.getInviteInfo;
}
}