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 af44b628f..de3c3478f 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 f85993ade..7011190f4 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 000000000..d4eb7aded
--- /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 918b684ed..9f284b27a 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']()}
+
+
+
+
+
-
{t['404.hint']()}
-
-
-
-
>
) : (
signInComponent
@@ -84,6 +87,32 @@ export const NotFoundPage = ({
return (
+
+
{t['404.hint']()}
+
+
+
+ {user ? (
+
+ ) : null}
+
-
{t['404.hint']()}
-
-
-
-
- {user ? (
-
- ) : 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 639eb3c46..ad600a90d 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 2f15f5f8b..1babe0eee 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 37dba6f28..9a77ba780 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 f5ada09ef..8c9890c02 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 8afce69a7..063410519 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 1c414363e..e5a777a3b 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 3fdd1e7ff..bf6abbd36 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';
+
+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
;
+ }
+
+ if (error) {
+ return
;
+ }
+
+ if (accepted) {
+ return (
+
+ );
+ } else {
+ // invite is expired
+ return
;
+ }
+};
/**
* /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 (
-
- );
-};
-
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
;
+ }
+
if (loginStatus === 'authenticated') {
- return
;
+ 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
;
- }
-
- return
;
-};
diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts
index 967de6735..a60c0e4af 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 000000000..5fbb3a917
--- /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 000000000..4bb6ffb69
--- /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 000000000..49551dc5e
--- /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 dcf02b409..f20e7b83d 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 61c50a68f..d8f737cb8 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}}2> 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 253943d65..c5e94ee44 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}}2> 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.",