diff --git a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts index bddc6d1e36..7400b66049 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts @@ -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: { diff --git a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts index d2b70c5aba..07b73ed7f8 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts @@ -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! ); diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 09cc9a47a6..5748d30ca9 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -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' }, diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index 78c0e1963c..8cdf0eec74 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -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, diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index 2addd5b8a9..15c46c24e8 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/member.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -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 { 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; diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index f521ee04ce..46ac693c08 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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 diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index f41146d597..0c3873413b 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -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', diff --git a/packages/frontend/component/src/components/member-components/index.tsx b/packages/frontend/component/src/components/member-components/index.tsx index 034ed6b9c0..8e767bd9ed 100644 --- a/packages/frontend/component/src/components/member-components/index.tsx +++ b/packages/frontend/component/src/components/member-components/index.tsx @@ -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'; diff --git a/packages/frontend/component/src/components/member-components/invitation-account-mismatch.tsx b/packages/frontend/component/src/components/member-components/invitation-account-mismatch.tsx new file mode 100644 index 0000000000..571ea083eb --- /dev/null +++ b/packages/frontend/component/src/components/member-components/invitation-account-mismatch.tsx @@ -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 ( + + {user ? ( +
+ + {user.email} +
+ ) : null} +
+ + +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/member-components/styles.css.ts b/packages/frontend/component/src/components/member-components/styles.css.ts index 3d4e597dad..ac92c229fa 100644 --- a/packages/frontend/component/src/components/member-components/styles.css.ts +++ b/packages/frontend/component/src/components/member-components/styles.css.ts @@ -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', }); diff --git a/packages/frontend/core/src/components/sign-in/index.tsx b/packages/frontend/core/src/components/sign-in/index.tsx index 005a7ef892..18d3a8f892 100644 --- a/packages/frontend/core/src/components/sign-in/index.tsx +++ b/packages/frontend/core/src/components/sign-in/index.tsx @@ -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({ step: initStep @@ -41,6 +43,7 @@ export const SignInPanel = ({ ? 'addSelfhosted' : 'signIn', initialServerBaseUrl: initialServerBaseUrl, + redirectUrl, }); const defaultServerService = useService(DefaultServerService); diff --git a/packages/frontend/core/src/desktop/pages/auth/sign-in.tsx b/packages/frontend/core/src/desktop/pages/auth/sign-in.tsx index ae0c91a86e..749c2b129b 100644 --- a/packages/frontend/core/src/desktop/pages/auth/sign-in.tsx +++ b/packages/frontend/core/src/desktop/pages/auth/sign-in.tsx @@ -70,6 +70,7 @@ export const SignIn = ({ onAuthenticated={handleAuthenticated} initStep={initStep} server={server} + redirectUrl={redirectUrl ?? undefined} /> diff --git a/packages/frontend/core/src/desktop/pages/invite/index.tsx b/packages/frontend/core/src/desktop/pages/invite/index.tsx index 54f80ec66e..4ed35571d5 100644 --- a/packages/frontend/core/src/desktop/pages/invite/index.tsx +++ b/packages/frontend/core/src/desktop/pages/invite/index.tsx @@ -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( 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 ( + + ); + } + + if ( + !inviteInfo && + !loading && + (!invitationError || + invitationError.is('INVALID_INVITATION') || + invitationError.is('NOT_FOUND')) + ) { return ; } - if (error || acceptError) { + if (invitationError || acceptError) { return ( - + ); } @@ -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(); diff --git a/packages/frontend/core/src/modules/cloud/services/invitation.ts b/packages/frontend/core/src/modules/cloud/services/invitation.ts index c767d3cd7c..a57106b48f 100644 --- a/packages/frontend/core/src/modules/cloud/services/invitation.ts +++ b/packages/frontend/core/src/modules/cloud/services/invitation.ts @@ -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( diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 07addfc60f..1d1f692411 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -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 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index b530cfbe2d..925d434c87 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -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}}.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index b0d14c8b53..c61ab0f73d 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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}} 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.",