Skip to content

fix(invitation): handle existing users - #4125

Open
endenis wants to merge 8 commits into
mainfrom
fix/BIL-534/invitations
Open

endenis wants to merge 8 commits into
mainfrom
fix/BIL-534/invitations

Conversation

@endenis

@endenis endenis commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

BIL-534

Context

The invitation page assumes the invited email has no Lago account. It logs the visitor out and always tries to create a new password.

Description

Requires lago-api#6146 to be deployed first: it queries existingUser and calls joinOrganization

  • Ask for the password of the existing account when the invited email already has one, without the password creation rules.
  • Let an authenticated invitee accept with the new joinOrganization mutation, and offer to log out when another user is logged in.
  • Stop logging visitors out when they open an invitation link.
  • Update the multi-org e2e spec to the new flow.
  • Regenerate the GraphQL types for the updated invitation API.

@endenis endenis self-assigned this Aug 13, 2026
@endenis
endenis marked this pull request as ready for review August 13, 2026 17:43
@ansmonjol

Copy link
Copy Markdown
Contributor

Self-review notes:

  • Okta / Entra ID acceptance still lands on the last-used org, not the invited one. oktaAcceptInvite/entraIdAcceptInvite return LoginUser (no organization), so onAccepted falls back to HOME_ROUTE -> RootRedirect, which prefers the persisted slug. Fix: select slug under invite { organization } in getinvite and pass invite?.organization.slug.
  • Skeleton renders stacked above the card during join. useCurrentUser has notifyOnNetworkStatusChange: true, so refetchCurrentUserInfos() sets currentUserLoading while currentUser is still populated, and both the skeleton and the content branches match. Gate the skeleton on !mode.
  • LoginMethodNotAuthorized in join mode is a dead end. The message says to use an authorized method, but the log-out button only renders for emailMismatch.
  • Typed refetchCurrentUserInfos as Apollo's real refetch signature. Fixes the Sonar "await of a non-promise" issue and pins the contract the join flow relies on (the refreshed memberships must land before navigate(/${slug})).
  • /invitation is now duplicated in the Cypress PUBLIC_PATHS list, and the comment above it is stale.

@endenis

endenis commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Self-review notes:

  • Okta / Entra ID acceptance still lands on the last-used org, not the invited one. oktaAcceptInvite/entraIdAcceptInvite return LoginUser (no organization), so onAccepted falls back to HOME_ROUTE -> RootRedirect, which prefers the persisted slug. Fix: select slug under invite { organization } in getinvite and pass invite?.organization.slug.
  • Skeleton renders stacked above the card during join. useCurrentUser has notifyOnNetworkStatusChange: true, so refetchCurrentUserInfos() sets currentUserLoading while currentUser is still populated, and both the skeleton and the content branches match. Gate the skeleton on !mode.
  • LoginMethodNotAuthorized in join mode is a dead end. The message says to use an authorized method, but the log-out button only renders for emailMismatch.
  • Typed refetchCurrentUserInfos as Apollo's real refetch signature. Fixes the Sonar "await of a non-promise" issue and pins the contract the join flow relies on (the refreshed memberships must land before navigate(/${slug})).
  • /invitation is now duplicated in the Cypress PUBLIC_PATHS list, and the comment above it is stale.

@ansmonjol Thank you, I addressed these points

@endenis
endenis requested a review from ansmonjol August 19, 2026 10:27
@sonarqubecloud

Copy link
Copy Markdown

Comment thread src/pages/Invitation.tsx
<Typography variant="headline">
{translate('text_664c90c9b2b6c2012aa50bcd', {
orgnisationName: data?.invite?.organization.name,
{!error &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During onLogOut -> refetchInvite() the card renders empty apart from the logo: mode is still defined (data survives logOut - client.stop() cancels the cache watch before resetPersistedCache, so the hook keeps its last result), which blocks the skeleton, and loading is true, which blocks the content. The notifyOnNetworkStatusChange comment on line 146 assumes the opposite of what happens.

The three branches here are gated by three independent expressions that are neither exhaustive nor mutually exclusive. Would a single render helper be safer?

const isResolving = !!loading || (isAuthenticated && (!!currentUserLoading || !currentUser))

const renderContent = () => {
  if (isResolving) return skeletons
  if (!!error || !invite || !mode) return notFoundContent

  return inviteContent
}

should log out and refetch the invitation does not catch this because logOut is mocked, so data is never in the intermediate state.

The same helper would also cover isAuthenticated === true with currentUser never resolving (a non-auth failure of getCurrentUserInfos, which useCurrentUser swallows since it does not expose error): today mode stays undefined and the skeleton never goes away.

Comment thread src/pages/Invitation.tsx

{errorAlert}

{mode === 'join' && !joinLoginMethodNotAuthorized && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

joinOrganization can also fail with InviteEmailMistmatch and EmailAlreadyUsed (both handled in errorTranslation above), but only LoginMethodNotAuthorized swaps the "Accept invitation" button for "Log out". On the other two the user keeps a button that can only fail again, with no way out of the page and no way into the org.

Since all three are terminal for this session, could we widen the flag rather than special-casing one code?

const JOIN_BLOCKING_ERROR_CODES = [
  'LoginMethodNotAuthorized',
  'InviteEmailMistmatch',
  'EmailAlreadyUsed',
] as const

const isJoinBlocked = JOIN_BLOCKING_ERROR_CODES.some((code) =>
  hasDefinedGQLError(code, joinOrganizationError),
)

and then use isJoinBlocked both here and on the log-out button's condition below. The per-code messages stay as they are.

Comment thread src/pages/Invitation.tsx
return translate('text_620bc4d4269a55014d493fb7')
}

if (hasDefinedGQLError('EmailAlreadyUsed', acceptInviteError)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text_1786557508910guitmzid55q is "You are already a member of this organization." - true for the joinOrganization case below, but not here: on the sign-up path EmailAlreadyUsed means an account now exists for the invited email, which the test name (should explain when an account was created after the invitation was loaded) states correctly. As written the user is told something false and left on a sign-up form that cannot succeed.

Worth a dedicated key, plus an actual recovery - refetchInvite() in onError would return existingUser: true, flipping mode to logInRequired and swapping in the log-in form with no action from the user:

onError: (mutationError) => {
  if (hasDefinedGQLError('EmailAlreadyUsed', mutationError)) {
    refetchInvite().catch(() => undefined)
  }
},

Comment thread src/pages/Invitation.tsx

// Logging out clears the Apollo store without refetching the active queries, so the invite has
// to be queried again to render the logged out flow.
const onLogOut = async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: refetchInvite() here can reject and nothing catches it, while onJoinOrganization does .catch(() => undefined). await refetchInvite().catch(() => undefined) would keep the two consistent - the hook's own error state still drives the fallback card. onSubmitPassword has the same shape but predates this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants