mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-05 08:19:48 +08:00
feat(server): support switching accounts when accepting an invitation (#15542)
#### PR Dependency Tree * **PR #15542** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Invitation links now require sign-in to view or accept. - Added guidance when an invitation belongs to a different account, with options to switch accounts or return to AFFiNE. - Sign-in preserves the invitation link when switching accounts. - **Bug Fixes** - Invitation errors now display accurate messages instead of generic errors. - Improved handling of expired, invalid, and missing invitations. - Invitation status checks now work correctly for authenticated invitees. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -84,6 +84,48 @@ e2e('should invite a user', async t => {
|
||||
result.inviteMembers[0].inviteId!
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: getInviteInfoQuery,
|
||||
variables: {
|
||||
inviteId: invitationNotification.payload.inviteId,
|
||||
},
|
||||
}),
|
||||
{ message: 'This invitation belongs to another account.' }
|
||||
);
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: acceptInviteByInviteIdMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
inviteId: invitationNotification.payload.inviteId,
|
||||
},
|
||||
}),
|
||||
{ message: 'This invitation belongs to another account.' }
|
||||
);
|
||||
|
||||
await app.logout();
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: getInviteInfoQuery,
|
||||
variables: {
|
||||
inviteId: invitationNotification.payload.inviteId,
|
||||
},
|
||||
}),
|
||||
{ message: 'You must sign in first to access this resource.' }
|
||||
);
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: acceptInviteByInviteIdMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
inviteId: invitationNotification.payload.inviteId,
|
||||
},
|
||||
}),
|
||||
{ message: 'You must sign in first to access this resource.' }
|
||||
);
|
||||
|
||||
await app.login(u2);
|
||||
// invitation status is pending
|
||||
const { getInviteInfo } = await app.gql({
|
||||
query: getInviteInfoQuery,
|
||||
@@ -94,7 +136,6 @@ e2e('should invite a user', async t => {
|
||||
t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending);
|
||||
|
||||
// u2 accept invite
|
||||
await app.login(u2);
|
||||
await app.gql({
|
||||
query: acceptInviteByInviteIdMutation,
|
||||
variables: {
|
||||
|
||||
@@ -141,6 +141,7 @@ e2e('should set new invited users to waiting-seat status', async t => {
|
||||
|
||||
t.not(result.inviteMembers[0].inviteId, null);
|
||||
|
||||
await app.login(u1);
|
||||
const invitationInfo = await getInvitationInfo(
|
||||
result.inviteMembers[0].inviteId!
|
||||
);
|
||||
@@ -163,6 +164,7 @@ e2e('should allocate existing team seats for new invited users', async t => {
|
||||
|
||||
t.not(result.inviteMembers[0].inviteId, null);
|
||||
|
||||
await app.login(u1);
|
||||
const invitationInfo = await getInvitationInfo(
|
||||
result.inviteMembers[0].inviteId!
|
||||
);
|
||||
|
||||
@@ -630,6 +630,10 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
type: 'invalid_input',
|
||||
message: 'Invalid invitation provided.',
|
||||
},
|
||||
invitation_account_mismatch: {
|
||||
type: 'action_forbidden',
|
||||
message: 'This invitation belongs to another account.',
|
||||
},
|
||||
no_more_seat: {
|
||||
type: 'bad_request',
|
||||
args: { spaceId: 'string' },
|
||||
|
||||
@@ -602,6 +602,12 @@ export class InvalidInvitation extends UserFriendlyError {
|
||||
super('invalid_input', 'invalid_invitation', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvitationAccountMismatch extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'invitation_account_mismatch', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class NoMoreSeatDataType {
|
||||
@Field() spaceId!: string
|
||||
@@ -1283,6 +1289,7 @@ export enum ErrorNames {
|
||||
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS,
|
||||
NEW_OWNER_IS_NOT_ACTIVE_MEMBER,
|
||||
INVALID_INVITATION,
|
||||
INVITATION_ACCOUNT_MISMATCH,
|
||||
NO_MORE_SEAT,
|
||||
UNSUPPORTED_SUBSCRIPTION_PLAN,
|
||||
FAILED_TO_CHECKOUT,
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
ActionForbidden,
|
||||
ActionForbiddenOnNonTeamWorkspace,
|
||||
AlreadyInSpace,
|
||||
AuthenticationRequired,
|
||||
Cache,
|
||||
CanNotRevokeYourself,
|
||||
Config,
|
||||
EventBus,
|
||||
getRequestTrackerId,
|
||||
InvalidInvitation,
|
||||
InvitationAccountMismatch,
|
||||
isValidCacheTtl,
|
||||
mapAnyError,
|
||||
MemberNotFoundInSpace,
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from '../../../base';
|
||||
import type { GraphqlContext } from '../../../base/graphql';
|
||||
import { Models, type WorkspaceUserCompat } from '../../../models';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { containsUrlOrDomain } from '../../content-policy';
|
||||
import {
|
||||
PermissionAccess,
|
||||
@@ -556,26 +556,24 @@ export class WorkspaceMemberResolver {
|
||||
}
|
||||
|
||||
@Throttle('strict')
|
||||
@Public()
|
||||
@Query(() => InvitationType, {
|
||||
description: 'get workspace invitation info',
|
||||
})
|
||||
async getInviteInfo(
|
||||
@CurrentUser() user: UserType | undefined,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('inviteId') inviteId: string
|
||||
): Promise<InvitationType> {
|
||||
const { workspaceId, inviteeUserId, isLink } =
|
||||
await this.workspaceService.getInviteInfo(inviteId);
|
||||
|
||||
if (!isLink && (!user || user.id !== inviteeUserId)) {
|
||||
throw new InvalidInvitation();
|
||||
if (!isLink && user.id !== inviteeUserId) {
|
||||
throw new InvitationAccountMismatch();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
|
||||
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||
|
||||
const inviteeId = inviteeUserId || user?.id;
|
||||
if (!inviteeId) throw new UserNotFound();
|
||||
const inviteeId = inviteeUserId || user.id;
|
||||
const invitee = await this.models.user.getWorkspaceUser(inviteeId);
|
||||
if (!invitee) throw new UserNotFound();
|
||||
|
||||
@@ -643,9 +641,8 @@ export class WorkspaceMemberResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@Public()
|
||||
async acceptInviteById(
|
||||
@CurrentUser() user: CurrentUser | undefined,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('inviteId') inviteId: string,
|
||||
@Args('workspaceId', { deprecationReason: 'never used', nullable: true })
|
||||
_workspaceId: string,
|
||||
@@ -658,17 +655,13 @@ export class WorkspaceMemberResolver {
|
||||
const role = await this.models.workspaceUser.getById(inviteId);
|
||||
// invitation by email
|
||||
if (role) {
|
||||
if (user && user.id !== role.userId) {
|
||||
throw new InvalidInvitation();
|
||||
if (user.id !== role.userId) {
|
||||
throw new InvitationAccountMismatch();
|
||||
}
|
||||
|
||||
await this.acceptInvitationByEmail(role);
|
||||
} else {
|
||||
// invitation by link
|
||||
if (!user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
const invitation = await this.cache.get<{
|
||||
workspaceId: string;
|
||||
inviterUserId: string;
|
||||
|
||||
@@ -1021,6 +1021,7 @@ enum ErrorNames {
|
||||
INVALID_RUNTIME_CONFIG_TYPE
|
||||
INVALID_SEARCH_PROVIDER_REQUEST
|
||||
INVALID_SUBSCRIPTION_PARAMETERS
|
||||
INVITATION_ACCOUNT_MISMATCH
|
||||
LICENSE_EXPIRED
|
||||
LICENSE_NOT_FOUND
|
||||
LICENSE_REVEALED
|
||||
|
||||
@@ -1221,6 +1221,7 @@ export enum ErrorNames {
|
||||
INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE',
|
||||
INVALID_SEARCH_PROVIDER_REQUEST = 'INVALID_SEARCH_PROVIDER_REQUEST',
|
||||
INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS',
|
||||
INVITATION_ACCOUNT_MISMATCH = 'INVITATION_ACCOUNT_MISMATCH',
|
||||
LICENSE_EXPIRED = 'LICENSE_EXPIRED',
|
||||
LICENSE_NOT_FOUND = 'LICENSE_NOT_FOUND',
|
||||
LICENSE_REVEALED = 'LICENSE_REVEALED',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './accept-invite-page';
|
||||
export * from './expired';
|
||||
export * from './failed-to-send-page';
|
||||
export * from './invitation-account-mismatch';
|
||||
export * from './invite-modal';
|
||||
export * from './invite-team-modal';
|
||||
export * from './join-failed-page';
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
AuthPageContainer,
|
||||
type User,
|
||||
} from '@affine/component/auth-components';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import { Button } from '../../ui/button';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const InvitationAccountMismatchPage = ({
|
||||
user,
|
||||
switchingAccount,
|
||||
onSwitchAccount,
|
||||
onOpenAffine,
|
||||
}: {
|
||||
user: User | null;
|
||||
switchingAccount: boolean;
|
||||
onSwitchAccount: () => void;
|
||||
onOpenAffine: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['com.affine.invitation.account-mismatch.title']()}
|
||||
subtitle={t['com.affine.invitation.account-mismatch.description']()}
|
||||
>
|
||||
{user ? (
|
||||
<div className={styles.currentAccount}>
|
||||
<Avatar url={user.avatar ?? user.image} name={user.label} />
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.accountMismatchActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
loading={switchingAccount}
|
||||
disabled={switchingAccount}
|
||||
onClick={onSwitchAccount}
|
||||
block
|
||||
>
|
||||
{t['com.affine.invitation.account-mismatch.switch-account']()}
|
||||
</Button>
|
||||
<Button size="large" onClick={onOpenAffine} block>
|
||||
{t['com.affine.invitation.account-mismatch.back-to-affine']()}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,20 @@ export const userInfoWrapper = style({
|
||||
marginTop: '28px',
|
||||
});
|
||||
|
||||
export const currentAccount = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const accountMismatchActions = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const lineHeight = style({
|
||||
lineHeight: '1.5',
|
||||
});
|
||||
|
||||
@@ -28,11 +28,13 @@ export const SignInPanel = ({
|
||||
server: initialServerBaseUrl,
|
||||
initStep,
|
||||
onAuthenticated,
|
||||
redirectUrl,
|
||||
}: {
|
||||
onAuthenticated?: (status: AuthSessionStatus) => void;
|
||||
onSkip: () => void;
|
||||
server?: string;
|
||||
initStep?: SignInStep | undefined;
|
||||
redirectUrl?: string;
|
||||
}) => {
|
||||
const [state, setState] = useState<SignInState>({
|
||||
step: initStep
|
||||
@@ -41,6 +43,7 @@ export const SignInPanel = ({
|
||||
? 'addSelfhosted'
|
||||
: 'signIn',
|
||||
initialServerBaseUrl: initialServerBaseUrl,
|
||||
redirectUrl,
|
||||
});
|
||||
|
||||
const defaultServerService = useService(DefaultServerService);
|
||||
|
||||
@@ -70,6 +70,7 @@ export const SignIn = ({
|
||||
onAuthenticated={handleAuthenticated}
|
||||
initStep={initStep}
|
||||
server={server}
|
||||
redirectUrl={redirectUrl ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</SignInPageContainer>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notify } from '@affine/component';
|
||||
import {
|
||||
AcceptInvitePage,
|
||||
ExpiredPage,
|
||||
InvitationAccountMismatchPage,
|
||||
JoinFailedPage,
|
||||
RequestToJoinPage,
|
||||
SentRequestPage,
|
||||
@@ -35,6 +36,7 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [requestToJoinLoading, setRequestToJoinLoading] = useState(false);
|
||||
const [switchingAccount, setSwitchingAccount] = useState(false);
|
||||
const [acceptError, setAcceptError] = useState<UserFriendlyError | null>(
|
||||
null
|
||||
);
|
||||
@@ -76,27 +78,63 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
|
||||
return openWorkspace();
|
||||
}
|
||||
setAcceptError(err);
|
||||
if (err.is('INVITATION_ACCOUNT_MISMATCH')) {
|
||||
return;
|
||||
}
|
||||
notify.error(err);
|
||||
});
|
||||
setRequestToJoinLoading(false);
|
||||
}, [invitationService, openWorkspace, targetInviteId]);
|
||||
|
||||
const onSignOut = useAsyncCallback(async () => {
|
||||
await authService.signOut();
|
||||
navigateHelper.jumpToSignIn();
|
||||
}, [authService, navigateHelper]);
|
||||
const onSwitchAccount = useAsyncCallback(async () => {
|
||||
setSwitchingAccount(true);
|
||||
try {
|
||||
await authService.signOut();
|
||||
navigateHelper.jumpToSignIn(
|
||||
`/invite/${targetInviteId}`,
|
||||
RouteLogic.REPLACE
|
||||
);
|
||||
} finally {
|
||||
setSwitchingAccount(false);
|
||||
}
|
||||
}, [authService, navigateHelper, targetInviteId]);
|
||||
|
||||
const invitationError = error ? UserFriendlyError.fromAny(error) : null;
|
||||
const accountMismatch =
|
||||
invitationError?.is('INVITATION_ACCOUNT_MISMATCH') ||
|
||||
acceptError?.is('INVITATION_ACCOUNT_MISMATCH');
|
||||
|
||||
if ((loading && !requestToJoinLoading) || inviteId !== targetInviteId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!inviteInfo && !loading) {
|
||||
if (accountMismatch) {
|
||||
return (
|
||||
<InvitationAccountMismatchPage
|
||||
user={user}
|
||||
switchingAccount={switchingAccount}
|
||||
onSwitchAccount={onSwitchAccount}
|
||||
onOpenAffine={onOpenAffine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!inviteInfo &&
|
||||
!loading &&
|
||||
(!invitationError ||
|
||||
invitationError.is('INVALID_INVITATION') ||
|
||||
invitationError.is('NOT_FOUND'))
|
||||
) {
|
||||
return <ExpiredPage onOpenAffine={onOpenAffine} />;
|
||||
}
|
||||
|
||||
if (error || acceptError) {
|
||||
if (invitationError || acceptError) {
|
||||
return (
|
||||
<JoinFailedPage inviteInfo={inviteInfo} error={error || acceptError} />
|
||||
<JoinFailedPage
|
||||
inviteInfo={inviteInfo}
|
||||
error={invitationError || acceptError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,7 +161,7 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
|
||||
user={user}
|
||||
inviteInfo={inviteInfo}
|
||||
requestToJoin={requestToJoin}
|
||||
onSignOut={onSignOut}
|
||||
onSignOut={onSwitchAccount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -142,10 +180,13 @@ export const Component = () => {
|
||||
|
||||
useEffect(() => {
|
||||
authService.session.revalidate();
|
||||
if (params.inviteId) {
|
||||
}, [authService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginStatus === 'authenticated' && params.inviteId) {
|
||||
invitationService.getInviteInfo({ inviteId: params.inviteId });
|
||||
}
|
||||
}, [authService, invitationService, params.inviteId]);
|
||||
}, [invitationService, loginStatus, params.inviteId]);
|
||||
|
||||
const { jumpToSignIn } = useNavigateHelper();
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export class InvitationService extends Service {
|
||||
this.inviteId$.setValue(inviteId);
|
||||
this.loading$.setValue(true);
|
||||
this.inviteInfo$.setValue(undefined);
|
||||
this.error$.setValue(null);
|
||||
}),
|
||||
onComplete(() => {
|
||||
this.loading$.setValue(false);
|
||||
@@ -59,6 +60,9 @@ export class InvitationService extends Service {
|
||||
this.getInviteInfo({ inviteId });
|
||||
await this.loading$.waitFor(f => !f);
|
||||
if (!this.inviteInfo$.value) {
|
||||
if (this.error$.value) {
|
||||
throw this.error$.value;
|
||||
}
|
||||
throw new Error('Invalid invite id');
|
||||
}
|
||||
return await this.acceptInviteStore.acceptInvite(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ar": 88,
|
||||
"ar": 87,
|
||||
"ca": 85,
|
||||
"da": 3,
|
||||
"de": 95,
|
||||
@@ -9,20 +9,20 @@
|
||||
"es-CL": 85,
|
||||
"es": 84,
|
||||
"fa": 84,
|
||||
"fr": 88,
|
||||
"fr": 87,
|
||||
"hi": 1,
|
||||
"it": 85,
|
||||
"ja": 84,
|
||||
"kk": 91,
|
||||
"ko": 85,
|
||||
"kk": 90,
|
||||
"ko": 84,
|
||||
"nb-NO": 42,
|
||||
"pl": 85,
|
||||
"pt-BR": 84,
|
||||
"ru": 86,
|
||||
"sv-SE": 84,
|
||||
"tr": 91,
|
||||
"tr": 90,
|
||||
"uk": 84,
|
||||
"ur": 91,
|
||||
"ur": 90,
|
||||
"zh-Hans": 95,
|
||||
"zh-Hant": 86
|
||||
}
|
||||
|
||||
@@ -8962,6 +8962,22 @@ export function useAFFiNEI18N(): {
|
||||
* `Join Failed`
|
||||
*/
|
||||
["com.affine.fail-to-join-workspace.title"](): string;
|
||||
/**
|
||||
* `This invitation is for another account`
|
||||
*/
|
||||
["com.affine.invitation.account-mismatch.title"](): string;
|
||||
/**
|
||||
* `You're signed in with an account that wasn't invited. Sign in with the account that received this invitation to continue.`
|
||||
*/
|
||||
["com.affine.invitation.account-mismatch.description"](): string;
|
||||
/**
|
||||
* `Sign in with another account`
|
||||
*/
|
||||
["com.affine.invitation.account-mismatch.switch-account"](): string;
|
||||
/**
|
||||
* `Back to AFFiNE`
|
||||
*/
|
||||
["com.affine.invitation.account-mismatch.back-to-affine"](): string;
|
||||
/**
|
||||
* `Please contact your workspace owner to add more seats.`
|
||||
*/
|
||||
@@ -10174,6 +10190,10 @@ export function useAFFiNEI18N(): {
|
||||
* `Invalid invitation provided.`
|
||||
*/
|
||||
["error.INVALID_INVITATION"](): string;
|
||||
/**
|
||||
* `This invitation belongs to another account.`
|
||||
*/
|
||||
["error.INVITATION_ACCOUNT_MISMATCH"](): string;
|
||||
/**
|
||||
* `No more seat available in the Space {{spaceId}}.`
|
||||
*/
|
||||
|
||||
@@ -2234,6 +2234,10 @@
|
||||
"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.invitation.account-mismatch.title": "This invitation is for another account",
|
||||
"com.affine.invitation.account-mismatch.description": "You're signed in with an account that wasn't invited. Sign in with the account that received this invitation to continue.",
|
||||
"com.affine.invitation.account-mismatch.switch-account": "Sign in with another account",
|
||||
"com.affine.invitation.account-mismatch.back-to-affine": "Back to AFFiNE",
|
||||
"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.",
|
||||
"com.affine.request-to-join-workspace.button": "Request to join",
|
||||
@@ -2516,6 +2520,7 @@
|
||||
"error.CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS": "Can not batch grant doc owner permissions.",
|
||||
"error.NEW_OWNER_IS_NOT_ACTIVE_MEMBER": "Can not set a non-active member as owner.",
|
||||
"error.INVALID_INVITATION": "Invalid invitation provided.",
|
||||
"error.INVITATION_ACCOUNT_MISMATCH": "This invitation belongs to another account.",
|
||||
"error.NO_MORE_SEAT": "No more seat available in the Space {{spaceId}}.",
|
||||
"error.UNSUPPORTED_SUBSCRIPTION_PLAN": "Unsupported subscription plan: {{plan}}.",
|
||||
"error.FAILED_TO_CHECKOUT": "Failed to create checkout session.",
|
||||
|
||||
Reference in New Issue
Block a user