From d5a626d9c3e8492478c33b216fac2e7080cb026c Mon Sep 17 00:00:00 2001 From: JimmFly Date: Fri, 7 Feb 2025 10:08:00 +0000 Subject: [PATCH] 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 --- .../auth-components/auth-page-container.tsx | 2 +- .../components/member-components/index.tsx | 1 + .../member-components/join-failed-page.tsx | 53 ++++++ .../not-found-page/not-found-page.tsx | 98 +++++------ .../components/not-found-page/styles.css.ts | 16 +- .../setting/general-setting/plans/actions.tsx | 9 +- .../members/cloud-members-panel.tsx | 89 +++++++++- .../workspace-setting/members/member-list.tsx | 14 +- .../members/member-option.tsx | 42 ++++- .../workspace-setting/members/styles.css.ts | 3 + .../core/src/desktop/pages/invite/index.tsx | 157 ++++++++---------- .../frontend/core/src/modules/cloud/index.ts | 9 +- .../modules/cloud/services/accept-invite.ts | 78 +++++++++ .../src/modules/cloud/stores/accept-invite.ts | 30 ++++ .../src/modules/cloud/stores/invite-info.ts | 25 +++ .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/i18n.gen.ts | 69 ++++++++ packages/frontend/i18n/src/resources/en.json | 15 ++ 18 files changed, 560 insertions(+), 152 deletions(-) create mode 100644 packages/frontend/component/src/components/member-components/join-failed-page.tsx create mode 100644 packages/frontend/core/src/modules/cloud/services/accept-invite.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/accept-invite.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/invite-info.ts diff --git a/packages/frontend/component/src/components/auth-components/auth-page-container.tsx b/packages/frontend/component/src/components/auth-components/auth-page-container.tsx index af44b628fff3a..de3c3478ff220 100644 --- a/packages/frontend/component/src/components/auth-components/auth-page-container.tsx +++ b/packages/frontend/component/src/components/auth-components/auth-page-container.tsx @@ -22,7 +22,7 @@ export const AuthPageContainer: FC<

{title}

-

{subtitle}

