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:
DarkSky
2026-08-28 12:50:09 +08:00
committed by GitHub
parent 6fc5d82f1d
commit 4953682779
17 changed files with 223 additions and 33 deletions
@@ -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;
+1
View File
@@ -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
+1
View File
@@ -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';
@@ -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
}
+20
View File
@@ -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.",