From c4b56bf3f213c9bbe58a6ec47f3055d31bdc5683 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Mon, 27 May 2024 18:39:19 +0200 Subject: [PATCH] chore(web): More auth refactoring - Simplify useAuth storage and only use local storage - Load useBlueprint only in one place --- .../{AuthContainer.tsx => AuthLayout.tsx} | 2 +- .../useOrganizationSelect.ts | 8 +- apps/web/src/components/utils/Spotlight.tsx | 14 +--- apps/web/src/hooks/useBlueprint.ts | 7 +- apps/web/src/hooks/useVercelParams.ts | 2 + apps/web/src/pages/auth/InvitationPage.tsx | 52 +++++------- apps/web/src/pages/auth/LoginPage.tsx | 76 +---------------- apps/web/src/pages/auth/PasswordResetPage.tsx | 30 +++---- apps/web/src/pages/auth/QuestionnairePage.tsx | 33 ++++---- apps/web/src/pages/auth/SignUpPage.tsx | 9 +- .../auth/components/HubspotSignupForm.tsx | 8 +- .../src/pages/auth/components/LoginForm.tsx | 69 ++++++++++----- .../auth/components/QuestionnaireForm.tsx | 17 ++-- .../src/pages/auth/components/SignUpForm.tsx | 28 +++---- .../pages/auth/components/useAcceptInvite.ts | 34 ++------ libs/shared-web/src/hooks/useAuth.ts | 84 ++++++++++--------- 16 files changed, 183 insertions(+), 290 deletions(-) rename apps/web/src/components/layout/components/{AuthContainer.tsx => AuthLayout.tsx} (95%) diff --git a/apps/web/src/components/layout/components/AuthContainer.tsx b/apps/web/src/components/layout/components/AuthLayout.tsx similarity index 95% rename from apps/web/src/components/layout/components/AuthContainer.tsx rename to apps/web/src/components/layout/components/AuthLayout.tsx index a939acc2dc2..f0cbbca4b21 100644 --- a/apps/web/src/components/layout/components/AuthContainer.tsx +++ b/apps/web/src/components/layout/components/AuthLayout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { colors, Text, Title, Container } from '@novu/design-system'; import PageMeta from './PageMeta'; -export default function AuthContainer({ +export default function AuthLayout({ title, description = '', children, diff --git a/apps/web/src/components/nav/OrganizationSelect/useOrganizationSelect.ts b/apps/web/src/components/nav/OrganizationSelect/useOrganizationSelect.ts index a5ca709b958..b82db5a8cc2 100644 --- a/apps/web/src/components/nav/OrganizationSelect/useOrganizationSelect.ts +++ b/apps/web/src/components/nav/OrganizationSelect/useOrganizationSelect.ts @@ -9,7 +9,6 @@ import { addOrganization, switchOrganization } from '../../../api/organization'; import { useSpotlightContext } from '../../providers/SpotlightProvider'; export const useOrganizationSelect = () => { - const [value, setValue] = useState(''); const [search, setSearch] = useState(''); const [loadingSwitch, setLoadingSwitch] = useState(false); const { addItem, removeItems } = useSpotlightContext(); @@ -60,6 +59,8 @@ export const useOrganizationSelect = () => { }); } + const value = currentOrganization?._id; + const organizationItems = useMemo(() => { return (organizations || []) .filter((item) => item._id !== value) @@ -72,13 +73,8 @@ export const useOrganizationSelect = () => { })); }, [organizations, value, switchOrgCallback]); - useEffect(() => { - setValue(currentOrganization?._id || ''); - }, [currentOrganization]); - useEffect(() => { removeItems(['change-org-' + value]); - addItem(organizationItems); }, [addItem, removeItems, organizationItems, value]); diff --git a/apps/web/src/components/utils/Spotlight.tsx b/apps/web/src/components/utils/Spotlight.tsx index e21a276d6f6..ddad688570d 100644 --- a/apps/web/src/components/utils/Spotlight.tsx +++ b/apps/web/src/components/utils/Spotlight.tsx @@ -4,7 +4,6 @@ import { UTM_CAMPAIGN_QUERY_PARAM } from '@novu/shared'; import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '@novu/shared-web'; import { ROUTES } from '../../constants/routes.enum'; import useThemeChange from '../../hooks/useThemeChange'; import { useSpotlightContext } from '../providers/SpotlightProvider'; @@ -13,9 +12,8 @@ import useStyles from './Spotlight.styles'; export const SpotLight = ({ children }) => { const navigate = useNavigate(); const { items, addItem } = useSpotlightContext(); - const { logout } = useAuth(); const { themeIcon, toggleColorScheme } = useThemeChange(); - const { classes, theme } = useStyles(); + const { classes } = useStyles(); useEffect(() => { addItem([ @@ -91,16 +89,8 @@ export const SpotLight = ({ children }) => { toggleColorScheme(); }, }, - { - id: 'sign-out', - title: 'Sign out', - icon: , - onTrigger: () => { - logout(); - }, - }, ]); - }, [navigate, addItem, themeIcon, toggleColorScheme, logout]); + }, [navigate, addItem, themeIcon, toggleColorScheme]); return ( diff --git a/apps/web/src/hooks/useBlueprint.ts b/apps/web/src/hooks/useBlueprint.ts index 9d594e4e123..bf13f1e096e 100644 --- a/apps/web/src/hooks/useBlueprint.ts +++ b/apps/web/src/hooks/useBlueprint.ts @@ -1,7 +1,6 @@ -import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useEffect } from 'react'; -import { useAuth } from '@novu/shared-web'; import { useSegment } from '../components/providers/SegmentProvider'; import { ROUTES } from '../constants/routes.enum'; @@ -9,10 +8,8 @@ export const useBlueprint = () => { const [params] = useSearchParams(); const blueprintId = params.get('blueprintId'); const navigate = useNavigate(); - const { pathname } = useLocation(); const segment = useSegment(); const id = localStorage.getItem('blueprintId'); - const { token } = useAuth(); useEffect(() => { if (id) { @@ -20,7 +17,7 @@ export const useBlueprint = () => { replace: true, }); } - }, [navigate, id, token, pathname]); + }, [navigate, id]); useEffect(() => { if (blueprintId) { diff --git a/apps/web/src/hooks/useVercelParams.ts b/apps/web/src/hooks/useVercelParams.ts index 26d0e62796e..83196b3f2a4 100644 --- a/apps/web/src/hooks/useVercelParams.ts +++ b/apps/web/src/hooks/useVercelParams.ts @@ -5,9 +5,11 @@ export function useVercelParams() { const code = params.get('code'); const next = params.get('next'); const configurationId = params.get('configurationId'); + const isFromVercel = !!(code && next); return { + params, code, next, configurationId, diff --git a/apps/web/src/pages/auth/InvitationPage.tsx b/apps/web/src/pages/auth/InvitationPage.tsx index ddca68ec554..a40ae8620cd 100644 --- a/apps/web/src/pages/auth/InvitationPage.tsx +++ b/apps/web/src/pages/auth/InvitationPage.tsx @@ -1,11 +1,11 @@ -import { useLocation, useNavigate, useParams, Link } from 'react-router-dom'; +import { useParams, Link } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { Center, LoadingOverlay } from '@mantine/core'; import { IGetInviteResponseDto } from '@novu/shared'; import { getInviteTokenData } from '../../api/invitation'; -import AuthContainer from '../../components/layout/components/AuthContainer'; +import AuthLayout from '../../components/layout/components/AuthLayout'; import { SignUpForm } from './components/SignUpForm'; import { colors, Text, Button } from '@novu/design-system'; import { useAuth } from '@novu/shared-web'; @@ -14,16 +14,10 @@ import { LoginForm } from './components/LoginForm'; export default function InvitationPage() { const queryClient = useQueryClient(); - const navigate = useNavigate(); - const { token, logout, currentUser } = useAuth(); - const location = useLocation(); - // TODO: Replace token check with currentUser check - const isLoggedIn = !!token; + const { currentUser, logout } = useAuth(); const { token: invitationToken } = useParams<{ token: string }>(); - const tokensRef = useRef({ token, invitationToken }); - tokensRef.current = { token, invitationToken }; - const { isLoading: isAcceptingInvite, submitToken } = useAcceptInvite(); - const { data, isInitialLoading } = useQuery( + const { isLoading: isAcceptingInvite, acceptInvite } = useAcceptInvite(); + const { data, isLoading: isInviteTokenDataLoading } = useQuery( ['getInviteTokenData'], () => getInviteTokenData(invitationToken || ''), { @@ -33,21 +27,15 @@ export default function InvitationPage() { ); const inviterFirstName = data?.inviter?.firstName || ''; const organizationName = data?.organization.name || ''; - const existingUser = !!(invitationToken && data?._userId); - const isLoggedInAsInvitedUser = !!(isLoggedIn && existingUser && currentUser && currentUser._id === data?._userId); - const Form = existingUser ? LoginForm : SignUpForm; - - const logoutWhenActiveSession = () => { - logout(); - navigate(location.pathname); - }; + const existingUserId = data?._userId; + const isLoggedInAsInvitedUser = !!(existingUserId && currentUser && currentUser._id === existingUserId); + const Form = existingUserId ? LoginForm : SignUpForm; useEffect(() => { - // auto accept invitation when logged in as invited user - if (isLoggedInAsInvitedUser) { - submitToken(tokensRef.current.token as string, tokensRef.current.invitationToken as string, true); + if (invitationToken && isLoggedInAsInvitedUser) { + acceptInvite(invitationToken); } - }, [isLoggedInAsInvitedUser, submitToken]); + }, [isLoggedInAsInvitedUser, acceptInvite, invitationToken]); useEffect(() => { return () => { @@ -55,8 +43,8 @@ export default function InvitationPage() { }; }, [queryClient]); - return isLoggedIn ? ( - @@ -70,7 +58,7 @@ export default function InvitationPage() { } > -
@@ -81,10 +69,10 @@ export default function InvitationPage() { Dashboard
-
+ ) : ( - @@ -107,7 +95,7 @@ export default function InvitationPage() { ) : undefined } > - {isInitialLoading ? ( + {isInviteTokenDataLoading ? ( )} - + ); } diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 337775376e5..c7bfb0a5bb8 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -1,78 +1,10 @@ -import { useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; - -import { useAuth } from '@novu/shared-web'; import { LoginForm } from './components/LoginForm'; -import AuthContainer from '../../components/layout/components/AuthContainer'; -import { useVercelIntegration, useBlueprint, useVercelParams } from '../../hooks'; -import SetupLoader from './components/SetupLoader'; -import { useSegment } from '../../components/providers/SegmentProvider'; -import { useAcceptInvite } from './components/useAcceptInvite'; -import { ROUTES } from '../../constants/routes.enum'; +import AuthLayout from '../../components/layout/components/AuthLayout'; export default function LoginPage() { - useBlueprint(); - const { login, token: oldToken, currentUser } = useAuth(); - const segment = useSegment(); - const navigate = useNavigate(); - const [params] = useSearchParams(); - const queryToken = params.get('token'); - const invitationToken = params.get('invitationToken'); - const source = params.get('source'); - const sourceWidget = params.get('source_widget'); - const token = queryToken ?? oldToken; - - const { startVercelSetup, isLoading } = useVercelIntegration(); - const { code, isFromVercel, next } = useVercelParams(); - const { isLoading: isLoadingAcceptInvite, submitToken } = useAcceptInvite(); - - useEffect(() => { - if (token) { - if (!invitationToken && currentUser?._id && (!currentUser?.organizationId || !currentUser?.environmentId)) { - const authApplicationLink = isFromVercel - ? `${ROUTES.AUTH_APPLICATION}?code=${code}&next=${next}` - : ROUTES.AUTH_APPLICATION; - login(token); - navigate(authApplicationLink); - - return; - } - - if (isFromVercel) { - login(token); - startVercelSetup(); - - return; - } - - if (source === 'cli') { - segment.track('Dashboard Visit', { - widget: sourceWidget || 'unknown', - source: 'cli', - }); - login(token); - navigate(ROUTES.GET_STARTED); - - return; - } - - if (invitationToken) { - submitToken(token, invitationToken); - - return; - } - - login(token); - navigate('/'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]); - - return isLoading || isLoadingAcceptInvite ? ( - - ) : ( - + return ( + - + ); } diff --git a/apps/web/src/pages/auth/PasswordResetPage.tsx b/apps/web/src/pages/auth/PasswordResetPage.tsx index 3f25d99b9b4..f5ff8e71bd6 100644 --- a/apps/web/src/pages/auth/PasswordResetPage.tsx +++ b/apps/web/src/pages/auth/PasswordResetPage.tsx @@ -1,39 +1,35 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useState } from 'react'; -import AuthContainer from '../../components/layout/components/AuthContainer'; +import AuthLayout from '../../components/layout/components/AuthLayout'; import { PasswordResetRequestForm } from './components/PasswordResetRequestForm'; import { PasswordResetForm } from './components/PasswordResetForm'; import { Button } from '@novu/design-system'; import { ROUTES } from '../../constants/routes.enum'; import { useVercelParams } from '../../hooks'; -type Props = {}; - -export function PasswordResetPage({}: Props) { +export function PasswordResetPage() { const navigate = useNavigate(); const { token } = useParams<{ token: string }>(); const [showSentSuccess, setShowSentSuccess] = useState(); - const { isFromVercel, code, next, configurationId } = useVercelParams(); + const { isFromVercel, params } = useVercelParams(); - const vercelQueryParams = `code=${code}&next=${next}&configurationId=${configurationId}`; - const loginLink = isFromVercel ? `/auth/login?${vercelQueryParams}` : ROUTES.AUTH_LOGIN; - function onSent() { - setShowSentSuccess(true); - } + const loginLink = isFromVercel ? `${ROUTES.AUTH_LOGIN}?${params.toString()}` : ROUTES.AUTH_LOGIN; - return showSentSuccess ? ( - - - ) : ( - - {!token && } + ; + } + + return ( + + {!token && setShowSentSuccess(true)} />} {token && } - + ); } diff --git a/apps/web/src/pages/auth/QuestionnairePage.tsx b/apps/web/src/pages/auth/QuestionnairePage.tsx index 2ef7288fc30..a583eebad90 100644 --- a/apps/web/src/pages/auth/QuestionnairePage.tsx +++ b/apps/web/src/pages/auth/QuestionnairePage.tsx @@ -1,32 +1,27 @@ -import AuthContainer from '../../components/layout/components/AuthContainer'; +import AuthLayout from '../../components/layout/components/AuthLayout'; import { QuestionnaireForm } from './components/QuestionnaireForm'; import { useVercelIntegration } from '../../hooks'; import SetupLoader from './components/SetupLoader'; -import { ENV, IS_DOCKER_HOSTED, useFeatureFlag } from '@novu/shared-web'; -import { HubspotSignupForm } from './components/HubspotSignupForm'; import { FeatureFlagsKeysEnum } from '@novu/shared'; -import { When } from '@novu/design-system'; +import { useFeatureFlag, HUBSPOT_PORTAL_ID } from '@novu/shared-web'; +import { HubspotSignupForm } from './components/HubspotSignupForm'; export default function QuestionnairePage() { + // TODO: Remove vercel integration logic from this page const { isLoading } = useVercelIntegration(); - const isHubspotFormEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_HUBSPOT_ONBOARDING_ENABLED); - const isNovuProd = !IS_DOCKER_HOSTED && ENV === 'production'; + const isHubspotFormFeatureFlagEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_HUBSPOT_ONBOARDING_ENABLED); + const isHubspotEnabled = HUBSPOT_PORTAL_ID && isHubspotFormFeatureFlagEnabled; - const shouldUseHubspotForm = isHubspotFormEnabled && isNovuProd; + if (isLoading) { + ; + } - return isLoading ? ( - - ) : ( - - - - - - - - + {isHubspotEnabled ? : } + ); } diff --git a/apps/web/src/pages/auth/SignUpPage.tsx b/apps/web/src/pages/auth/SignUpPage.tsx index c43d8a7ce0d..c2ffcf05bac 100644 --- a/apps/web/src/pages/auth/SignUpPage.tsx +++ b/apps/web/src/pages/auth/SignUpPage.tsx @@ -1,13 +1,10 @@ import { SignUpForm } from './components/SignUpForm'; -import AuthContainer from '../../components/layout/components/AuthContainer'; -import { useBlueprint } from '../../hooks'; +import AuthLayout from '../../components/layout/components/AuthLayout'; export default function SignUpPage() { - useBlueprint(); - return ( - + - + ); } diff --git a/apps/web/src/pages/auth/components/HubspotSignupForm.tsx b/apps/web/src/pages/auth/components/HubspotSignupForm.tsx index a978cb0d199..200d57b9aaa 100644 --- a/apps/web/src/pages/auth/components/HubspotSignupForm.tsx +++ b/apps/web/src/pages/auth/components/HubspotSignupForm.tsx @@ -17,7 +17,7 @@ import { successMessage } from '@novu/design-system'; export function HubspotSignupForm() { const [loading, setLoading] = useState(); const navigate = useNavigate(); - const { login, token, currentUser } = useAuth(); + const { login, currentUser } = useAuth(); const { startVercelSetup } = useVercelIntegration(); const { isFromVercel } = useVercelParams(); const { colorScheme } = useMantineColorScheme(); @@ -31,8 +31,8 @@ export function HubspotSignupForm() { >((data: ICreateOrganizationDto) => api.post(`/v1/organizations`, data)); useEffect(() => { - if (token) { - if (currentUser?.environmentId) { + if (currentUser) { + if (currentUser.environmentId) { if (isFromVercel) { startVercelSetup(); @@ -42,7 +42,7 @@ export function HubspotSignupForm() { navigate(ROUTES.HOME); } } - }, [token, navigate, isFromVercel, startVercelSetup, currentUser]); + }, [navigate, isFromVercel, startVercelSetup, currentUser]); async function createOrganization(data: IOrganizationCreateForm) { const { organizationName, jobTitle, ...rest } = data; diff --git a/apps/web/src/pages/auth/components/LoginForm.tsx b/apps/web/src/pages/auth/components/LoginForm.tsx index a8328891d7a..5e45a81f81a 100644 --- a/apps/web/src/pages/auth/components/LoginForm.tsx +++ b/apps/web/src/pages/auth/components/LoginForm.tsx @@ -1,14 +1,15 @@ -import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { useEffect } from 'react'; +import { Link, useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import * as Sentry from '@sentry/react'; import { Center } from '@mantine/core'; import { PasswordInput, Button, colors, Input, Text } from '@novu/design-system'; -import type { IResponseError } from '@novu/shared'; - import { useAuth } from '@novu/shared-web'; +import type { IResponseError } from '@novu/shared'; +import { useVercelIntegration, useVercelParams } from '../../../hooks'; +import { useSegment } from '../../../components/providers/SegmentProvider'; import { api } from '../../../api/api.client'; -import { useVercelParams } from '../../../hooks'; import { useAcceptInvite } from './useAcceptInvite'; import { ROUTES } from '../../../constants/routes.enum'; import { OAuth } from './OAuth'; @@ -26,10 +27,17 @@ export interface LocationState { } export function LoginForm({ email, invitationToken }: LoginFormProps) { + const segment = useSegment(); + const { login, currentUser } = useAuth(); + const { startVercelSetup } = useVercelIntegration(); + const { isFromVercel, params: vercelParams } = useVercelParams(); + const [params] = useSearchParams(); + const tokenInQuery = params.get('token'); + const source = params.get('source'); + const sourceWidget = params.get('source_widget'); + const { isLoading: isLoadingAcceptInvite, acceptInvite } = useAcceptInvite(); const navigate = useNavigate(); const location = useLocation(); - const state = location.state as LocationState; - const { login } = useAuth(); const { isLoading, mutateAsync, isError, error } = useMutation< { token: string }, IResponseError, @@ -38,12 +46,34 @@ export function LoginForm({ email, invitationToken }: LoginFormProps) { password: string; } >((data) => api.post('/v1/auth/login', data)); - const { isLoading: isLoadingAcceptInvite, submitToken } = useAcceptInvite(); - const { isFromVercel, code, next, configurationId } = useVercelParams(); - const vercelQueryParams = `code=${code}&next=${next}&configurationId=${configurationId}`; - const signupLink = isFromVercel ? `/auth/signup?${vercelQueryParams}` : ROUTES.AUTH_SIGNUP; - const resetPasswordLink = isFromVercel ? `/auth/reset/request?${vercelQueryParams}` : ROUTES.AUTH_RESET_REQUEST; + useEffect(() => { + if (tokenInQuery) { + debugger; + login(tokenInQuery); + } + + if (isFromVercel) { + startVercelSetup(); + + return; + } + + if (tokenInQuery && source === 'cli') { + segment.track('Dashboard Visit', { + widget: sourceWidget || 'unknown', + source: 'cli', + }); + navigate(ROUTES.GET_STARTED); + } + + navigate(ROUTES.GET_STARTED); + }, [currentUser]); + + const signupLink = isFromVercel ? `${ROUTES.AUTH_SIGNUP}?${params.toString()}` : ROUTES.AUTH_SIGNUP; + const resetPasswordLink = isFromVercel + ? `${ROUTES.AUTH_RESET_REQUEST}?${params.toString()}` + : ROUTES.AUTH_RESET_REQUEST; const { register, @@ -65,21 +95,16 @@ export function LoginForm({ email, invitationToken }: LoginFormProps) { try { const response = await mutateAsync(itemData); const token = (response as any).token; - if (isFromVercel) { - login(token); - - return; - } + login(token); if (invitationToken) { - submitToken(token, invitationToken); - - return; + const updatedToken = await acceptInvite(invitationToken); + if (updatedToken) { + login(updatedToken); + } } - login(token); - - navigate(state?.redirectTo?.pathname || ROUTES.WORKFLOWS); + navigate(ROUTES.WORKFLOWS); } catch (e: any) { if (e.statusCode !== 400) { Sentry.captureException(e); diff --git a/apps/web/src/pages/auth/components/QuestionnaireForm.tsx b/apps/web/src/pages/auth/components/QuestionnaireForm.tsx index 7c076372286..cc2e6853fcb 100644 --- a/apps/web/src/pages/auth/components/QuestionnaireForm.tsx +++ b/apps/web/src/pages/auth/components/QuestionnaireForm.tsx @@ -34,7 +34,7 @@ export function QuestionnaireForm() { control, } = useForm({}); const navigate = useNavigate(); - const { login, token, currentUser } = useAuth(); + const { login, currentUser } = useAuth(); const { startVercelSetup } = useVercelIntegration(); const { isFromVercel } = useVercelParams(); const { parse } = useDomainParser(); @@ -46,18 +46,15 @@ export function QuestionnaireForm() { >((data: ICreateOrganizationDto) => api.post(`/v1/organizations`, data)); useEffect(() => { - if (token) { - if (currentUser?.environmentId) { - if (isFromVercel) { - startVercelSetup(); + // TODO: Do we need this page. + if (currentUser?.environmentId) { + if (isFromVercel) { + startVercelSetup(); - return; - } - - navigate(ROUTES.HOME); + return; } } - }, [token, navigate, isFromVercel, startVercelSetup, currentUser]); + }, [navigate, isFromVercel, startVercelSetup, currentUser]); async function createOrganization(data: IOrganizationCreateForm) { const { organizationName, ...rest } = data; diff --git a/apps/web/src/pages/auth/components/SignUpForm.tsx b/apps/web/src/pages/auth/components/SignUpForm.tsx index 3d9567dcf5a..e6ca099c719 100644 --- a/apps/web/src/pages/auth/components/SignUpForm.tsx +++ b/apps/web/src/pages/auth/components/SignUpForm.tsx @@ -30,10 +30,9 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) { const navigate = useNavigate(); const { login } = useAuth(); - const { isLoading: loadingAcceptInvite, submitToken } = useAcceptInvite(); - const { isFromVercel, code, next, configurationId } = useVercelParams(); - const vercelQueryParams = `code=${code}&next=${next}&configurationId=${configurationId}`; - const loginLink = isFromVercel ? `/auth/login?${vercelQueryParams}` : ROUTES.AUTH_LOGIN; + const { isLoading: isAcceptInviteLoading, acceptInvite } = useAcceptInvite(); + const { params, isFromVercel } = useVercelParams(); + const loginLink = isFromVercel ? `${ROUTES.AUTH_LOGIN}?${params.toString()}` : ROUTES.AUTH_LOGIN; const { isLoading, mutateAsync, isError, error } = useMutation< { token: string }, @@ -56,24 +55,17 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) { }; const response = await mutateAsync(itemData); - - /** - * We need to call the applyToken to avoid a race condition for accept invite - * To get the correct token when sending the request - */ const token = (response as any).token; - // login(token); + login(token); if (invitationToken) { - submitToken(token, invitationToken); - - return true; + const updatedToken = await acceptInvite(invitationToken); + if (updatedToken) { + login(updatedToken); + } } - login(token); - navigate(isFromVercel ? `/auth/application?${vercelQueryParams}` : ROUTES.AUTH_APPLICATION); - - return true; + navigate(isFromVercel ? `${ROUTES.AUTH_APPLICATION}?${params.toString()}` : ROUTES.AUTH_APPLICATION); }; const { @@ -181,7 +173,7 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) { disabled={!accepted} mt={20} inherit - loading={isLoading || loadingAcceptInvite} + loading={isLoading || isAcceptInviteLoading} submit data-test-id="submitButton" > diff --git a/apps/web/src/pages/auth/components/useAcceptInvite.ts b/apps/web/src/pages/auth/components/useAcceptInvite.ts index 9bc0c941901..c27de5f8524 100644 --- a/apps/web/src/pages/auth/components/useAcceptInvite.ts +++ b/apps/web/src/pages/auth/components/useAcceptInvite.ts @@ -1,52 +1,32 @@ import { useCallback } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import type { IResponseError } from '@novu/shared'; import { api } from '../../../api/api.client'; -import { useAuth } from '@novu/shared-web'; -import { ROUTES } from '../../../constants/routes.enum'; import { errorMessage } from '../../../utils/notifications'; -import { LocationState } from './LoginForm'; export function useAcceptInvite() { - const { login } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - const state = location.state as LocationState; - const queryClient = useQueryClient(); - - const { isLoading, mutateAsync, error, isError } = useMutation((tokenItem) => + const { isLoading, mutateAsync, error } = useMutation((tokenItem) => api.post(`/v1/invites/${tokenItem}/accept`, {}) ); - const submitToken = useCallback( - async (token: string, invitationToken: string, refetch = false) => { + const acceptInvite = useCallback( + async (invitationToken: string) => { try { - const newToken = await mutateAsync(invitationToken); - login(newToken); - // TODO: This refetch shouldn't be necessary anymore. Remove it after testing. - if (refetch) { - await queryClient.refetchQueries({ - predicate: (query) => query.queryKey.includes('/v1/organizations'), - }); - } - navigate(state?.redirectTo?.pathname || ROUTES.WORKFLOWS); + return await mutateAsync(invitationToken); } catch (e: unknown) { errorMessage('Failed to accept an invite.'); Sentry.captureException(e); } }, - [mutateAsync, navigate, login, state?.redirectTo?.pathname, queryClient] + [mutateAsync] ); return { + acceptInvite, isLoading, - mutateAsync, - submitToken, error, - isError, }; } diff --git a/libs/shared-web/src/hooks/useAuth.ts b/libs/shared-web/src/hooks/useAuth.ts index a22fc62ae6b..35ed5d1d400 100644 --- a/libs/shared-web/src/hooks/useAuth.ts +++ b/libs/shared-web/src/hooks/useAuth.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useState, useMemo } from 'react'; +import { useEffect, useCallback, useLayoutEffect, useMemo } from 'react'; import { useLDClient } from 'launchdarkly-react-client-sdk'; import jwtDecode from 'jwt-decode'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -27,7 +27,7 @@ function getOrganizations() { return api.get(`/v1/organizations`); } -function setTokenInStorage(token: string | null) { +function saveToken(token: string | null) { if (token) { localStorage.setItem(LOCAL_STORAGE_AUTH_TOKEN_KEY, token); } else { @@ -35,49 +35,35 @@ function setTokenInStorage(token: string | null) { } } -function getTokenFromStorage(): string { +export function getToken(): string { return localStorage.getItem(LOCAL_STORAGE_AUTH_TOKEN_KEY) || ''; } +export function getTokenClaims(): IJwtClaims | null { + const token = getToken(); + + return token ? jwtDecode(token) : null; +} + export function useAuth() { const ldClient = useLDClient(); const segment = useSegment(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); - const [token, setToken] = useState(getTokenFromStorage()); const inPublicRoute = PUBLIC_ROUTES.has(location.pathname as any); const inPrivateRoute = !inPublicRoute; - - const login = useCallback((newToken: string) => { - if (newToken) { - setTokenInStorage(newToken); - setToken(newToken); - refetchOrganizations(); - } else { - logout(); - } - }, []); - - const logout = useCallback(() => { - setToken(null); - setTokenInStorage(null); - queryClient.clear(); - navigate(ROUTES.AUTH_LOGIN); - segment.reset(); - }, []); - - const redirectToLogin = useCallback(() => navigate(ROUTES.AUTH_LOGIN), [navigate]); + const hasToken = !!getToken(); useEffect(() => { - if (!token && inPrivateRoute) { - redirectToLogin(); + if (!getToken() && inPrivateRoute) { + navigate(ROUTES.AUTH_LOGIN); } - }, [redirectToLogin, token, inPublicRoute]); + }, [navigate, inPrivateRoute]); const { data: user, isLoading: isUserLoading } = useQuery(['/v1/users/me'], getUser, { - enabled: !!token && inPrivateRoute, + enabled: hasToken, retry: false, onError: (error: any) => { if (error?.statusCode === UNAUTHENTICATED_STATUS_CODE) { @@ -91,7 +77,7 @@ export function useAuth() { isLoading: isOrganizationLoading, refetch: refetchOrganizations, } = useQuery(['/v1/organizations'], getOrganizations, { - enabled: inPrivateRoute, + enabled: hasToken, retry: false, onError: (error: any) => { if (error?.statusCode === UNAUTHENTICATED_STATUS_CODE) { @@ -100,16 +86,36 @@ export function useAuth() { }, }); - const claims = useMemo(() => (token ? jwtDecode(token) : null), [token]); + const login = useCallback( + (newToken: string, redirectUrl?: string) => { + if (!newToken) { + return; + } + + saveToken(newToken); + refetchOrganizations(); + + redirectUrl ? navigate(redirectUrl) : void 0; + }, + [navigate, refetchOrganizations] + ); + + const logout = useCallback(() => { + saveToken(null); + queryClient.clear(); + segment.reset(); + navigate(ROUTES.AUTH_LOGIN); + }, [navigate]); + + const { organizationId, environmentId } = getTokenClaims() || {}; const currentOrganization = useMemo(() => { - const { organizationId } = claims || {}; if (organizationId && organizations?.length > 0) { return organizations.find((org) => org._id === organizationId); } return null; - }, [claims, organizations]); + }, [organizations, organizationId]); useEffect(() => { if (user && currentOrganization) { @@ -151,16 +157,16 @@ export function useAuth() { inPrivateRoute, isLoading: inPrivateRoute && (isUserLoading || isOrganizationLoading), // TODO: Remove orgId and envId from currentUser and add them to the useAuth hook returned object - currentUser: { - ...user, - organizationId: claims?.organizationId, - environmentId: claims?.environmentId, - } satisfies IUserWithContext, + currentUser: user + ? ({ + ...user, + organizationId, + environmentId, + } satisfies IUserWithContext) + : null, organizations, currentOrganization, - token, login, logout, - claims, }; }