+
{subtitle}
{children}
diff --git a/packages/frontend/component/src/components/member-components/index.tsx b/packages/frontend/component/src/components/member-components/index.tsx index f85993ade1bb2..7011190f4345f 100644 --- a/packages/frontend/component/src/components/member-components/index.tsx +++ b/packages/frontend/component/src/components/member-components/index.tsx @@ -1,4 +1,5 @@ export * from './accept-invite-page'; export * from './invite-modal'; export * from './invite-team-modal'; +export * from './join-failed-page'; export * from './member-limit-modal'; diff --git a/packages/frontend/component/src/components/member-components/join-failed-page.tsx b/packages/frontend/component/src/components/member-components/join-failed-page.tsx new file mode 100644 index 0000000000000..d4eb7adedfa95 --- /dev/null +++ b/packages/frontend/component/src/components/member-components/join-failed-page.tsx @@ -0,0 +1,53 @@ +import { AuthPageContainer } from '@affine/component/auth-components'; +import { + ErrorNames, + type GetInviteInfoQuery, + UserFriendlyError, +} from '@affine/graphql'; +import { Trans, useI18n } from '@affine/i18n'; + +import { Avatar } from '../../ui/avatar'; +import * as styles from './styles.css'; + +export const JoinFailedPage = ({ + inviteInfo, + error, +}: { + inviteInfo: GetInviteInfoQuery['getInviteInfo']; + error?: any; +}) => { + const userFriendlyError = UserFriendlyError.fromAnyError(error); + const t = useI18n(); + return ( + + + ), + 2: , + }} + values={{ + workspaceName: inviteInfo.workspace.name, + }} + /> +
{t['com.affine.fail-to-join-workspace.description-2']()}
+
+ ) : ( +
{t['error.' + userFriendlyError.name]()}
+ ) + } + /> + ); +}; diff --git a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx index 918b684ed5209..9f284b27aca57 100644 --- a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx +++ b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx @@ -11,6 +11,7 @@ import illustrationLight from '../affine-other-page-layout/assets/other-page.lig import type { User } from '../auth-components'; import { illustration, + info, largeButtonEffect, notFoundPageContainer, wrapper, @@ -35,6 +36,30 @@ export const NoPermissionOrNotFound = ({
{user ? ( <> +
+

{t['404.hint']()}

+
+ +
+
+ + {user.email} + + + +
+
-

{t['404.hint']()}

-
- -
-
- - {user.email} - - - -
) : ( signInComponent @@ -84,6 +87,32 @@ export const NotFoundPage = ({ return (
+
+

{t['404.hint']()}

+
+ +
+ {user ? ( +
+ + {user.email} + + + +
+ ) : null} +
-

{t['404.hint']()}

-
- -
- - {user ? ( -
- - {user.email} - - - -
- ) : null}
); diff --git a/packages/frontend/component/src/components/not-found-page/styles.css.ts b/packages/frontend/component/src/components/not-found-page/styles.css.ts index 639eb3c462b55..ad600a90df5ed 100644 --- a/packages/frontend/component/src/components/not-found-page/styles.css.ts +++ b/packages/frontend/component/src/components/not-found-page/styles.css.ts @@ -3,11 +3,10 @@ import { style } from '@vanilla-extract/css'; export const notFoundPageContainer = style({ fontSize: cssVar('fontBase'), color: cssVar('textPrimaryColor'), - height: '100vh', + height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', - justifyContent: 'center', width: '100%', padding: '0 20px', }); @@ -15,7 +14,18 @@ export const wrapper = style({ display: 'flex', alignItems: 'center', justifyContent: 'center', - margin: '24px auto 0', + margin: '0 auto', +}); +export const info = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '24px', + textAlign: 'center', + marginTop: 'auto', + paddingTop: '120px', + marginBottom: 'auto', }); export const largeButtonEffect = style({ boxShadow: `${cssVar('largeButtonEffect')} !important`, diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx index 2f15f5f8b6570..1babe0eee290b 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx @@ -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 ( <> diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx index 37dba6f28a1bc..9a77ba780422c 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx @@ -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 = ({
- +
); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx index f5ada09ef96e8..8c9890c0218b3 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx @@ -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 = ({
{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: diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx index 8afce69a7a971..063410519a9e5 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx @@ -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, ]); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/styles.css.ts index 1c414363e451c..e5a777a3ba751 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/styles.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/styles.css.ts @@ -97,6 +97,9 @@ export const roleOrStatus = style({ '&.pending': { color: cssVarV2('text/emphasis'), }, + '&.error': { + color: cssVarV2('status/error'), + }, }, }); diff --git a/packages/frontend/core/src/desktop/pages/invite/index.tsx b/packages/frontend/core/src/desktop/pages/invite/index.tsx index 3fdd1e7ffddfc..bf6abbd36a0bb 100644 --- a/packages/frontend/core/src/desktop/pages/invite/index.tsx +++ b/packages/frontend/core/src/desktop/pages/invite/index.tsx @@ -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'; -/** - * /invite/:inviteId page - * - * only for web - */ -const AcceptInvite = ({ - inviteInfo, -}: { - inviteInfo: GetInviteInfoQuery['getInviteInfo']; -}) => { +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); - }, [inviteInfo.workspace.id, jumpToPage]); + }, [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; + } - return ( - - ); + if (!inviteInfo) { + // if invite is expired + return ; + } + + if (error) { + return ; + } + + if (accepted) { + return ( + + ); + } else { + // invite is expired + return ; + } }; +/** + * /invite/:inviteId page + * + * only for web + */ 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 (loginStatus === 'authenticated') { - return ; + if (!params.inviteId) { + return ; } - 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 ; + if (loginStatus === 'authenticated') { + return ; } - return ; + return null; }; diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 967de67350595..a60c0e4af353a 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -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) diff --git a/packages/frontend/core/src/modules/cloud/services/accept-invite.ts b/packages/frontend/core/src/modules/cloud/services/accept-invite.ts new file mode 100644 index 0000000000000..5fbb3a9170339 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/accept-invite.ts @@ -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(undefined); + accepted$ = new LiveData(false); + loading$ = new LiveData(false); + error$ = new LiveData(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(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/accept-invite.ts b/packages/frontend/core/src/modules/cloud/stores/accept-invite.ts new file mode 100644 index 0000000000000..4bb6ffb690c62 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/accept-invite.ts @@ -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; + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/invite-info.ts b/packages/frontend/core/src/modules/cloud/stores/invite-info.ts new file mode 100644 index 0000000000000..49551dc5e4263 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/invite-info.ts @@ -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; + } +} diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index dcf02b409d7cd..f20e7b83df95e 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -6,7 +6,7 @@ "el-GR": 90, "en": 100, "es-AR": 90, - "es-CL": 92, + "es-CL": 91, "es": 90, "fa": 90, "fr": 90, diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 61c50a68f6abb..d8f737cb8c3d0 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -289,6 +289,10 @@ export function useAFFiNEI18N(): { * `Invited members will collaborate with you in current workspace` */ ["Invite Members Message"](): string; + /** + * `Insufficient team seat` + */ + ["insufficient-team-seat"](): string; /** * `Joined workspace` */ @@ -3881,6 +3885,10 @@ export function useAFFiNEI18N(): { * `Remove member` */ ["com.affine.payment.member.team.remove"](): string; + /** + * `Retry payment` + */ + ["com.affine.payment.member.team.retry-payment"](): string; /** * `Change role to admin` */ @@ -3893,6 +3901,34 @@ export function useAFFiNEI18N(): { * `Assign as owner` */ ["com.affine.payment.member.team.assign"](): string; + /** + * `Insufficient Team Seats` + */ + ["com.affine.payment.member.team.retry-payment.title"](): string; + /** + * `The payment for adding new team members has failed. To add more seats, please update your payment method and process unpaid invoices.` + */ + ["com.affine.payment.member.team.retry-payment.owner.description"](): string; + /** + * `The payment for adding new team members has failed. Please contact your workspace owner to update the payment method and process unpaid invoices.` + */ + ["com.affine.payment.member.team.retry-payment.admin.description"](): string; + /** + * `Update Payment` + */ + ["com.affine.payment.member.team.retry-payment.update-payment"](): string; + /** + * `Subscription has been disabled for your team workspace. To add more seats, you'll need to resume subscription first.` + */ + ["com.affine.payment.member.team.disabled-subscription.owner.description"](): string; + /** + * `Your team workspace has subscription disabled, which prevents adding more seats. Please contact your workspace owner to enable subscription.` + */ + ["com.affine.payment.member.team.disabled-subscription.admin.description"](): string; + /** + * `Resume Subscription` + */ + ["com.affine.payment.member.team.disabled-subscription.resume-subscription"](): string; /** * `Invitation Revoked` */ @@ -4081,6 +4117,14 @@ export function useAFFiNEI18N(): { * `Resume` */ ["com.affine.payment.resume"](): string; + /** + * `Subscription Resumed` + */ + ["com.affine.payment.resume.success.title"](): string; + /** + * `Your team workspace subscription has been enabled successfully. Changes will take effect immediately.` + */ + ["com.affine.payment.resume.success.team.message"](): string; /** * `Resume auto-renewal` */ @@ -4241,6 +4285,10 @@ export function useAFFiNEI18N(): { * `Open in center peek` */ ["com.affine.peek-view-controls.open-doc-in-center-peek"](): string; + /** + * `Click or drag` + */ + ["com.affine.split-view-drag-handle.tooltip"](): string; /** * `New` */ @@ -6820,6 +6868,14 @@ export function useAFFiNEI18N(): { * `Are you sure you want to delete these blob files? This action cannot be undone. Make sure you no longer need them before proceeding.` */ ["com.affine.settings.workspace.storage.unused-blobs.delete.warning"](): string; + /** + * `Join Failed` + */ + ["com.affine.fail-to-join-workspace.title"](): string; + /** + * `Please contact your workspace owner to add more seats.` + */ + ["com.affine.fail-to-join-workspace.description-2"](): string; /** * `An internal error occurred.` */ @@ -7072,6 +7128,10 @@ export function useAFFiNEI18N(): { * `A Team workspace is required to perform this action.` */ ["error.ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE"](): string; + /** + * `Page default role can not be owner.` + */ + ["error.PAGE_DEFAULT_ROLE_CAN_NOT_BE_OWNER"](): string; /** * `Unsupported subscription plan: {{plan}}.` */ @@ -7690,4 +7750,13 @@ export const TypedTrans: { }, { ["1"]: JSX.Element; }>>; + /** + * `Unable to join <1/> <2>{{workspaceName}} due to insufficient seats available.` + */ + ["com.affine.fail-to-join-workspace.description-1"]: ComponentType>; } = /*#__PURE__*/ createProxy(createComponent); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 253943d65bff5..c5e94ee44fb3d 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -63,6 +63,7 @@ "Invite": "Invite", "Invite Members": "Invite members", "Invite Members Message": "Invited members will collaborate with you in current workspace", + "insufficient-team-seat": "Insufficient team seat", "Joined Workspace": "Joined workspace", "Leave": "Leave", "Link": "Hyperlink (with selected text)", @@ -965,9 +966,17 @@ "com.affine.payment.member.team.approve": "Approve", "com.affine.payment.member.team.decline": "Decline", "com.affine.payment.member.team.remove": "Remove member", + "com.affine.payment.member.team.retry-payment": "Retry payment", "com.affine.payment.member.team.change.admin": "Change role to admin", "com.affine.payment.member.team.change.collaborator": "Change role to collaborator", "com.affine.payment.member.team.assign": "Assign as owner", + "com.affine.payment.member.team.retry-payment.title": "Insufficient Team Seats", + "com.affine.payment.member.team.retry-payment.owner.description": "The payment for adding new team members has failed. To add more seats, please update your payment method and process unpaid invoices.", + "com.affine.payment.member.team.retry-payment.admin.description": "The payment for adding new team members has failed. Please contact your workspace owner to update the payment method and process unpaid invoices.", + "com.affine.payment.member.team.retry-payment.update-payment": "Update Payment", + "com.affine.payment.member.team.disabled-subscription.owner.description": "Subscription has been disabled for your team workspace. To add more seats, you'll need to resume subscription first.", + "com.affine.payment.member.team.disabled-subscription.admin.description": "Your team workspace has subscription disabled, which prevents adding more seats. Please contact your workspace owner to enable subscription.", + "com.affine.payment.member.team.disabled-subscription.resume-subscription": "Resume Subscription", "com.affine.payment.member.team.revoke.notify.title": "Invitation Revoked", "com.affine.payment.member.team.revoke.notify.message": "You have canceled the invitation for {{name}}", "com.affine.payment.member.team.approve.notify.title": "Request approved", @@ -1011,6 +1020,8 @@ "com.affine.payment.recurring-monthly": "monthly", "com.affine.payment.recurring-yearly": "annually", "com.affine.payment.resume": "Resume", + "com.affine.payment.resume.success.title": "Subscription Resumed", + "com.affine.payment.resume.success.team.message": "Your team workspace subscription has been enabled successfully. Changes will take effect immediately.", "com.affine.payment.resume-renewal": "Resume auto-renewal", "com.affine.payment.see-all-plans": "See all plans", "com.affine.payment.sign-up-free": "Sign up free", @@ -1701,6 +1712,9 @@ "com.affine.settings.workspace.storage.unused-blobs.selected": "Selected", "com.affine.settings.workspace.storage.unused-blobs.delete.title": "Delete blob files", "com.affine.settings.workspace.storage.unused-blobs.delete.warning": "Are you sure you want to delete these blob files? This action cannot be undone. Make sure you no longer need them before proceeding.", + "com.affine.fail-to-join-workspace.title": "Join Failed", + "com.affine.fail-to-join-workspace.description-1": "Unable to join <1/> <2>{{workspaceName}} due to insufficient seats available.", + "com.affine.fail-to-join-workspace.description-2": "Please contact your workspace owner to add more seats.", "error.INTERNAL_SERVER_ERROR": "An internal error occurred.", "error.TOO_MANY_REQUEST": "Too many requests.", "error.NOT_FOUND": "Resource not found.", @@ -1751,6 +1765,7 @@ "error.FAILED_TO_SAVE_UPDATES": "Failed to store doc updates.", "error.FAILED_TO_UPSERT_SNAPSHOT": "Failed to store doc snapshot.", "error.ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE": "A Team workspace is required to perform this action.", + "error.PAGE_DEFAULT_ROLE_CAN_NOT_BE_OWNER": "Page default role can not be owner.", "error.UNSUPPORTED_SUBSCRIPTION_PLAN": "Unsupported subscription plan: {{plan}}.", "error.FAILED_TO_CHECKOUT": "Failed to create checkout session.", "error.INVALID_CHECKOUT_PARAMETERS": "Invalid checkout parameters provided.",