diff --git a/apps/console-v5/src/app/components/header/header.tsx b/apps/console-v5/src/app/components/header/header.tsx index eb9b23f7b27..016e5f65315 100644 --- a/apps/console-v5/src/app/components/header/header.tsx +++ b/apps/console-v5/src/app/components/header/header.tsx @@ -1,3 +1,4 @@ +import { Link, useParams } from '@tanstack/react-router' import posthog from 'posthog-js' import { useFeatureFlagVariantKey } from 'posthog-js/react' import { Suspense, useCallback } from 'react' @@ -21,6 +22,7 @@ export function Separator() { } export function Header() { + const { organizationId = '' } = useParams({ strict: false }) const isDevopsCopilotEnabled = useFeatureFlagVariantKey('devops-copilot') const handleFeedbackClick = useCallback(() => { posthog.capture('feedback_button_clicked_new_navigation') @@ -30,7 +32,9 @@ export function Header() {
- + + +
}> diff --git a/apps/console-v5/src/routes/_authenticated/accept-invitation/index.tsx b/apps/console-v5/src/routes/_authenticated/accept-invitation/index.tsx index f788fea0254..c6dd3ec830d 100644 --- a/apps/console-v5/src/routes/_authenticated/accept-invitation/index.tsx +++ b/apps/console-v5/src/routes/_authenticated/accept-invitation/index.tsx @@ -1,28 +1,64 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' +import { z } from 'zod' import { AcceptInvitation, useInviteMember } from '@qovery/domains/onboarding/feature' +const acceptInvitationSearchSchema = z.object({ + inviteToken: z.string().optional(), + organization: z.string().optional(), +}) + export const Route = createFileRoute('/_authenticated/accept-invitation/')({ - component: RouteComponent, + validateSearch: acceptInvitationSearchSchema, + component: AcceptInvitationRouteComponent, }) -function RouteComponent() { - const { acceptInvitation, displayInvitation, checkTokenInStorage } = useInviteMember() +export function AcceptInvitationRouteComponent() { + const { + acceptInvitation, + displayInvitation, + fetchInvitationDetail, + initializeInvitation, + inviteDetail, + isAcceptingInvitation, + } = useInviteMember() const navigate = useNavigate() + const search = Route.useSearch() + + const inviteSearch = useMemo(() => { + const searchParams = new URLSearchParams() + + if (search.inviteToken) { + searchParams.set('inviteToken', search.inviteToken) + } + + if (search.organization) { + searchParams.set('organization', search.organization) + } + + const searchString = searchParams.toString() + return searchString ? `?${searchString}` : '' + }, [search.inviteToken, search.organization]) useEffect(() => { - checkTokenInStorage() - }, [checkTokenInStorage]) + initializeInvitation(inviteSearch) + }, [initializeInvitation, inviteSearch]) const onSubmit = async () => { await acceptInvitation() } + useEffect(() => { + if (displayInvitation) { + fetchInvitationDetail().then() + } + }, [displayInvitation, fetchInvitationDetail]) + useEffect(() => { if (displayInvitation === false) { navigate({ to: '/login' }) } }, [displayInvitation, navigate]) - return + return } diff --git a/libs/domains/onboarding/feature/src/lib/accept-invitation/accept-invitation.spec.tsx b/libs/domains/onboarding/feature/src/lib/accept-invitation/accept-invitation.spec.tsx index b2faaa91783..51de6483bdd 100644 --- a/libs/domains/onboarding/feature/src/lib/accept-invitation/accept-invitation.spec.tsx +++ b/libs/domains/onboarding/feature/src/lib/accept-invitation/accept-invitation.spec.tsx @@ -1,15 +1,37 @@ -import { render } from '__tests__/utils/setup-jest' +import { type InviteMember } from 'qovery-typescript-axios' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' import AcceptInvitation from './accept-invitation' -jest.mock('@tanstack/react-router', () => ({ - ...jest.requireActual('@tanstack/react-router'), - useLocation: () => ({ pathname: '/accept-invitation', search: '' }), - useNavigate: () => jest.fn(), -})) - describe('AcceptInvitation', () => { it('should render successfully', () => { - const { baseElement } = render() + const { baseElement } = renderWithProviders() expect(baseElement).toBeTruthy() }) + + it('should render invitation details from props', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Jane has invited you to join:')).toBeInTheDocument() + expect(screen.getByText('Qovery')).toBeInTheDocument() + }) + + it('should call onSubmit when clicking accept', async () => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Accept invitation' })) + + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it('should show a loading state on the accept button', () => { + renderWithProviders() + + expect(screen.getByTestId('spinner')).toBeInTheDocument() + }) }) diff --git a/libs/domains/onboarding/feature/src/lib/accept-invitation/accept-invitation.tsx b/libs/domains/onboarding/feature/src/lib/accept-invitation/accept-invitation.tsx index 223619c6584..c6619fc7bfe 100644 --- a/libs/domains/onboarding/feature/src/lib/accept-invitation/accept-invitation.tsx +++ b/libs/domains/onboarding/feature/src/lib/accept-invitation/accept-invitation.tsx @@ -1,47 +1,37 @@ -import { useEffect } from 'react' +import { type InviteMember } from 'qovery-typescript-axios' import { Button, LogoBrandedIcon } from '@qovery/shared/ui' -import { useInviteMember } from '../hooks/use-invite-member/use-invite-member' export interface AcceptInvitationProps { + inviteDetail?: InviteMember + loading?: boolean onSubmit: () => void } + interface InviteDetailsProps { user_name?: string organization_name?: string } -function InviteDetails(props: InviteDetailsProps) { - const { user_name = '', organization_name = '' } = props - +function InviteDetails({ user_name = '', organization_name = '' }: InviteDetailsProps) { return ( -

- {user_name} has invited you to join +

+ {user_name} has invited you to join:
- {organization_name} -

+ {organization_name} +
) } -export function AcceptInvitation(props: AcceptInvitationProps) { - const { inviteDetail, fetchInvitationDetail, checkTokenInStorage } = useInviteMember() - - useEffect(() => { - checkTokenInStorage() - }, [checkTokenInStorage]) - - useEffect(() => { - fetchInvitationDetail().then() - }, [fetchInvitationDetail]) - +export function AcceptInvitation({ inviteDetail, loading = false, onSubmit }: AcceptInvitationProps) { return ( -
- -
+
+
+ {inviteDetail && ( )} -
diff --git a/libs/domains/onboarding/feature/src/lib/hooks/use-invite-member/use-invite-member.spec.tsx b/libs/domains/onboarding/feature/src/lib/hooks/use-invite-member/use-invite-member.spec.tsx index e70c5410d27..6962a0c4faf 100644 --- a/libs/domains/onboarding/feature/src/lib/hooks/use-invite-member/use-invite-member.spec.tsx +++ b/libs/domains/onboarding/feature/src/lib/hooks/use-invite-member/use-invite-member.spec.tsx @@ -1,44 +1,89 @@ import { Wrapper } from '__tests__/utils/providers' -import { ACCEPT_INVITATION_URL, LOGIN_URL } from '@qovery/shared/routes' +import { type PropsWithChildren } from 'react' import { act, renderHook } from '@qovery/shared/util-tests' import { useInviteMember } from './use-invite-member' const mockUseNavigate = jest.fn() const mockUseLocation = jest.fn(() => ({ pathname: '/', search: '' })) +const mockUseMatchRoute = jest.fn() +const mockRefetchMemberInvitation = jest.fn() +const mockRefetchOrganizations = jest.fn() jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), useNavigate: () => mockUseNavigate, - useLocation: () => mockUseLocation(), + useMatchRoute: () => mockUseMatchRoute, +})) + +jest.mock('@qovery/domains/organizations/feature', () => ({ + useAcceptInviteMember: () => ({ mutateAsync: jest.fn() }), + useMemberInvitation: () => ({ refetch: mockRefetchMemberInvitation }), + useOrganizations: () => ({ refetch: mockRefetchOrganizations }), +})) + +jest.mock('@qovery/shared/util-hooks', () => ({ + useLocalStorage: (key: string, initialValue: unknown) => { + const { useState } = jest.requireActual('react') + + const getStoredValue = () => { + const storedValue = globalThis.localStorage.getItem(key) + return storedValue ? JSON.parse(storedValue) : initialValue + } + + const [value, setValue] = useState(getStoredValue) + + const setStoredValue = (nextValue: unknown) => { + const resolvedValue = typeof nextValue === 'function' ? nextValue(value) : nextValue + + if (resolvedValue === undefined || resolvedValue === null) { + globalThis.localStorage.removeItem(key) + } else { + globalThis.localStorage.setItem(key, JSON.stringify(resolvedValue)) + } + + setValue(resolvedValue) + } + + return [value, setStoredValue] + }, })) describe('useInviteMember Hook', () => { beforeEach(() => { localStorage.clear() + mockUseNavigate.mockClear() + mockUseLocation.mockReturnValue({ pathname: '/', search: '' }) + mockUseMatchRoute.mockImplementation(({ to, fuzzy }: { to: string; fuzzy?: boolean }) => + fuzzy ? mockUseLocation().pathname.startsWith(to) : mockUseLocation().pathname === to + ) + mockRefetchMemberInvitation.mockClear() + mockRefetchOrganizations.mockClear() + window.history.pushState({}, 'Test page', '/') }) - it('should store the tokens from the query inside localstorage and remove redirection from localStorage', async () => { + it('should initialize invitation from the query string and remove redirection from localStorage', async () => { localStorage.setItem('redirectLoginUri', '/organization/123') - mockUseLocation.mockReturnValue({ - search: '?inviteToken=123&organization=456', - pathname: 'login', - }) + const SearchWrapper = ({ children }: PropsWithChildren) => ( + {children} + ) - const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper }) + const { result } = renderHook(() => useInviteMember(), { wrapper: SearchWrapper }) - const { onSearchUpdate } = result.current - await act(() => { - onSearchUpdate() + let hasInvitation: boolean | undefined + await act(async () => { + hasInvitation = result.current.onSearchUpdate() }) - expect(localStorage.getItem('inviteToken')).toBe('123') - expect(localStorage.getItem('inviteOrganizationId')).toBe('456') + expect(hasInvitation).toBe(true) + expect(JSON.parse(localStorage.getItem('inviteToken') ?? 'null')).toBe('123') + expect(JSON.parse(localStorage.getItem('inviteOrganizationId') ?? 'null')).toBe('456') expect(localStorage.getItem('redirectLoginUri')).toBeNull() + expect(result.current.displayInvitation).toBe(true) }) it('should redirect to the acceptation page if token found in localStorage', async () => { - localStorage.setItem('inviteToken', '123') - localStorage.setItem('inviteOrganizationId', '456') + localStorage.setItem('inviteToken', JSON.stringify('123')) + localStorage.setItem('inviteOrganizationId', JSON.stringify('456')) mockUseLocation.mockReturnValue({ search: '', pathname: '/organization', @@ -46,11 +91,12 @@ describe('useInviteMember Hook', () => { const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper }) - const { checkTokenInStorage } = result.current - await act(() => { - checkTokenInStorage() + let hasInvitation: boolean | undefined + await act(async () => { + hasInvitation = result.current.checkTokenInStorage() }) + expect(hasInvitation).toBe(true) const { redirectToAcceptPageGuard } = result.current await act(() => { redirectToAcceptPageGuard() @@ -61,9 +107,11 @@ describe('useInviteMember Hook', () => { it('should not redirect if we are already on login', async () => { mockUseLocation.mockReturnValue({ - search: '?inviteToken=123&organization=456', - pathname: LOGIN_URL, + pathname: '/login', + search: '', }) + localStorage.setItem('inviteToken', JSON.stringify('123')) + localStorage.setItem('inviteOrganizationId', JSON.stringify('456')) const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper }) @@ -82,9 +130,11 @@ describe('useInviteMember Hook', () => { it('should not redirect if we are already on accept page', async () => { mockUseLocation.mockReturnValue({ - search: '?inviteToken=123&organization=456', - pathname: ACCEPT_INVITATION_URL, + pathname: '/accept-invitation', + search: '', }) + localStorage.setItem('inviteToken', JSON.stringify('123')) + localStorage.setItem('inviteOrganizationId', JSON.stringify('456')) const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper }) @@ -102,12 +152,26 @@ describe('useInviteMember Hook', () => { expect(mockUseNavigate).not.toHaveBeenCalled() }) + it('should set displayInvitation to false when no invite is found in query or storage', async () => { + const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper }) + + let hasInvitation: boolean | undefined + await act(async () => { + hasInvitation = result.current.initializeInvitation() + }) + + expect(hasInvitation).toBe(false) + expect(result.current.displayInvitation).toBe(false) + expect(localStorage.getItem('inviteToken')).toBeNull() + expect(localStorage.getItem('inviteOrganizationId')).toBeNull() + }) + it('should remove the inviteToken from localStorage', async () => { - localStorage.setItem('inviteToken', '123') - localStorage.setItem('inviteOrganizationId', '456') + localStorage.setItem('inviteToken', JSON.stringify('123')) + localStorage.setItem('inviteOrganizationId', JSON.stringify('456')) mockUseLocation.mockReturnValue({ search: '?inviteToken=123&organization=456', - pathname: ACCEPT_INVITATION_URL, + pathname: '/accept-invitation', }) const { cleanInvitation } = renderHook(() => useInviteMember(), { wrapper: Wrapper }).result.current diff --git a/libs/domains/onboarding/feature/src/lib/hooks/use-invite-member/use-invite-member.tsx b/libs/domains/onboarding/feature/src/lib/hooks/use-invite-member/use-invite-member.tsx index 597f4ccd557..e5e23f45412 100644 --- a/libs/domains/onboarding/feature/src/lib/hooks/use-invite-member/use-invite-member.tsx +++ b/libs/domains/onboarding/feature/src/lib/hooks/use-invite-member/use-invite-member.tsx @@ -1,77 +1,94 @@ import { useAuth0 } from '@auth0/auth0-react' -import { useLocation, useNavigate } from '@tanstack/react-router' +import { useMatchRoute, useNavigate } from '@tanstack/react-router' import { type InviteMember } from 'qovery-typescript-axios' import { useCallback, useState } from 'react' import { useAcceptInviteMember, useMemberInvitation, useOrganizations } from '@qovery/domains/organizations/feature' import { useAuth } from '@qovery/shared/auth' -import { ACCEPT_INVITATION_URL, LOGIN_URL, LOGOUT_URL } from '@qovery/shared/routes' +import { useLocalStorage } from '@qovery/shared/util-hooks' + +function getInviteParams(search = '') { + const urlParams = new URLSearchParams(search) + + return { + inviteToken: urlParams.get('inviteToken') || undefined, + organizationId: urlParams.get('organization') || undefined, + } +} export function useInviteMember() { const [displayInvitation, setDisplayInvitation] = useState(undefined) const [organizationId, setOrganizationId] = useState() const [inviteId, setInviteId] = useState() const [inviteDetail, setInviteDetail] = useState() - const { search, pathname } = useLocation() + const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false) + const [storedInviteId, setStoredInviteId] = useLocalStorage('inviteToken', undefined) + const [storedOrganizationId, setStoredOrganizationId] = useLocalStorage( + 'inviteOrganizationId', + undefined + ) + const matchRoute = useMatchRoute() const navigate = useNavigate() const { isAuthenticated } = useAuth0() - const { getAccessTokenSilently } = useAuth() + const { authLogout, getAccessTokenSilently } = useAuth() const { mutateAsync: mutateAcceptInviteMember } = useAcceptInviteMember() const { refetch: refetchMemberInvitation } = useMemberInvitation({ organizationId, inviteId, enabled: false }) const { refetch: refetchOrganizations } = useOrganizations({ enabled: isAuthenticated, }) - const checkTokenInStorage = useCallback(() => { - const inviteToken = localStorage.getItem('inviteToken') - - if (inviteToken) { - // avoid redirected conflict, we bypass the normal redirecting - localStorage.removeItem('redirectLoginUri') - setInviteId(inviteToken) - setOrganizationId(localStorage.getItem('inviteOrganizationId') || '') - setDisplayInvitation(true) - } else { - setDisplayInvitation(false) - } - }, []) - - const onSearchUpdate = useCallback(() => { - // check if inviteToken query param is present in URL - // @ts-ignore-next-line TODO needs to be fixed - const urlParams = new URLSearchParams(search) - const inviteToken = urlParams.get('inviteToken') - - if (inviteToken) { - localStorage.setItem('inviteToken', inviteToken) - setInviteId(inviteToken) - - const organizationId = urlParams.get('organization') - if (organizationId) { - localStorage.setItem('inviteOrganizationId', organizationId) - setOrganizationId(organizationId) + const initializeInvitation = useCallback( + (search = '') => { + const { inviteToken: searchInviteToken, organizationId: searchOrganizationId } = getInviteParams(search) + + if (searchInviteToken) { + setStoredInviteId(searchInviteToken) } - // avoid redirected conflict, we bypass the normal redirecting - localStorage.removeItem('redirectLoginUri') - setDisplayInvitation(true) - } - }, [search, setOrganizationId, setInviteId, setDisplayInvitation]) + if (searchOrganizationId) { + setStoredOrganizationId(searchOrganizationId) + } + + const nextInviteId = searchInviteToken ?? storedInviteId + const nextOrganizationId = searchOrganizationId ?? storedOrganizationId + const hasInvitation = Boolean(nextInviteId && nextOrganizationId) + + if (searchInviteToken || hasInvitation) { + // avoid redirected conflict, we bypass the normal redirecting + localStorage.removeItem('redirectLoginUri') + } + + setInviteId(nextInviteId) + setOrganizationId(nextOrganizationId) + setDisplayInvitation(hasInvitation) + + return hasInvitation + }, + [setStoredInviteId, setStoredOrganizationId, storedInviteId, storedOrganizationId] + ) + + const checkTokenInStorage = useCallback(() => initializeInvitation(), [initializeInvitation]) + + const onSearchUpdate = useCallback(() => initializeInvitation(window.location.search), [initializeInvitation]) const redirectToAcceptPageGuard = useCallback(() => { - if (displayInvitation && pathname.indexOf(ACCEPT_INVITATION_URL) === -1 && pathname.indexOf(LOGIN_URL) === -1) { - navigate({ to: ACCEPT_INVITATION_URL }) + const isOnAcceptInvitationPage = matchRoute({ to: '/accept-invitation', fuzzy: true }) + const isOnLoginPage = matchRoute({ to: '/login', fuzzy: true }) + + if (displayInvitation && !isOnAcceptInvitationPage && !isOnLoginPage) { + navigate({ to: '/accept-invitation' }) } - }, [pathname, displayInvitation, navigate]) + }, [displayInvitation, matchRoute, navigate]) const cleanInvitation = () => { - localStorage.removeItem('inviteOrganizationId') - localStorage.removeItem('inviteToken') + setStoredOrganizationId(undefined) + setStoredInviteId(undefined) setInviteId(undefined) setOrganizationId(undefined) } const acceptInvitation = async () => { if (organizationId && inviteId) { + setIsAcceptingInvitation(true) try { await mutateAcceptInviteMember({ organizationId, inviteId }) cleanInvitation() @@ -81,14 +98,14 @@ export function useInviteMember() { window.location.assign(`/organization/${organizationId}`) } catch (e) { console.error(e) + setIsAcceptingInvitation(false) setDisplayInvitation(false) cleanInvitation() setTimeout(() => { window.location.assign(`/`) }) - // @ts-ignore-next-line - navigate({ to: LOGOUT_URL }) + void authLogout() } } } @@ -113,8 +130,10 @@ export function useInviteMember() { displayInvitation, fetchInvitationDetail, acceptInvitation, + isAcceptingInvitation, cleanInvitation, inviteDetail, + initializeInvitation, redirectToAcceptPageGuard, onSearchUpdate, checkTokenInStorage,