From c35f81483e4896fac51e7be2047a61e67a6b821b Mon Sep 17 00:00:00 2001 From: endenis Date: Thu, 13 Aug 2026 18:41:00 +0200 Subject: [PATCH 1/6] fix(invitation): handle existing users --- .../e2e/00-auth/t30-multi-org-redirect.cy.ts | 12 +- .../activityLogs/ActivityLogDetails.tsx | 3 + src/core/router/AuthRoutes.tsx | 1 - src/core/router/slugPrefixes.ts | 8 +- src/core/router/types.ts | 1 - src/generated/graphql.tsx | 238 ++++++++++- src/hooks/core/useLocationHistory.ts | 5 - src/pages/Invitation.tsx | 396 +++++++++++------- src/pages/InvitationInit.tsx | 22 +- src/pages/__tests__/Invitation.test.tsx | 169 +++++++- .../invitationForm/InvitationLogInForm.tsx | 74 ++++ .../invitationForm/InvitationSignUpForm.tsx | 82 ++++ .../__tests__/validationSchema.test.ts | 133 +----- src/pages/invitationForm/types.ts | 7 + src/pages/invitationForm/validationSchema.ts | 10 +- translations/base.json | 11 +- 16 files changed, 869 insertions(+), 303 deletions(-) create mode 100644 src/pages/invitationForm/InvitationLogInForm.tsx create mode 100644 src/pages/invitationForm/InvitationSignUpForm.tsx create mode 100644 src/pages/invitationForm/types.ts diff --git a/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts b/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts index 7d9cceaa11..2696f99d79 100644 --- a/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts +++ b/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts @@ -75,16 +75,20 @@ describe('Multi-organization redirect flows', () => { invitationUrl = $link.attr('href') || $link.text().trim() cy.log('Invitation URL captured:', invitationUrl) - // Force a full page navigation by visiting the URL - // This will clear the session and disconnect User B + // Force a full page navigation by visiting the URL. + // The invitation page keeps the session: User B is still logged in here. cy.visit(invitationUrl, { failOnStatusCode: false }) }) - // 5. User A accepts the invitation to Org2 - enter the same password to avoid DB issues + // 5. The invitation targets User A, so User B has to log out first cy.url().should('include', '/invitation/') + cy.get('[data-test="log-out-button"]', { timeout: 10000 }).click() + + // 6. User A already has an account: accepting requires the password of that account, which is + // what proves the acceptor owns it cy.get('input[name="password"]', { timeout: 10000 }).should('be.visible') cy.get('input[name="password"]').type(testUsers.userA.password) - cy.get('[data-test="submit-button"]').click() + cy.get('[data-test="log-in-button"]').click() // User A should now have access to both organizations cy.url().should('match', /\/(analytics|customers)/) diff --git a/src/components/developers/activityLogs/ActivityLogDetails.tsx b/src/components/developers/activityLogs/ActivityLogDetails.tsx index e84df1331d..575a532d09 100644 --- a/src/components/developers/activityLogs/ActivityLogDetails.tsx +++ b/src/components/developers/activityLogs/ActivityLogDetails.tsx @@ -96,6 +96,9 @@ gql` ... on ProductFilter { id } + ... on RateCard { + id + } ... on PaymentRequest { id } diff --git a/src/core/router/AuthRoutes.tsx b/src/core/router/AuthRoutes.tsx index 4e74769a7c..57407b8b05 100644 --- a/src/core/router/AuthRoutes.tsx +++ b/src/core/router/AuthRoutes.tsx @@ -90,6 +90,5 @@ export const authRoutes: CustomRouteObject[] = [ { path: INVITATION_ROUTE_FORM, element: , - invitation: true, }, ] diff --git a/src/core/router/slugPrefixes.ts b/src/core/router/slugPrefixes.ts index 1b237d565f..9ed1564e39 100644 --- a/src/core/router/slugPrefixes.ts +++ b/src/core/router/slugPrefixes.ts @@ -9,4 +9,10 @@ * Logout goes through `logOut(client)` in cacheUtils (never `navigate`), * so no logout route is listed. */ -export const NEVER_SLUG_PREFIXES = ['/customer-portal', '/forbidden', '/404', '/login'] +export const NEVER_SLUG_PREFIXES = [ + '/customer-portal', + '/forbidden', + '/404', + '/login', + '/invitation', +] diff --git a/src/core/router/types.ts b/src/core/router/types.ts index 81cfbc3d19..e247dcb186 100644 --- a/src/core/router/types.ts +++ b/src/core/router/types.ts @@ -7,7 +7,6 @@ export interface CustomRouteObject extends Omit // AND logic (all must be true) diff --git a/src/generated/graphql.tsx b/src/generated/graphql.tsx index e16487dd53..6768135f7f 100644 --- a/src/generated/graphql.tsx +++ b/src/generated/graphql.tsx @@ -15,7 +15,10 @@ export type Scalars = { Boolean: { input: boolean; output: boolean; } Int: { input: number; output: number; } Float: { input: number; output: number; } - /** Represents non-fractional signed whole numeric values. Since the value may exceed the size of a 32-bit integer, it's encoded as a string. */ + /** + * Represents non-fractional signed whole numeric values. Since the value may + * exceed the size of a 32-bit integer, it's encoded as a string. + */ BigInt: { input: any; output: any; } ChargeFilterValues: { input: any; output: any; } /** Api Logs HTTP status */ @@ -33,7 +36,8 @@ export type Scalars = { export type AcceptInviteInput = { /** A unique identifier for the client performing the mutation. */ clientMutationId?: InputMaybe; - email: Scalars['String']['input']; + /** @deprecated The email is resolved from the invitation token. */ + email?: InputMaybe; password: Scalars['String']['input']; /** Uniq token of the Invite */ token: Scalars['String']['input']; @@ -81,7 +85,7 @@ export type ActivityLogCollection = { }; /** Activity log resource */ -export type ActivityLogResourceObject = BillableMetric | BillingEntity | Coupon | CreditNote | Customer | FeatureObject | Invoice | PaymentReceipt | PaymentRequest | Plan | Product | ProductCategory | ProductFilter | Subscription | Wallet; +export type ActivityLogResourceObject = BillableMetric | BillingEntity | Coupon | CreditNote | Customer | FeatureObject | Invoice | PaymentReceipt | PaymentRequest | Plan | Product | ProductCategory | ProductFilter | RateCard | Subscription | Wallet; /** Activity Logs source enums */ export enum ActivitySourceEnum { @@ -2241,6 +2245,25 @@ export type CreateQuoteInput = { subscriptionId?: InputMaybe; }; +/** Create rate card input arguments */ +export type CreateRateCardInput = { + appliedPricingUnitCode?: InputMaybe; + billingTiming?: InputMaybe; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + code: Scalars['String']['input']; + currency: CurrencyEnum; + description?: InputMaybe; + displayOnInvoice?: InputMaybe; + name: Scalars['String']['input']; + productFilterId?: InputMaybe; + productId: Scalars['ID']['input']; + proration?: InputMaybe; + rates?: InputMaybe>; + regroupPaidFees?: InputMaybe; + walletTargetable?: InputMaybe; +}; + /** Create rate card rate input arguments */ export type CreateRateCardRateInput = { appliedPricingUnitConversionRate?: InputMaybe; @@ -3816,6 +3839,21 @@ export type DestroyProductPayload = { id?: Maybe; }; +/** Autogenerated input type of DestroyRateCard */ +export type DestroyRateCardInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + id: Scalars['ID']['input']; +}; + +/** Autogenerated return type of DestroyRateCard. */ +export type DestroyRateCardPayload = { + __typename?: 'DestroyRateCardPayload'; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: Maybe; + id?: Maybe; +}; + /** Autogenerated input type of DestroyRateCardRate */ export type DestroyRateCardRateInput = { /** A unique identifier for the client performing the mutation. */ @@ -4837,6 +4875,7 @@ export type Invite = { __typename?: 'Invite'; acceptedAt?: Maybe; email: Scalars['String']['output']; + existingUser: Scalars['Boolean']['output']; id: Scalars['ID']['output']; organization: Organization; recipient: Membership; @@ -5108,6 +5147,14 @@ export type ItemMetadata = { value?: Maybe; }; +/** Autogenerated input type of JoinOrganization */ +export type JoinOrganizationInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** Unique token of the Invite */ + token: Scalars['String']['input']; +}; + export enum LagoApiError { AddressLocationNotFound = 'AddressLocationNotFound', EntityNotFoundError = 'EntityNotFoundError', @@ -5490,6 +5537,8 @@ export type Mutation = { createProductFilter?: Maybe; /** Create a new quote */ createQuote?: Maybe; + /** Creates a new rate card */ + createRateCard?: Maybe; /** Adds a rate to a rate card */ createRateCardRate?: Maybe; /** Creates a new custom role */ @@ -5560,6 +5609,8 @@ export type Mutation = { destroyProductCategory?: Maybe; /** Deletes a product filter */ destroyProductFilter?: Maybe; + /** Deletes a rate card */ + destroyRateCard?: Maybe; /** Deletes a pending rate of a rate card */ destroyRateCardRate?: Maybe; /** Deletes a custom role */ @@ -5616,6 +5667,8 @@ export type Mutation = { googleLoginUser?: Maybe; /** Register a new user with Google Oauth */ googleRegisterUser?: Maybe; + /** Joins the organization of an Invite as the authenticated user */ + joinOrganization?: Maybe; /** Opens a session for an existing user */ loginUser?: Maybe; /** Mark payment dispute as lost */ @@ -5772,6 +5825,8 @@ export type Mutation = { updateQuote?: Maybe; /** Update a quote version */ updateQuoteVersion?: Maybe; + /** Updates an existing rate card */ + updateRateCard?: Maybe; /** Updates a rate of a rate card */ updateRateCardRate?: Maybe; /** Updates an existing custom role */ @@ -6097,6 +6152,11 @@ export type MutationCreateQuoteArgs = { }; +export type MutationCreateRateCardArgs = { + input: CreateRateCardInput; +}; + + export type MutationCreateRateCardRateArgs = { input: CreateRateCardRateInput; }; @@ -6272,6 +6332,11 @@ export type MutationDestroyProductFilterArgs = { }; +export type MutationDestroyRateCardArgs = { + input: DestroyRateCardInput; +}; + + export type MutationDestroyRateCardRateArgs = { input: DestroyRateCardRateInput; }; @@ -6417,6 +6482,11 @@ export type MutationGoogleRegisterUserArgs = { }; +export type MutationJoinOrganizationArgs = { + input: JoinOrganizationInput; +}; + + export type MutationLoginUserArgs = { input: LoginUserInput; }; @@ -6817,6 +6887,11 @@ export type MutationUpdateQuoteVersionArgs = { }; +export type MutationUpdateRateCardArgs = { + input: UpdateRateCardInput; +}; + + export type MutationUpdateRateCardRateArgs = { input: UpdateRateCardRateInput; }; @@ -8199,8 +8274,12 @@ export type Query = { quoteVersion?: Maybe; /** Query quotes of an organization */ quotes: QuoteCollection; + /** Query a single rate card of an organization */ + rateCard?: Maybe; /** Query the rates of a rate card */ rateCardRates: RateCardRateCollection; + /** Query rate cards of an organization */ + rateCards: RateCardCollection; /** Query a single role */ role?: Maybe; /** Query roles available for the organization */ @@ -9032,6 +9111,11 @@ export type QueryQuotesArgs = { }; +export type QueryRateCardArgs = { + id: Scalars['ID']['input']; +}; + + export type QueryRateCardRatesArgs = { limit?: InputMaybe; page?: InputMaybe; @@ -9039,6 +9123,18 @@ export type QueryRateCardRatesArgs = { }; +export type QueryRateCardsArgs = { + code?: InputMaybe; + limit?: InputMaybe; + page?: InputMaybe; + productCode?: InputMaybe; + productFilterCode?: InputMaybe; + productFilterId?: InputMaybe; + productId?: InputMaybe; + searchTerm?: InputMaybe; +}; + + export type QueryRoleArgs = { id: Scalars['ID']['input']; }; @@ -9262,6 +9358,45 @@ export type QuoteVersion = { voidedAt?: Maybe; }; +/** Base rate card */ +export type RateCard = { + __typename?: 'RateCard'; + activeRate?: Maybe; + appliedPricingUnitCode?: Maybe; + attachedToPlanOrSubscription: Scalars['Boolean']['output']; + attachedToSubscriptions: Scalars['Boolean']['output']; + billingTiming: RateCardBillingTimingEnum; + code: Scalars['String']['output']; + createdAt: Scalars['ISO8601DateTime']['output']; + currency: CurrencyEnum; + description?: Maybe; + displayOnInvoice: Scalars['Boolean']['output']; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; + organization?: Maybe; + product: Product; + productFilter?: Maybe; + proration: Scalars['Boolean']['output']; + ratesCount: Scalars['Int']['output']; + regroupPaidFees: RateCardRegroupPaidFeesEnum; + updatedAt: Scalars['ISO8601DateTime']['output']; + walletTargetable?: Maybe; +}; + +export enum RateCardBillingTimingEnum { + Advance = 'advance', + Arrears = 'arrears' +} + +/** RateCardCollection type */ +export type RateCardCollection = { + __typename?: 'RateCardCollection'; + /** A collection of paginated RateCardCollection */ + collection: Array; + /** Pagination Metadata for navigating the Pagination */ + metadata: CollectionMetadata; +}; + /** An effective-dated pricing entry of a rate card */ export type RateCardRate = { __typename?: 'RateCardRate'; @@ -9295,6 +9430,18 @@ export type RateCardRateCollection = { metadata: CollectionMetadata; }; +/** Rate card rate input arguments */ +export type RateCardRateInput = { + appliedPricingUnitConversionRate?: InputMaybe; + billingIntervalCount?: InputMaybe; + billingIntervalUnit: RateCardRateBillingIntervalUnitEnum; + code: Scalars['String']['input']; + effectiveFrom: Scalars['ISO8601DateTime']['input']; + minAmountCents?: InputMaybe; + rateModel: RateCardRateModelEnum; + rateProperties: Scalars['JSON']['input']; +}; + export enum RateCardRateModelEnum { Custom = 'custom', Dynamic = 'dynamic', @@ -9312,6 +9459,11 @@ export enum RateCardRateStatusEnum { Terminated = 'terminated' } +export enum RateCardRegroupPaidFeesEnum { + Invoice = 'invoice', + None = 'none' +} + export enum RecurringTransactionIntervalEnum { Monthly = 'monthly', Quarterly = 'quarterly', @@ -10955,6 +11107,22 @@ export type UpdateQuoteVersionInput = { startDate?: InputMaybe; }; +/** Update rate card input arguments */ +export type UpdateRateCardInput = { + appliedPricingUnitCode?: InputMaybe; + billingTiming?: InputMaybe; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + currency?: InputMaybe; + description?: InputMaybe; + displayOnInvoice?: InputMaybe; + id: Scalars['ID']['input']; + name?: InputMaybe; + proration?: InputMaybe; + regroupPaidFees?: InputMaybe; + walletTargetable?: InputMaybe; +}; + /** Update rate card rate input arguments */ export type UpdateRateCardRateInput = { appliedPricingUnitConversionRate?: InputMaybe; @@ -12333,6 +12501,7 @@ export type ActivityLogDetailsFragment = { __typename?: 'ActivityLog', activityT | { __typename?: 'Product', id: string } | { __typename?: 'ProductCategory', id: string } | { __typename?: 'ProductFilter', id: string } + | { __typename?: 'RateCard', id: string } | { __typename?: 'Subscription', id: string } | { __typename?: 'Wallet', id: string, walletCustomer?: { __typename?: 'Customer', id: string } | null } | null }; @@ -12356,6 +12525,7 @@ export type GetSingleActivityLogQuery = { __typename?: 'Query', activityLog?: { | { __typename?: 'Product', id: string } | { __typename?: 'ProductCategory', id: string } | { __typename?: 'ProductFilter', id: string } + | { __typename?: 'RateCard', id: string } | { __typename?: 'Subscription', id: string } | { __typename?: 'Wallet', id: string, walletCustomer?: { __typename?: 'Customer', id: string } | null } | null } | null }; @@ -14811,21 +14981,28 @@ export type GetinviteQueryVariables = Exact<{ }>; -export type GetinviteQuery = { __typename?: 'Query', invite?: { __typename?: 'Invite', id: string, email: string, organization: { __typename?: 'Organization', id: string, name: string } } | null }; +export type GetinviteQuery = { __typename?: 'Query', invite?: { __typename?: 'Invite', id: string, email: string, existingUser: boolean, organization: { __typename?: 'Organization', id: string, name: string } } | null }; export type AcceptInviteMutationVariables = Exact<{ input: AcceptInviteInput; }>; -export type AcceptInviteMutation = { __typename?: 'Mutation', acceptInvite?: { __typename?: 'RegisterUser', token: string } | null }; +export type AcceptInviteMutation = { __typename?: 'Mutation', acceptInvite?: { __typename?: 'RegisterUser', token: string, organization: { __typename?: 'Organization', id: string, slug: string } } | null }; + +export type JoinOrganizationMutationVariables = Exact<{ + input: JoinOrganizationInput; +}>; + + +export type JoinOrganizationMutation = { __typename?: 'Mutation', joinOrganization?: { __typename?: 'Membership', id: string, organization: { __typename?: 'Organization', id: string, slug: string } } | null }; export type GoogleAcceptInviteMutationVariables = Exact<{ input: GoogleAcceptInviteInput; }>; -export type GoogleAcceptInviteMutation = { __typename?: 'Mutation', googleAcceptInvite?: { __typename?: 'RegisterUser', token: string } | null }; +export type GoogleAcceptInviteMutation = { __typename?: 'Mutation', googleAcceptInvite?: { __typename?: 'RegisterUser', token: string, organization: { __typename?: 'Organization', id: string, slug: string } } | null }; export type FetchOktaAuthorizeUrlMutationVariables = Exact<{ input: OktaAuthorizeInput; @@ -17773,6 +17950,9 @@ export const ActivityLogDetailsFragmentDoc = gql` ... on ProductFilter { id } + ... on RateCard { + id + } ... on PaymentRequest { id } @@ -37719,6 +37899,7 @@ export const GetinviteDocument = gql` invite(token: $token) { id email + existingUser organization { id name @@ -37766,6 +37947,10 @@ export const AcceptInviteDocument = gql` mutation acceptInvite($input: AcceptInviteInput!) { acceptInvite(input: $input) { token + organization { + id + slug + } } } `; @@ -37795,10 +37980,51 @@ export function useAcceptInviteMutation(baseOptions?: Apollo.MutationHookOptions export type AcceptInviteMutationHookResult = ReturnType; export type AcceptInviteMutationResult = Apollo.MutationResult; export type AcceptInviteMutationOptions = Apollo.BaseMutationOptions; +export const JoinOrganizationDocument = gql` + mutation joinOrganization($input: JoinOrganizationInput!) { + joinOrganization(input: $input) { + id + organization { + id + slug + } + } +} + `; +export type JoinOrganizationMutationFn = Apollo.MutationFunction; + +/** + * __useJoinOrganizationMutation__ + * + * To run a mutation, you first call `useJoinOrganizationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useJoinOrganizationMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [joinOrganizationMutation, { data, loading, error }] = useJoinOrganizationMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useJoinOrganizationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(JoinOrganizationDocument, options); + } +export type JoinOrganizationMutationHookResult = ReturnType; +export type JoinOrganizationMutationResult = Apollo.MutationResult; +export type JoinOrganizationMutationOptions = Apollo.BaseMutationOptions; export const GoogleAcceptInviteDocument = gql` mutation googleAcceptInvite($input: GoogleAcceptInviteInput!) { googleAcceptInvite(input: $input) { token + organization { + id + slug + } } } `; diff --git a/src/hooks/core/useLocationHistory.ts b/src/hooks/core/useLocationHistory.ts index 232cda2337..fa45e32342 100644 --- a/src/hooks/core/useLocationHistory.ts +++ b/src/hooks/core/useLocationHistory.ts @@ -177,11 +177,6 @@ export const useLocationHistory: UseLocationHistoryReturn = () => { } else if (isAuthenticated && !isCurrentUserLoading) { handleAuthenticatedRouteEnter(routeConfig, location) } else if (!routeConfig?.children && !routeConfig.onlyPublic) { - // In the invitation for page, once users are logged in, we redirect them to the home page - if (routeConfig.invitation && isAuthenticated) { - // We can then safely redirect to the home page. - navigate(HOME_ROUTE) - } /** * We add the current location to the history only if : * - Current route has no children (to avoid adding Layout route which will result in duplicates) diff --git a/src/pages/Invitation.tsx b/src/pages/Invitation.tsx index 36c5b84a92..ec5ec8a2dd 100644 --- a/src/pages/Invitation.tsx +++ b/src/pages/Invitation.tsx @@ -1,6 +1,5 @@ import { gql, useApolloClient } from '@apollo/client' import Stack from '@mui/material/Stack' -import { revalidateLogic, useStore } from '@tanstack/react-form' import { useEffect, useMemo } from 'react' import { useParams, useSearchParams } from 'react-router-dom' @@ -9,13 +8,10 @@ import { Alert } from '~/components/designSystem/Alert' import { Button } from '~/components/designSystem/Button' import { Skeleton } from '~/components/designSystem/Skeleton' import { Typography } from '~/components/designSystem/Typography' -import { PasswordValidationHints } from '~/components/form/PasswordValidationHints/PasswordValidationHints' -import { TextInput } from '~/components/form/TextInput' -import { hasDefinedGQLError, onLogIn } from '~/core/apolloClient' +import { hasDefinedGQLError, logOut, onLogIn } from '~/core/apolloClient' import { DOCUMENTATION_ENV_VARS } from '~/core/constants/externalUrls' -import { LOGIN_ROUTE } from '~/core/router' +import { HOME_ROUTE, LOGIN_ROUTE, useNavigate } from '~/core/router' import { addValuesToUrlState } from '~/core/utils/urlUtils' -import { PASSWORD_VALIDATION_ERRORS } from '~/formValidation/zodCustoms' import { CurrentUserFragmentDoc, LagoApiError, @@ -25,29 +21,40 @@ import { useFetchOktaAuthorizeUrlMutation, useGetinviteQuery, useGoogleAcceptInviteMutation, + useJoinOrganizationMutation, useOktaAcceptInviteMutation, } from '~/generated/graphql' import { useIsAuthenticated } from '~/hooks/auth/useIsAuthenticated' import { useInternationalization } from '~/hooks/core/useInternationalization' -import { useAppForm } from '~/hooks/forms/useAppform' -import { usePasswordValidation } from '~/hooks/forms/usePasswordValidation' +import { useCurrentUser } from '~/hooks/useCurrentUser' import MicrosoftEntraId from '~/public/images/microsoft-entra-id.svg' import { Card, Page, StyledLogo, Subtitle, Title } from '~/styles/auth' -import { - invitationDefaultValues, - invitationValidationSchema, -} from './invitationForm/validationSchema' +import { InvitationLogInForm } from './invitationForm/InvitationLogInForm' +import { InvitationSignUpForm } from './invitationForm/InvitationSignUpForm' export const INVITATION_FORM_ID = 'invitation-form' export const INVITATION_ERROR_ALERT_TEST_ID = 'invitation-error-alert' export const INVITATION_SUBMIT_BUTTON_TEST_ID = 'submit-button' +export const INVITATION_JOIN_BUTTON_TEST_ID = 'join-button' +export const INVITATION_LOG_IN_BUTTON_TEST_ID = 'log-in-button' +export const INVITATION_LOG_OUT_BUTTON_TEST_ID = 'log-out-button' + +/** + * How the invitation can be accepted: + * - `signUp`: the invited email has no account, the password is created. + * - `logInRequired`: the invited email has an account, its password is required. + * - `join`: the invited user is logged in, only the membership is added. + * - `emailMismatch`: another user is logged in. + */ +type InvitationMode = 'signUp' | 'logInRequired' | 'join' | 'emailMismatch' gql` query getinvite($token: String!) { invite(token: $token) { id email + existingUser organization { id name @@ -58,12 +65,30 @@ gql` mutation acceptInvite($input: AcceptInviteInput!) { acceptInvite(input: $input) { token + organization { + id + slug + } + } + } + + mutation joinOrganization($input: JoinOrganizationInput!) { + joinOrganization(input: $input) { + id + organization { + id + slug + } } } mutation googleAcceptInvite($input: GoogleAcceptInviteInput!) { googleAcceptInvite(input: $input) { token + organization { + id + slug + } } } @@ -96,9 +121,11 @@ gql` const Invitation = () => { const { isAuthenticated } = useIsAuthenticated() + const { currentUser, loading: currentUserLoading, refetchCurrentUserInfos } = useCurrentUser() const { translate } = useInternationalization() const { token } = useParams() const client = useApolloClient() + const navigate = useNavigate() const [searchParams] = useSearchParams() const googleCode = searchParams.get('code') || '' @@ -107,28 +134,80 @@ const Invitation = () => { const entraIdCode = searchParams.get('entraIdCode') || '' const entraIdState = searchParams.get('entraIdState') || '' - const { data, error, loading } = useGetinviteQuery({ + const { + data, + error, + loading, + refetch: refetchInvite, + } = useGetinviteQuery({ context: { silentErrorCodes: [LagoApiError.InviteNotFound, LagoApiError.NotFound] }, variables: { token: token || '' }, - skip: !token || isAuthenticated, // We need to skip when authenticated to prevent an error flash on the form after submit + // Keeps the skeleton visible while the invite is fetched again after a log out. + notifyOnNetworkStatusChange: true, + skip: !token, }) - const email = data?.invite?.email + const invite = data?.invite + const email = invite?.email + + const mode: InvitationMode | undefined = useMemo(() => { + if (!invite) return undefined + + if (isAuthenticated) { + if (!currentUser) return undefined + + // Emails are not downcased on write, hence the case insensitive comparison. + const isInvitedUser = currentUser.email?.toLowerCase() === invite.email.toLowerCase() + + return isInvitedUser ? 'join' : 'emailMismatch' + } + + return invite.existingUser ? 'logInRequired' : 'signUp' + }, [invite, isAuthenticated, currentUser]) + + // Land on the organization of the invitation. Without it the home page would resolve the last + // used organization, which is not the one that was just joined. + const onAccepted = async (userToken: string, slug?: string) => { + await onLogIn(client, userToken) + navigate(slug ? `/${slug}` : HOME_ROUTE, { replace: true, skipSlugPrepend: true }) + } + + // 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 () => { + await logOut(client, true) + await refetchInvite() + } const [acceptInvite, { error: acceptInviteError, loading: acceptInviteLoading }] = useAcceptInviteMutation({ context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, onCompleted: async (res) => { if (!!res?.acceptInvite) { - await onLogIn(client, res?.acceptInvite.token) + await onAccepted(res.acceptInvite.token, res.acceptInvite.organization.slug) } }, }) + const [joinOrganization, { error: joinOrganizationError, loading: joinOrganizationLoading }] = + useJoinOrganizationMutation({ + context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, + onCompleted: async (res) => { + const slug = res?.joinOrganization?.organization.slug + + if (!slug) return + + // The mutation cannot return the permissions and the organization details the cached + // current user holds, so the memberships are reloaded instead of written to the cache. + await refetchCurrentUserInfos() + navigate(`/${slug}`, { replace: true, skipSlugPrepend: true }) + }, + }) + const [googleAcceptInvite, { error: googleAcceptInviteError }] = useGoogleAcceptInviteMutation({ context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, onCompleted: async (res) => { if (!!res?.googleAcceptInvite) { - await onLogIn(client, res?.googleAcceptInvite.token) + await onAccepted(res.googleAcceptInvite.token, res.googleAcceptInvite.organization.slug) } }, }) @@ -146,7 +225,7 @@ const Invitation = () => { context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, onCompleted: async (res) => { if (!!res?.oktaAcceptInvite) { - await onLogIn(client, res?.oktaAcceptInvite.token) + await onAccepted(res.oktaAcceptInvite.token) } }, }) @@ -166,32 +245,23 @@ const Invitation = () => { context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, onCompleted: async (res) => { if (res?.entraIdAcceptInvite) { - await onLogIn(client, res?.entraIdAcceptInvite.token) + await onAccepted(res.entraIdAcceptInvite.token) } }, }) - const form = useAppForm({ - defaultValues: invitationDefaultValues, - validationLogic: revalidateLogic(), - validators: { - onDynamic: invitationValidationSchema, - }, - onSubmit: async ({ value }) => { - await acceptInvite({ - variables: { - input: { - token: token || '', - email: email || '', - password: value.password, - }, + // Both forms submit the same mutation. The API creates the password when the invited email has + // no account, and verifies it otherwise. + const onSubmitPassword = async (password: string) => { + await acceptInvite({ + variables: { + input: { + token: token || '', + password, }, - }) - }, - }) - - const password = useStore(form.store, (state) => state.values.password) - const passwordValidation = usePasswordValidation(password) + }, + }) + } const onOktaLogin = async () => { const { data: oktaAuthorizeData } = await fetchOktaAuthorizeUrl({ @@ -280,6 +350,7 @@ const Invitation = () => { const errorTranslation: string | undefined = useMemo(() => { if ( !acceptInviteError && + !joinOrganizationError && !googleAcceptInviteError && !oktaAcceptInviteError && !oktaAuthorizeUrlError && @@ -344,11 +415,33 @@ const Invitation = () => { }) } + // The password submitted for an invitation whose email already has an account did not match. + if (hasDefinedGQLError('IncorrectLoginOrPassword', acceptInviteError)) { + return translate('text_620bc4d4269a55014d493fb7') + } + + if (hasDefinedGQLError('EmailAlreadyUsed', acceptInviteError)) { + return translate('text_1786557508910guitmzid55q') + } + + if (hasDefinedGQLError('InviteEmailMistmatch', joinOrganizationError)) { + return translate('text_17865575089107lip4oupwdj') + } + + if (hasDefinedGQLError('EmailAlreadyUsed', joinOrganizationError)) { + return translate('text_1786557508910guitmzid55q') + } + + if (hasDefinedGQLError('LoginMethodNotAuthorized', joinOrganizationError)) { + return translate('text_1786557573982blvi6cjpnti') + } + return // eslint-disable-next-line react-hooks/exhaustive-deps }, [ acceptInviteError, + joinOrganizationError, googleAcceptInviteError, oktaAcceptInviteError, oktaAuthorizeUrlError, @@ -356,14 +449,43 @@ const Invitation = () => { entraIdAuthorizeUrlError, ]) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - form.handleSubmit() - } + const errorAlert = !!errorTranslation && ( + + + + ) - if (isAuthenticated) { - return null - } + const ssoButtons = ( + + + + + + + + ) return ( @@ -384,112 +506,98 @@ const Invitation = () => { )} - {!error && !!loading && ( + {!error && (!!loading || (isAuthenticated && (!!currentUserLoading || !currentUser))) && ( <> )} - {!error && !loading && !!data?.invite && ( -
- - - - {translate('text_664c90c9b2b6c2012aa50bcd', { - orgnisationName: data?.invite?.organization.name, + {!error && !loading && !!invite && !!mode && ( + + + + {translate('text_664c90c9b2b6c2012aa50bcd', { + orgnisationName: invite.organization.name, + })} + + + {mode === 'signUp' && translate('text_63246f875e2228ab7b63dcd4')} + {mode === 'logInRequired' && translate('text_1786557508910b6cacpc0pjt', { email })} + {mode === 'join' && + translate('text_17865575089107fdhugc24r9', { email: currentUser?.email })} + {mode === 'emailMismatch' && + translate('text_1786557508910jl708qczi4g', { + inviteEmail: email, + currentEmail: currentUser?.email, })} - - {translate('text_63246f875e2228ab7b63dcd4')} - - - {!!errorTranslation && ( - - - - )} - - - - - - - - - -
- - {translate('text_6303351deffd2a0d70498675').toUpperCase()} - -
- -
- - -
- - {(field) => ( - - )} - - -
-
- - - - {translate('text_63246f875e2228ab7b63dd1c')} - - - +
- + + {errorAlert} + + {mode === 'join' && ( + + )} + + {mode === 'emailMismatch' && ( + + )} + + {(mode === 'signUp' || mode === 'logInRequired') && ( + <> + {ssoButtons} + +
+ + {translate('text_6303351deffd2a0d70498675').toUpperCase()} + +
+ + {mode === 'signUp' ? ( + + ) : ( + + )} + + {mode === 'signUp' && ( + + )} + + )} +
)}
diff --git a/src/pages/InvitationInit.tsx b/src/pages/InvitationInit.tsx index 77884b4ff1..8657989a4d 100644 --- a/src/pages/InvitationInit.tsx +++ b/src/pages/InvitationInit.tsx @@ -1,28 +1,22 @@ -import { useApolloClient } from '@apollo/client' import { useEffect } from 'react' import { generatePath, Outlet, useParams } from 'react-router-dom' -import { logOut } from '~/core/apolloClient' import { INVITATION_ROUTE_FORM, useNavigate } from '~/core/router' -import { useIsAuthenticated } from '~/hooks/auth/useIsAuthenticated' +/** + * Forwards an invitation link to the invitation form. The visitor is not logged out: an + * authenticated invitee accepts the invitation without opening a new session. + */ const InvitationInit = () => { const { token } = useParams() - const { isAuthenticated } = useIsAuthenticated() const navigate = useNavigate() - const client = useApolloClient() useEffect(() => { - const triggerLogout = async () => { - await logOut(client, true) - } - - triggerLogout() - - // We first logout the user and then redirect to the invitation form - !isAuthenticated && navigate(generatePath(INVITATION_ROUTE_FORM, { token: token as string })) + navigate(generatePath(INVITATION_ROUTE_FORM, { token: token as string }), { + replace: true, + }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAuthenticated]) + }, [token]) return } diff --git a/src/pages/__tests__/Invitation.test.tsx b/src/pages/__tests__/Invitation.test.tsx index f176978829..4c720753d1 100644 --- a/src/pages/__tests__/Invitation.test.tsx +++ b/src/pages/__tests__/Invitation.test.tsx @@ -3,9 +3,14 @@ import { act, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { PASSWORD_HINTS_TEST_IDS } from '~/components/form/PasswordValidationHints/PasswordValidationHints' -import { GetinviteDocument } from '~/generated/graphql' +import { GetinviteDocument, JoinOrganizationDocument } from '~/generated/graphql' -import Invitation, { INVITATION_SUBMIT_BUTTON_TEST_ID } from '../Invitation' +import Invitation, { + INVITATION_JOIN_BUTTON_TEST_ID, + INVITATION_LOG_IN_BUTTON_TEST_ID, + INVITATION_LOG_OUT_BUTTON_TEST_ID, + INVITATION_SUBMIT_BUTTON_TEST_ID, +} from '../Invitation' const getByDataTest = (testId: string) => document.querySelector(`[data-test="${testId}"]`) @@ -21,6 +26,13 @@ jest.mock('~/hooks/auth/useIsAuthenticated', () => ({ useIsAuthenticated: () => mockIsAuthenticated(), })) +const mockCurrentUser = jest.fn() +const mockRefetchCurrentUserInfos = jest.fn() + +jest.mock('~/hooks/useCurrentUser', () => ({ + useCurrentUser: () => mockCurrentUser(), +})) + jest.mock('~/components/auth/GoogleAuthButton', () => ({ __esModule: true, default: ({ label }: { label: string }) => ( @@ -116,6 +128,7 @@ const createInviteMock = ( token?: string email?: string organizationName?: string + existingUser?: boolean error?: boolean } = {}, ): MockedResponse => { @@ -123,6 +136,7 @@ const createInviteMock = ( token = 'test-token', email = 'test@example.com', organizationName = 'Test Org', + existingUser = false, error = false, } = overrides @@ -146,6 +160,7 @@ const createInviteMock = ( invite: { id: 'invite-1', email, + existingUser, organization: { id: 'org-1', name: organizationName, @@ -156,6 +171,30 @@ const createInviteMock = ( } } +const createJoinOrganizationMock = ( + overrides: { token?: string; slug?: string } = {}, +): MockedResponse => { + const { token = 'test-token', slug = 'test-org' } = overrides + + return { + request: { + query: JoinOrganizationDocument, + variables: { input: { token } }, + }, + result: { + data: { + joinOrganization: { + id: 'membership-1', + organization: { + id: 'org-1', + slug, + }, + }, + }, + }, + } +} + const renderInvitation = async ( mocks: MockedResponse[] = [createInviteMock()], token = 'test-token', @@ -182,6 +221,11 @@ describe('Invitation', () => { jest.clearAllMocks() setupMockUseStore('', true) mockIsAuthenticated.mockReturnValue({ isAuthenticated: false }) + mockCurrentUser.mockReturnValue({ + currentUser: undefined, + loading: false, + refetchCurrentUserInfos: mockRefetchCurrentUserInfos, + }) mockPasswordValidation.mockReturnValue({ isValid: false, errors: ['MIN', 'LOWERCASE', 'UPPERCASE', 'NUMBER', 'SPECIAL'], @@ -300,13 +344,126 @@ describe('Invitation', () => { }) }) - describe('when user is authenticated', () => { - it('should render nothing', async () => { + describe('when the invited email already has an account', () => { + const mocks = [createInviteMock({ existingUser: true })] + + it('should ask for the password of the existing account', async () => { + await renderInvitation(mocks) + + await waitFor(() => { + expect(getByDataTest(INVITATION_LOG_IN_BUTTON_TEST_ID)).toBeInTheDocument() + }) + + expect(document.querySelector('input[name="email"]')).toBeDisabled() + expect(getByDataTest(INVITATION_SUBMIT_BUTTON_TEST_ID)).not.toBeInTheDocument() + }) + + // The password is verified against the existing account, not created: applying the creation + // rules would lock out any password predating them. + it('should not show the password creation hints', async () => { + setupMockUseStore('weak', true) + mockPasswordValidation.mockReturnValue({ + isValid: false, + errors: ['MIN', 'UPPERCASE', 'NUMBER', 'SPECIAL'], + }) + + await renderInvitation(mocks) + + await waitFor(() => { + expect(getByDataTest(INVITATION_LOG_IN_BUTTON_TEST_ID)).toBeInTheDocument() + }) + + expect(getByDataTest(PASSWORD_HINTS_TEST_IDS.VISIBLE)).not.toBeInTheDocument() + expect(getByDataTest(PASSWORD_HINTS_TEST_IDS.HIDDEN)).not.toBeInTheDocument() + }) + + it('should keep the SSO buttons available', async () => { + await renderInvitation(mocks) + + await waitFor(() => { + expect(screen.getByTestId('google-auth-button')).toBeInTheDocument() + }) + + expect(screen.getByText('text_664c90c9b2b6c2012aa50bd5')).toBeInTheDocument() + }) + }) + + describe('when the invited user is authenticated', () => { + beforeEach(() => { + mockIsAuthenticated.mockReturnValue({ isAuthenticated: true }) + mockCurrentUser.mockReturnValue({ + currentUser: { id: 'user-1', email: 'test@example.com' }, + loading: false, + refetchCurrentUserInfos: mockRefetchCurrentUserInfos, + }) + }) + + it('should only offer to accept the invitation', async () => { + await renderInvitation([createInviteMock({ existingUser: true })]) + + await waitFor(() => { + expect(getByDataTest(INVITATION_JOIN_BUTTON_TEST_ID)).toBeInTheDocument() + }) + + expect(getByDataTest(INVITATION_SUBMIT_BUTTON_TEST_ID)).not.toBeInTheDocument() + expect(screen.queryByTestId('google-auth-button')).not.toBeInTheDocument() + }) + + it('should reload the memberships of the user after accepting', async () => { + await renderInvitation([ + createInviteMock({ existingUser: true }), + createJoinOrganizationMock(), + ]) + + await waitFor(() => { + expect(getByDataTest(INVITATION_JOIN_BUTTON_TEST_ID)).toBeInTheDocument() + }) + + await act(async () => { + ;(getByDataTest(INVITATION_JOIN_BUTTON_TEST_ID) as HTMLElement).click() + }) + + await waitFor(() => { + expect(mockRefetchCurrentUserInfos).toHaveBeenCalled() + }) + }) + + it('should accept the invitation when the invited email only differs by its case', async () => { + mockCurrentUser.mockReturnValue({ + currentUser: { id: 'user-1', email: 'TEST@example.com' }, + loading: false, + refetchCurrentUserInfos: mockRefetchCurrentUserInfos, + }) + + await renderInvitation([createInviteMock({ existingUser: true })]) + + await waitFor(() => { + expect(getByDataTest(INVITATION_JOIN_BUTTON_TEST_ID)).toBeInTheDocument() + }) + + expect(getByDataTest(INVITATION_LOG_OUT_BUTTON_TEST_ID)).not.toBeInTheDocument() + }) + }) + + describe('when another user is authenticated', () => { + beforeEach(() => { mockIsAuthenticated.mockReturnValue({ isAuthenticated: true }) + mockCurrentUser.mockReturnValue({ + currentUser: { id: 'user-2', email: 'someone-else@example.com' }, + loading: false, + refetchCurrentUserInfos: mockRefetchCurrentUserInfos, + }) + }) + + it('should offer to log out instead of accepting the invitation', async () => { + await renderInvitation([createInviteMock({ existingUser: true })]) - const { container } = (await renderInvitation()) as unknown as { container: HTMLElement } + await waitFor(() => { + expect(getByDataTest(INVITATION_LOG_OUT_BUTTON_TEST_ID)).toBeInTheDocument() + }) - expect(container).toBeEmptyDOMElement() + expect(getByDataTest(INVITATION_JOIN_BUTTON_TEST_ID)).not.toBeInTheDocument() + expect(getByDataTest(INVITATION_SUBMIT_BUTTON_TEST_ID)).not.toBeInTheDocument() }) }) }) diff --git a/src/pages/invitationForm/InvitationLogInForm.tsx b/src/pages/invitationForm/InvitationLogInForm.tsx new file mode 100644 index 0000000000..07ce36d055 --- /dev/null +++ b/src/pages/invitationForm/InvitationLogInForm.tsx @@ -0,0 +1,74 @@ +import { revalidateLogic } from '@tanstack/react-form' + +import { TextInput } from '~/components/form/TextInput' +import { PASSWORD_VALIDATION_ERRORS } from '~/formValidation/zodCustoms' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { useAppForm } from '~/hooks/forms/useAppform' + +import { InvitationFormProps } from './types' +import { invitationDefaultValues, invitationLogInValidationSchema } from './validationSchema' + +/** + * Acceptance of an invitation whose email already has an account. The password is verified against + * that account. Users whose memberships were all revoked cannot log in anymore, so acceptance is + * not delegated to the login page. + */ +export const InvitationLogInForm = ({ + email, + formId, + loading, + submitDataTest, + onSubmit, +}: InvitationFormProps) => { + const { translate } = useInternationalization() + + const form = useAppForm({ + defaultValues: invitationDefaultValues, + validationLogic: revalidateLogic(), + validators: { + onDynamic: invitationLogInValidationSchema, + }, + onSubmit: async ({ value }) => { + await onSubmit(value.password) + }, + }) + + return ( +
{ + e.preventDefault() + form.handleSubmit() + }} + > +
+
+ + + + {(field) => ( + + )} + +
+ + + + {translate('text_1786557508910towzrwnae9w')} + + +
+
+ ) +} diff --git a/src/pages/invitationForm/InvitationSignUpForm.tsx b/src/pages/invitationForm/InvitationSignUpForm.tsx new file mode 100644 index 0000000000..c7b06dfa4d --- /dev/null +++ b/src/pages/invitationForm/InvitationSignUpForm.tsx @@ -0,0 +1,82 @@ +import { revalidateLogic, useStore } from '@tanstack/react-form' + +import { PasswordValidationHints } from '~/components/form/PasswordValidationHints/PasswordValidationHints' +import { TextInput } from '~/components/form/TextInput' +import { PASSWORD_VALIDATION_ERRORS } from '~/formValidation/zodCustoms' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { useAppForm } from '~/hooks/forms/useAppform' +import { usePasswordValidation } from '~/hooks/forms/usePasswordValidation' + +import { InvitationFormProps } from './types' +import { invitationDefaultValues, invitationValidationSchema } from './validationSchema' + +export const InvitationSignUpForm = ({ + email, + formId, + loading, + submitDataTest, + onSubmit, +}: InvitationFormProps) => { + const { translate } = useInternationalization() + + const form = useAppForm({ + defaultValues: invitationDefaultValues, + validationLogic: revalidateLogic(), + validators: { + onDynamic: invitationValidationSchema, + }, + onSubmit: async ({ value }) => { + await onSubmit(value.password) + }, + }) + + const password = useStore(form.store, (state) => state.values.password) + const passwordValidation = usePasswordValidation(password) + + return ( +
{ + e.preventDefault() + form.handleSubmit() + }} + > +
+
+ + +
+ + {(field) => ( + + )} + + +
+
+ + + + {translate('text_63246f875e2228ab7b63dd1c')} + + +
+
+ ) +} diff --git a/src/pages/invitationForm/__tests__/validationSchema.test.ts b/src/pages/invitationForm/__tests__/validationSchema.test.ts index 6a63c408af..3d2ab9c431 100644 --- a/src/pages/invitationForm/__tests__/validationSchema.test.ts +++ b/src/pages/invitationForm/__tests__/validationSchema.test.ts @@ -1,132 +1,27 @@ import { PASSWORD_VALIDATION_ERRORS } from '~/formValidation/zodCustoms' -import { - invitationDefaultValues, - InvitationFormValues, - invitationValidationSchema, -} from '../validationSchema' +import { invitationLogInValidationSchema, invitationValidationSchema } from '../validationSchema' describe('invitationValidationSchema', () => { - describe('password validation', () => { - it('should fail for empty password', () => { - const result = invitationValidationSchema.safeParse({ password: '' }) - - expect(result.success).toBe(false) - if (!result.success) { - const errors = result.error.flatten().fieldErrors.password - - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.REQUIRED) - } - }) - - it('should fail for password shorter than 8 characters', () => { - const result = invitationValidationSchema.safeParse({ password: 'Pass1!' }) - - expect(result.success).toBe(false) - if (!result.success) { - const errors = result.error.flatten().fieldErrors.password - - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.MIN) - } - }) - - it('should fail for password without lowercase letters', () => { - const result = invitationValidationSchema.safeParse({ password: 'PASSWORD1!' }) - - expect(result.success).toBe(false) - if (!result.success) { - const errors = result.error.flatten().fieldErrors.password - - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.LOWERCASE) - } - }) - - it('should fail for password without uppercase letters', () => { - const result = invitationValidationSchema.safeParse({ password: 'password1!' }) - - expect(result.success).toBe(false) - if (!result.success) { - const errors = result.error.flatten().fieldErrors.password - - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.UPPERCASE) - } - }) - - it('should fail for password without numbers', () => { - const result = invitationValidationSchema.safeParse({ password: 'Password!' }) - - expect(result.success).toBe(false) - if (!result.success) { - const errors = result.error.flatten().fieldErrors.password - - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.NUMBER) - } - }) - - it('should fail for password without special characters', () => { - const result = invitationValidationSchema.safeParse({ password: 'Password1' }) - - expect(result.success).toBe(false) - if (!result.success) { - const errors = result.error.flatten().fieldErrors.password - - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.SPECIAL) - } - }) - - it('should pass for valid password', () => { - const result = invitationValidationSchema.safeParse({ password: 'Password1!' }) - - expect(result.success).toBe(true) - }) - - it('should pass for password with various special characters', () => { - const validPasswords = [ - 'Password1!', - 'Password1@', - 'Password1#', - 'Password1$', - 'Password1%', - 'Password1^', - 'Password1&', - 'Password1*', - 'Password1/', - 'Password1.', - 'Password1,', - 'Password1?', - ] - - validPasswords.forEach((password) => { - const result = invitationValidationSchema.safeParse({ password }) - - expect(result.success).toBe(true) - }) - }) - - it('should return multiple errors for password missing multiple requirements', () => { - const result = invitationValidationSchema.safeParse({ password: 'short' }) - - expect(result.success).toBe(false) - if (!result.success) { - const errors = result.error.flatten().fieldErrors.password || [] + it('rejects a password that does not follow the creation rules', () => { + expect(invitationValidationSchema.safeParse({ password: 'weak' }).success).toBe(false) + }) - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.MIN) - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.UPPERCASE) - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.NUMBER) - expect(errors).toContain(PASSWORD_VALIDATION_ERRORS.SPECIAL) - } - }) + it('accepts a password that follows the creation rules', () => { + expect(invitationValidationSchema.safeParse({ password: 'ILoveLago1!' }).success).toBe(true) }) }) -describe('invitationDefaultValues', () => { - it('should have empty password as default', () => { - expect(invitationDefaultValues.password).toBe('') +describe('invitationLogInValidationSchema', () => { + // The password of an existing account can be older than the creation rules. + it('accepts a password that does not follow the creation rules', () => { + expect(invitationLogInValidationSchema.safeParse({ password: 'weak' }).success).toBe(true) }) - it('should match the expected type', () => { - const values: InvitationFormValues = invitationDefaultValues + it('rejects an empty password', () => { + const result = invitationLogInValidationSchema.safeParse({ password: '' }) - expect(values).toHaveProperty('password') + expect(result.success).toBe(false) + expect(result.error?.issues[0].message).toBe(PASSWORD_VALIDATION_ERRORS.REQUIRED) }) }) diff --git a/src/pages/invitationForm/types.ts b/src/pages/invitationForm/types.ts new file mode 100644 index 0000000000..bdddea5ca7 --- /dev/null +++ b/src/pages/invitationForm/types.ts @@ -0,0 +1,7 @@ +export interface InvitationFormProps { + email?: string + formId: string + loading?: boolean + submitDataTest: string + onSubmit: (password: string) => Promise +} diff --git a/src/pages/invitationForm/validationSchema.ts b/src/pages/invitationForm/validationSchema.ts index 26655336a5..063cef3fcd 100644 --- a/src/pages/invitationForm/validationSchema.ts +++ b/src/pages/invitationForm/validationSchema.ts @@ -1,11 +1,19 @@ import { z } from 'zod' -import { zodRequiredPassword } from '~/formValidation/zodCustoms' +import { PASSWORD_VALIDATION_ERRORS, zodRequiredPassword } from '~/formValidation/zodCustoms' export const invitationValidationSchema = z.object({ password: zodRequiredPassword, }) +/** + * The password of an existing account is verified, not created: an existing password can be older + * than the creation rules. + */ +export const invitationLogInValidationSchema = z.object({ + password: z.string().min(1, { message: PASSWORD_VALIDATION_ERRORS.REQUIRED }), +}) + export type InvitationFormValues = z.infer export const invitationDefaultValues: InvitationFormValues = { diff --git a/translations/base.json b/translations/base.json index 03579a26a8..5d4e22d097 100644 --- a/translations/base.json +++ b/translations/base.json @@ -4581,5 +4581,14 @@ "text_1786367738116xtz2r6c9eoz": "Rate card {{rateCardCode}} was created", "text_1786367738117541mevhwa63": "Rate card {{rateCardCode}} was deleted", "text_1786367738117gbjclk29or1": "Rate card {{rateCardCode}} was updated", - "text_1786367738117s1n9lpwf97k": "Rate card" + "text_1786367738117s1n9lpwf97k": "Rate card", + "text_1786557508910b6cacpc0pjt": "You already have a Lago account with {{email}}. Log in to accept this invitation.", + "text_1786557508910towzrwnae9w": "Log in to accept", + "text_17865575089107fdhugc24r9": "You are logged in as {{email}}. Accept the invitation to join this organization.", + "text_17865575089104r0enbn7r7l": "Accept invitation", + "text_1786557508910jl708qczi4g": "This invitation was sent to {{inviteEmail}}, but you are logged in as {{currentEmail}}. Log out to accept it with the invited account.", + "text_17865575089106781wwdm3l3": "Log out", + "text_1786557508910guitmzid55q": "You are already a member of this organization.", + "text_17865575089107lip4oupwdj": "This invitation was sent to another email address. Log in with the invited account to accept it.", + "text_1786557573982blvi6cjpnti": "Your login method is not allowed by this organization. Log in with an authorized method to accept this invitation." } \ No newline at end of file From b117032e39fe9391b3172a6a724343fc4eb10d01 Mon Sep 17 00:00:00 2001 From: endenis Date: Thu, 13 Aug 2026 19:25:47 +0200 Subject: [PATCH 2/6] fix: input label --- src/pages/invitationForm/InvitationLogInForm.tsx | 4 ++-- translations/base.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/invitationForm/InvitationLogInForm.tsx b/src/pages/invitationForm/InvitationLogInForm.tsx index 07ce36d055..1e5a65d06d 100644 --- a/src/pages/invitationForm/InvitationLogInForm.tsx +++ b/src/pages/invitationForm/InvitationLogInForm.tsx @@ -55,8 +55,8 @@ export const InvitationLogInForm = ({ {(field) => ( )} diff --git a/translations/base.json b/translations/base.json index 39e2e578a3..c2a3596b6e 100644 --- a/translations/base.json +++ b/translations/base.json @@ -4664,4 +4664,4 @@ "text_1786630268016meyak1nxn9g": "The fixed charge model of a plan has changed since the quote was approved", "text_1786630268016i9hcrwrzkcc": "The customer of this order could not be found", "text_1786630268016vpwqdibatsd": "This order has nothing to bill" -} \ No newline at end of file +} From bf638c70aae5ca3b1213507afafa94ecd0c81ce0 Mon Sep 17 00:00:00 2001 From: endenis Date: Thu, 13 Aug 2026 20:19:30 +0200 Subject: [PATCH 3/6] fix: improve coverage and fix e2e test --- .../e2e/00-auth/t30-multi-org-redirect.cy.ts | 24 +-- src/pages/__tests__/Invitation.test.tsx | 177 ++++++++++++++++-- src/pages/__tests__/InvitationInit.test.tsx | 27 +++ 3 files changed, 201 insertions(+), 27 deletions(-) create mode 100644 src/pages/__tests__/InvitationInit.test.tsx diff --git a/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts b/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts index 2696f99d79..81e93b6381 100644 --- a/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts +++ b/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts @@ -75,19 +75,17 @@ describe('Multi-organization redirect flows', () => { invitationUrl = $link.attr('href') || $link.text().trim() cy.log('Invitation URL captured:', invitationUrl) - // Force a full page navigation by visiting the URL. - // The invitation page keeps the session: User B is still logged in here. + // Log out User B before opening the invite + cy.clearLocalStorage() cy.visit(invitationUrl, { failOnStatusCode: false }) }) - // 5. The invitation targets User A, so User B has to log out first + // 5. User A accepts the invite with their password cy.url().should('include', '/invitation/') - cy.get('[data-test="log-out-button"]', { timeout: 10000 }).click() - - // 6. User A already has an account: accepting requires the password of that account, which is - // what proves the acceptor owns it - cy.get('input[name="password"]', { timeout: 10000 }).should('be.visible') - cy.get('input[name="password"]').type(testUsers.userA.password) + cy.get('input[name="password"]', { timeout: 10000 }) + .scrollIntoView() + .should('be.visible') + .type(testUsers.userA.password) cy.get('[data-test="log-in-button"]').click() // User A should now have access to both organizations @@ -122,7 +120,9 @@ describe('Multi-organization redirect flows', () => { cy.get('input[name="name"]').type('Customer Org1 Multi-Org Test') cy.get('input[name="externalId"]').type(`customer-org1-${Date.now()}`) cy.get(`[data-test="${SUBMIT_CUSTOMER_DATA_TEST}"]`).click() - cy.url().should('include', '/customer/') + cy.url() + .should('not.include', '/customer/create') + .and('match', /\/customer\/[^/?#]+$/) // Save the customer URL from Org1 cy.url().then((org1CustomerUrl) => { const customerIdMatch = org1CustomerUrl.match(/\/customer\/([^/]+)/) @@ -161,7 +161,9 @@ describe('Multi-organization redirect flows', () => { cy.get('input[name="name"]').type('Customer for Org Switch Test') cy.get('input[name="externalId"]').type(`customer-org-switch-${Date.now()}`) cy.get(`[data-test="${SUBMIT_CUSTOMER_DATA_TEST}"]`).click() - cy.url().should('include', '/customer/') + cy.url() + .should('not.include', '/customer/create') + .and('match', /\/customer\/[^/?#]+$/) const urlToAvoidAfterLogin = cy.url() diff --git a/src/pages/__tests__/Invitation.test.tsx b/src/pages/__tests__/Invitation.test.tsx index 4c720753d1..8d5401156a 100644 --- a/src/pages/__tests__/Invitation.test.tsx +++ b/src/pages/__tests__/Invitation.test.tsx @@ -1,9 +1,16 @@ import { MockedProvider, MockedResponse } from '@apollo/client/testing' import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { GraphQLError } from 'graphql' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { PASSWORD_HINTS_TEST_IDS } from '~/components/form/PasswordValidationHints/PasswordValidationHints' -import { GetinviteDocument, JoinOrganizationDocument } from '~/generated/graphql' +import { + AcceptInviteDocument, + GetinviteDocument, + JoinOrganizationDocument, + LagoApiError, +} from '~/generated/graphql' import Invitation, { INVITATION_JOIN_BUTTON_TEST_ID, @@ -33,6 +40,15 @@ jest.mock('~/hooks/useCurrentUser', () => ({ useCurrentUser: () => mockCurrentUser(), })) +const mockOnLogIn = jest.fn() +const mockLogOut = jest.fn() + +jest.mock('~/core/apolloClient', () => ({ + ...jest.requireActual('~/core/apolloClient'), + onLogIn: (...args: unknown[]) => mockOnLogIn(...args), + logOut: (...args: unknown[]) => mockLogOut(...args), +})) + jest.mock('~/components/auth/GoogleAuthButton', () => ({ __esModule: true, default: ({ label }: { label: string }) => ( @@ -47,9 +63,14 @@ jest.mock('~/hooks/forms/usePasswordValidation', () => ({ })) const mockHandleSubmit = jest.fn() +let mockFormPassword = '' jest.mock('~/hooks/forms/useAppform', () => ({ - useAppForm: () => ({ + useAppForm: ({ + onSubmit, + }: { + onSubmit: (args: { value: { password: string } }) => Promise + }) => ({ store: { subscribe: jest.fn(() => jest.fn()), getState: () => ({ @@ -57,7 +78,10 @@ jest.mock('~/hooks/forms/useAppform', () => ({ canSubmit: true, }), }, - handleSubmit: mockHandleSubmit, + handleSubmit: async () => { + mockHandleSubmit() + await onSubmit({ value: { password: mockFormPassword } }).catch(() => undefined) + }, AppField: ({ name, children, @@ -113,6 +137,7 @@ jest.mock('@tanstack/react-form', () => ({ })) const setupMockUseStore = (password = '', canSubmit = true) => { + mockFormPassword = password mockUseStore.mockImplementation((_store, selector) => { const state = { canSubmit, @@ -123,6 +148,13 @@ const setupMockUseStore = (password = '', canSubmit = true) => { }) } +type LagoApiErrorCode = keyof typeof LagoApiError + +const graphQLError = (code: LagoApiErrorCode) => + new GraphQLError(code, { + extensions: { code: LagoApiError[code] }, + }) + const createInviteMock = ( overrides: { token?: string @@ -130,6 +162,7 @@ const createInviteMock = ( organizationName?: string existingUser?: boolean error?: boolean + onResult?: () => void } = {}, ): MockedResponse => { const { @@ -138,6 +171,7 @@ const createInviteMock = ( organizationName = 'Test Org', existingUser = false, error = false, + onResult, } = overrides if (error) { @@ -150,24 +184,32 @@ const createInviteMock = ( } } + const result = { + data: { + invite: { + id: 'invite-1', + email, + existingUser, + organization: { + id: 'org-1', + name: organizationName, + }, + }, + }, + } + return { request: { query: GetinviteDocument, variables: { token }, }, - result: { - data: { - invite: { - id: 'invite-1', - email, - existingUser, - organization: { - id: 'org-1', - name: organizationName, - }, - }, - }, - }, + result: onResult + ? () => { + onResult() + + return result + } + : result, } } @@ -195,6 +237,37 @@ const createJoinOrganizationMock = ( } } +const createAcceptInviteMock = ( + overrides: { + token?: string + userToken?: string + slug?: string + errorCode?: LagoApiErrorCode + } = {}, +): MockedResponse => { + const { token = 'test-token', userToken = 'user-token', slug = 'test-org', errorCode } = overrides + + return { + request: { + query: AcceptInviteDocument, + variables: { input: { token, password: mockFormPassword } }, + }, + result: errorCode + ? { errors: [graphQLError(errorCode)] } + : { + data: { + acceptInvite: { + token: userToken, + organization: { + id: 'org-1', + slug, + }, + }, + }, + }, + } +} + const renderInvitation = async ( mocks: MockedResponse[] = [createInviteMock()], token = 'test-token', @@ -230,6 +303,8 @@ describe('Invitation', () => { isValid: false, errors: ['MIN', 'LOWERCASE', 'UPPERCASE', 'NUMBER', 'SPECIAL'], }) + mockOnLogIn.mockResolvedValue(undefined) + mockLogOut.mockResolvedValue(undefined) }) describe('when invite is loaded successfully', () => { @@ -386,6 +461,62 @@ describe('Invitation', () => { expect(screen.getByText('text_664c90c9b2b6c2012aa50bd5')).toBeInTheDocument() }) + + it('should submit the existing account password and start its session', async () => { + const user = userEvent.setup() + + setupMockUseStore('existing-password') + await renderInvitation([createInviteMock({ existingUser: true }), createAcceptInviteMock()]) + + await user.click(await screen.findByText('text_1786557508910towzrwnae9w')) + + await waitFor(() => { + expect(mockOnLogIn).toHaveBeenCalledWith(expect.anything(), 'user-token') + }) + }) + + it('should display an error when the existing account password is incorrect', async () => { + const user = userEvent.setup() + + setupMockUseStore('incorrect-password') + await renderInvitation([ + createInviteMock({ existingUser: true }), + createAcceptInviteMock({ errorCode: 'IncorrectLoginOrPassword' }), + ]) + + await user.click(await screen.findByText('text_1786557508910towzrwnae9w')) + + expect(await screen.findByText('text_620bc4d4269a55014d493fb7')).toBeInTheDocument() + }) + }) + + describe('when the invited email does not have an account', () => { + it('should submit the new password and start the created session', async () => { + const user = userEvent.setup() + + setupMockUseStore('ValidPassword1!') + await renderInvitation([createInviteMock(), createAcceptInviteMock()]) + + await user.click(await screen.findByText('text_63246f875e2228ab7b63dd1c')) + + await waitFor(() => { + expect(mockOnLogIn).toHaveBeenCalledWith(expect.anything(), 'user-token') + }) + }) + + it('should explain when an account was created after the invitation was loaded', async () => { + const user = userEvent.setup() + + setupMockUseStore('ValidPassword1!') + await renderInvitation([ + createInviteMock(), + createAcceptInviteMock({ errorCode: 'EmailAlreadyUsed' }), + ]) + + await user.click(await screen.findByText('text_63246f875e2228ab7b63dd1c')) + + expect(await screen.findByText('text_1786557508910guitmzid55q')).toBeInTheDocument() + }) }) describe('when the invited user is authenticated', () => { @@ -465,5 +596,19 @@ describe('Invitation', () => { expect(getByDataTest(INVITATION_JOIN_BUTTON_TEST_ID)).not.toBeInTheDocument() expect(getByDataTest(INVITATION_SUBMIT_BUTTON_TEST_ID)).not.toBeInTheDocument() }) + + it('should log out and refetch the invitation', async () => { + const user = userEvent.setup() + const onInviteResult = jest.fn() + const inviteMock = createInviteMock({ existingUser: true, onResult: onInviteResult }) + + await renderInvitation([inviteMock, inviteMock]) + await user.click(await screen.findByText('text_17865575089106781wwdm3l3')) + + await waitFor(() => { + expect(mockLogOut).toHaveBeenCalledWith(expect.anything(), true) + expect(onInviteResult).toHaveBeenCalledTimes(2) + }) + }) }) }) diff --git a/src/pages/__tests__/InvitationInit.test.tsx b/src/pages/__tests__/InvitationInit.test.tsx new file mode 100644 index 0000000000..2cca2512eb --- /dev/null +++ b/src/pages/__tests__/InvitationInit.test.tsx @@ -0,0 +1,27 @@ +import { render, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import InvitationInit from '../InvitationInit' + +const mockNavigate = jest.fn() + +jest.mock('~/core/router', () => ({ + ...jest.requireActual('~/core/router'), + useNavigate: () => mockNavigate, +})) + +describe('InvitationInit', () => { + it('should forward the invitation token to the form route', async () => { + render( + + + } /> + + , + ) + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/invitation/test-token/form', { replace: true }) + }) + }) +}) From 7b4cd58f5295aefb09d4a0cf237359f338f332f9 Mon Sep 17 00:00:00 2001 From: endenis Date: Thu, 13 Aug 2026 21:12:39 +0200 Subject: [PATCH 4/6] fix: e2e test --- cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts b/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts index 81e93b6381..2d4d4b2da8 100644 --- a/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts +++ b/cypress/e2e/00-auth/t30-multi-org-redirect.cy.ts @@ -76,8 +76,10 @@ describe('Multi-organization redirect flows', () => { cy.log('Invitation URL captured:', invitationUrl) // Log out User B before opening the invite - cy.clearLocalStorage() - cy.visit(invitationUrl, { failOnStatusCode: false }) + cy.visit(invitationUrl, { + failOnStatusCode: false, + onBeforeLoad: (win) => win.localStorage.clear(), + }) }) // 5. User A accepts the invite with their password From a3e392ffaa39d0eeb8fee7bd4692e0b9675fb1ff Mon Sep 17 00:00:00 2001 From: endenis Date: Tue, 18 Aug 2026 18:02:05 +0200 Subject: [PATCH 5/6] fix: redirects on accept --- cypress/support/e2e.ts | 20 +--- src/generated/graphql.tsx | 5 +- src/hooks/useCurrentUser.ts | 2 +- src/pages/Invitation.tsx | 89 +++++++++------- src/pages/__tests__/Invitation.test.tsx | 128 +++++++++++++++++++++--- 5 files changed, 178 insertions(+), 66 deletions(-) diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 7759d623ad..69df79bd2c 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -9,24 +9,10 @@ import { SIGNUP_SUBMIT_BUTTON_TEST_ID } from '~/pages/auth/signUpTestIds' import { userEmail, userPassword } from './reusableConstants' /** - * Paths that pass through `cy.visitApp()` unchanged (no slug prepended). - * - * Extends the app's `NEVER_SLUG_PREFIXES` with auth entry pages that are - * reachable only from outside the app (signup, invitation, password reset) - * — those aren't in `NEVER_SLUG_PREFIXES` because the in-app wrappers - * (`useNavigate` / ``) never build `navigate('/sign-up')` calls, but - * Cypress tests do visit those pages directly. - * - * Importing `NEVER_SLUG_PREFIXES` from the source keeps the two lists in - * sync — any new public route added there is reflected here automatically. + * Paths that `cy.visitApp()` should not prefix with an organization slug. + * Includes app routes and auth pages opened directly by Cypress. */ -const PUBLIC_PATHS = [ - ...NEVER_SLUG_PREFIXES, - '/sign-up', - '/invitation', - '/password-reset', - '/forgot-password', -] +const PUBLIC_PATHS = [...NEVER_SLUG_PREFIXES, '/sign-up', '/password-reset', '/forgot-password'] /** * Regex matching the first authenticated URL after login/signup. diff --git a/src/generated/graphql.tsx b/src/generated/graphql.tsx index 861c852738..bfe8bbf21b 100644 --- a/src/generated/graphql.tsx +++ b/src/generated/graphql.tsx @@ -14981,7 +14981,7 @@ export type GetinviteQueryVariables = Exact<{ }>; -export type GetinviteQuery = { __typename?: 'Query', invite?: { __typename?: 'Invite', id: string, email: string, existingUser: boolean, organization: { __typename?: 'Organization', id: string, name: string } } | null }; +export type GetinviteQuery = { __typename?: 'Query', invite?: { __typename?: 'Invite', id: string, email: string, existingUser: boolean, organization: { __typename?: 'Organization', id: string, name: string, slug: string } } | null }; export type AcceptInviteMutationVariables = Exact<{ input: AcceptInviteInput; @@ -37906,6 +37906,7 @@ export const GetinviteDocument = gql` organization { id name + slug } } } @@ -46830,4 +46831,4 @@ export function useGetBillableMetricsForWalletSuspenseQuery(baseOptions?: Apollo export type GetBillableMetricsForWalletQueryHookResult = ReturnType; export type GetBillableMetricsForWalletLazyQueryHookResult = ReturnType; export type GetBillableMetricsForWalletSuspenseQueryHookResult = ReturnType; -export type GetBillableMetricsForWalletQueryResult = Apollo.QueryResult; \ No newline at end of file +export type GetBillableMetricsForWalletQueryResult = Apollo.QueryResult; diff --git a/src/hooks/useCurrentUser.ts b/src/hooks/useCurrentUser.ts index db35a9ccc5..91705d2dbb 100644 --- a/src/hooks/useCurrentUser.ts +++ b/src/hooks/useCurrentUser.ts @@ -44,7 +44,7 @@ type UseCurrentUser = () => { loading: boolean currentUser?: CurrentUserInfosFragment currentMembership?: CurrentUserInfosFragment['memberships'][0] - refetchCurrentUserInfos: () => void + refetchCurrentUserInfos: ReturnType['refetch'] } export const useCurrentUser: UseCurrentUser = () => { diff --git a/src/pages/Invitation.tsx b/src/pages/Invitation.tsx index ec5ec8a2dd..7abde77b53 100644 --- a/src/pages/Invitation.tsx +++ b/src/pages/Invitation.tsx @@ -10,7 +10,7 @@ import { Skeleton } from '~/components/designSystem/Skeleton' import { Typography } from '~/components/designSystem/Typography' import { hasDefinedGQLError, logOut, onLogIn } from '~/core/apolloClient' import { DOCUMENTATION_ENV_VARS } from '~/core/constants/externalUrls' -import { HOME_ROUTE, LOGIN_ROUTE, useNavigate } from '~/core/router' +import { LOGIN_ROUTE, useNavigate } from '~/core/router' import { addValuesToUrlState } from '~/core/utils/urlUtils' import { CurrentUserFragmentDoc, @@ -58,6 +58,7 @@ gql` organization { id name + slug } } } @@ -148,6 +149,7 @@ const Invitation = () => { }) const invite = data?.invite const email = invite?.email + const invitedOrganizationSlug = invite?.organization.slug const mode: InvitationMode | undefined = useMemo(() => { if (!invite) return undefined @@ -166,15 +168,16 @@ const Invitation = () => { // Land on the organization of the invitation. Without it the home page would resolve the last // used organization, which is not the one that was just joined. - const onAccepted = async (userToken: string, slug?: string) => { + const onAccepted = async (userToken: string, slug: string) => { await onLogIn(client, userToken) - navigate(slug ? `/${slug}` : HOME_ROUTE, { replace: true, skipSlugPrepend: true }) + navigate(`/${slug}`, { replace: true, skipSlugPrepend: true }) } // 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 () => { await logOut(client, true) + resetJoinOrganization() await refetchInvite() } @@ -188,20 +191,26 @@ const Invitation = () => { }, }) - const [joinOrganization, { error: joinOrganizationError, loading: joinOrganizationLoading }] = - useJoinOrganizationMutation({ - context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, - onCompleted: async (res) => { - const slug = res?.joinOrganization?.organization.slug + const [ + joinOrganization, + { + error: joinOrganizationError, + loading: joinOrganizationLoading, + reset: resetJoinOrganization, + }, + ] = useJoinOrganizationMutation({ + context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, + onCompleted: async (res) => { + const slug = res?.joinOrganization?.organization.slug - if (!slug) return + if (!slug) return - // The mutation cannot return the permissions and the organization details the cached - // current user holds, so the memberships are reloaded instead of written to the cache. - await refetchCurrentUserInfos() - navigate(`/${slug}`, { replace: true, skipSlugPrepend: true }) - }, - }) + // The mutation cannot return the permissions and the organization details the cached + // current user holds, so the memberships are reloaded instead of written to the cache. + await refetchCurrentUserInfos() + navigate(`/${slug}`, { replace: true, skipSlugPrepend: true }) + }, + }) const [googleAcceptInvite, { error: googleAcceptInviteError }] = useGoogleAcceptInviteMutation({ context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, @@ -224,8 +233,8 @@ const Invitation = () => { useOktaAcceptInviteMutation({ context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, onCompleted: async (res) => { - if (!!res?.oktaAcceptInvite) { - await onAccepted(res.oktaAcceptInvite.token) + if (!!res?.oktaAcceptInvite && !!invitedOrganizationSlug) { + await onAccepted(res.oktaAcceptInvite.token, invitedOrganizationSlug) } }, }) @@ -244,8 +253,8 @@ const Invitation = () => { ] = useEntraIdAcceptInviteMutation({ context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, onCompleted: async (res) => { - if (res?.entraIdAcceptInvite) { - await onAccepted(res.entraIdAcceptInvite.token) + if (!!res?.entraIdAcceptInvite && !!invitedOrganizationSlug) { + await onAccepted(res.entraIdAcceptInvite.token, invitedOrganizationSlug) } }, }) @@ -303,6 +312,10 @@ const Invitation = () => { } } + const onJoinOrganization = () => { + joinOrganization({ variables: { input: { token: token || '' } } }).catch(() => undefined) + } + useEffect(() => { if (!!googleCode && !!token) { googleAcceptInvite({ @@ -318,7 +331,7 @@ const Invitation = () => { }, [googleCode, token]) useEffect(() => { - if (!!oktaCode && !!oktaState && !!token) { + if (!!oktaCode && !!oktaState && !!token && !!invitedOrganizationSlug) { oktaAcceptInvite({ variables: { input: { @@ -330,10 +343,10 @@ const Invitation = () => { }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [oktaCode, oktaState, token]) + }, [oktaCode, oktaState, token, invitedOrganizationSlug]) useEffect(() => { - if (!!entraIdCode && !!entraIdState && !!token) { + if (!!entraIdCode && !!entraIdState && !!token && !!invitedOrganizationSlug) { entraIdAcceptInvite({ variables: { input: { @@ -345,7 +358,12 @@ const Invitation = () => { }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entraIdCode, entraIdState, token]) + }, [entraIdCode, entraIdState, token, invitedOrganizationSlug]) + + const joinLoginMethodNotAuthorized = hasDefinedGQLError( + 'LoginMethodNotAuthorized', + joinOrganizationError, + ) const errorTranslation: string | undefined = useMemo(() => { if ( @@ -432,7 +450,7 @@ const Invitation = () => { return translate('text_1786557508910guitmzid55q') } - if (hasDefinedGQLError('LoginMethodNotAuthorized', joinOrganizationError)) { + if (joinLoginMethodNotAuthorized) { return translate('text_1786557573982blvi6cjpnti') } @@ -447,6 +465,7 @@ const Invitation = () => { oktaAuthorizeUrlError, entraIdAcceptInviteError, entraIdAuthorizeUrlError, + joinLoginMethodNotAuthorized, ]) const errorAlert = !!errorTranslation && ( @@ -506,13 +525,15 @@ const Invitation = () => { )} - {!error && (!!loading || (isAuthenticated && (!!currentUserLoading || !currentUser))) && ( - <> - - - - - )} + {!error && + !mode && + (!!loading || (isAuthenticated && (!!currentUserLoading || !currentUser))) && ( + <> + + + + + )} {!error && !loading && !!invite && !!mode && ( @@ -536,20 +557,20 @@ const Invitation = () => { {errorAlert} - {mode === 'join' && ( + {mode === 'join' && !joinLoginMethodNotAuthorized && ( )} - {mode === 'emailMismatch' && ( + {(mode === 'emailMismatch' || (mode === 'join' && joinLoginMethodNotAuthorized)) && (