diff --git a/apps/web/package.json b/apps/web/package.json index d427a18a193..97bc1a9b515 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -90,7 +90,6 @@ "eslint-plugin-react-hooks": "^4.4.0", "handlebars": "^4.7.7", "html-webpack-plugin": "5.5.3", - "jwt-decode": "^3.1.2", "launchdarkly-react-client-sdk": "^3.0.6", "less": "^4.1.0", "localforage": "^1.10.0", diff --git a/apps/web/src/AppRoutes.tsx b/apps/web/src/AppRoutes.tsx index e253788d20e..07f902bd608 100644 --- a/apps/web/src/AppRoutes.tsx +++ b/apps/web/src/AppRoutes.tsx @@ -1,7 +1,7 @@ import { FeatureFlagsKeysEnum } from '@novu/shared'; import { Route, Routes } from 'react-router-dom'; import { AppLayout } from './components/layout/AppLayout'; -import { RequiredAuth } from './components/layout/RequiredAuth'; +import { EnsureOnboardingComplete } from './components/layout/EnsureOnboardingComplete'; import { ROUTES } from './constants/routes.enum'; import { useFeatureFlag } from './hooks'; import { ActivitiesPage } from './pages/activities/ActivitiesPage'; @@ -62,17 +62,17 @@ export const AppRoutes = () => { + - + } /> + - + } /> }> diff --git a/apps/web/src/Providers.tsx b/apps/web/src/Providers.tsx index 7f371d85f39..9d158719b89 100644 --- a/apps/web/src/Providers.tsx +++ b/apps/web/src/Providers.tsx @@ -1,4 +1,4 @@ -import { Loader } from '@mantine/core'; +import { ColorSchemeProvider, Loader } from '@mantine/core'; import { colors } from '@novu/design-system'; import { CONTEXT_PATH, SegmentProvider } from '@novu/shared-web'; import * as Sentry from '@sentry/react'; @@ -48,17 +48,19 @@ const fallbackDisplay = ( */ const Providers: React.FC> = ({ children }) => { return ( - - - - - - {children} - - - - - + {}}> + + + + + + {children} + + + + + + ); }; diff --git a/apps/web/src/components/launch-darkly/LaunchDarklyProvider.tsx b/apps/web/src/components/launch-darkly/LaunchDarklyProvider.tsx index 191348a5369..10cfdd1a61f 100644 --- a/apps/web/src/components/launch-darkly/LaunchDarklyProvider.tsx +++ b/apps/web/src/components/launch-darkly/LaunchDarklyProvider.tsx @@ -2,10 +2,9 @@ import * as Sentry from '@sentry/react'; import { IOrganizationEntity } from '@novu/shared'; import { asyncWithLDProvider } from 'launchdarkly-react-client-sdk'; -import { PropsWithChildren, ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react'; import { useFeatureFlags, useAuthContext, LAUNCH_DARKLY_CLIENT_SIDE_ID } from '@novu/shared-web'; import { selectShouldInitializeLaunchDarkly } from './utils/selectShouldInitializeLaunchDarkly'; -import { selectShouldShowLaunchDarklyFallback } from './utils/selectShouldShowLaunchDarklyFallback'; /** A provider with children required */ type GenericLDProvider = Awaited>; @@ -13,22 +12,13 @@ type GenericLDProvider = Awaited>; /** Simply renders the children */ const DEFAULT_GENERIC_PROVIDER: GenericLDProvider = ({ children }) => <>{children}; -export interface ILaunchDarklyProviderProps { - /** Renders when LaunchDarkly is enabled and is awaiting initialization */ - fallbackDisplay: ReactNode; -} - /** * Async provider for feature flags. * * @requires AuthProvider must be wrapped in the AuthProvider. */ -export const LaunchDarklyProvider: React.FC> = ({ - children, - fallbackDisplay, -}) => { +export const LaunchDarklyProvider: React.FC> = ({ children }) => { const LDProvider = useRef(DEFAULT_GENERIC_PROVIDER); - const [isLDReady, setIsLDReady] = useState(false); const authContext = useAuthContext(); if (!authContext) { @@ -68,21 +58,11 @@ export const LaunchDarklyProvider: React.FC{fallbackDisplay}; - } + }, [shouldInitializeLd, currentOrganization]); return ( diff --git a/apps/web/src/components/launch-darkly/utils/selectShouldInitializeLaunchDarkly.tsx b/apps/web/src/components/launch-darkly/utils/selectShouldInitializeLaunchDarkly.tsx index d8131950cfd..db3e2a7499f 100644 --- a/apps/web/src/components/launch-darkly/utils/selectShouldInitializeLaunchDarkly.tsx +++ b/apps/web/src/components/launch-darkly/utils/selectShouldInitializeLaunchDarkly.tsx @@ -1,9 +1,9 @@ -import { selectHasUserCompletedSignUp, UserContext } from '@novu/shared-web'; +import { UserContext } from '@novu/shared-web'; import { checkShouldUseLaunchDarkly } from '@novu/shared-web'; /** Determine if LaunchDarkly should be initialized based on the current auth context */ export function selectShouldInitializeLaunchDarkly(userCtx: UserContext): boolean { - const { isLoggedIn, currentOrganization } = userCtx; + const { isLoggedIn, currentUser, currentOrganization } = userCtx; // don't show fallback if LaunchDarkly isn't enabled if (!checkShouldUseLaunchDarkly()) { return false; @@ -22,7 +22,7 @@ export function selectShouldInitializeLaunchDarkly(userCtx: UserContext): boolea * have an organizationId yet that we can use for org-based feature flags. To prevent from blocking this page * from loading during this "limbo" state, we should initialize LD with the anonymous context. */ - if (!selectHasUserCompletedSignUp(userCtx)) { + if (!currentUser?.organizationId) { return true; } diff --git a/apps/web/src/components/launch-darkly/utils/selectShouldShowLaunchDarklyFallback.tsx b/apps/web/src/components/launch-darkly/utils/selectShouldShowLaunchDarklyFallback.tsx deleted file mode 100644 index aa4e9839abe..00000000000 --- a/apps/web/src/components/launch-darkly/utils/selectShouldShowLaunchDarklyFallback.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { selectHasUserCompletedSignUp, UserContext, checkShouldUseLaunchDarkly } from '@novu/shared-web'; - -/** Determine if a fallback should be shown instead of the provider-wrapped application */ -export function selectShouldShowLaunchDarklyFallback(userCtx: UserContext, isLDReady: boolean): boolean { - const { isLoggedIn, currentOrganization } = userCtx; - // don't show fallback if LaunchDarkly isn't enabled - if (!checkShouldUseLaunchDarkly()) { - return false; - } - - // don't show fallback for unauthenticated areas of the app - if (!isLoggedIn) { - return false; - } - - // don't show fallback if user is still in onboarding - if (!selectHasUserCompletedSignUp(userCtx)) { - return false; - } - - // if the organization is not loaded or we haven't loaded LD, show the fallback - return !currentOrganization || !isLDReady; -} diff --git a/apps/web/src/components/layout/AppLayout.tsx b/apps/web/src/components/layout/AppLayout.tsx index 589a809d678..8f002c93ffb 100644 --- a/apps/web/src/components/layout/AppLayout.tsx +++ b/apps/web/src/components/layout/AppLayout.tsx @@ -8,7 +8,7 @@ import { HeaderNav } from './components/HeaderNav'; import { SideNav } from './components/SideNav'; import { IntercomProvider } from 'react-use-intercom'; import { INTERCOM_APP_ID } from '../../config'; -import { RequiredAuth } from './RequiredAuth'; +import { EnsureOnboardingComplete } from './EnsureOnboardingComplete'; import { SpotLight } from '../utils/Spotlight'; import { SpotLightProvider } from '../providers/SpotlightProvider'; import { useFeatureFlag } from '@novu/shared-web'; @@ -37,7 +37,7 @@ export function AppLayout() { const isInformationArchitectureEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_INFORMATION_ARCHITECTURE_ENABLED); return ( - + - + ); } diff --git a/apps/web/src/components/layout/EnsureOnboardingComplete.tsx b/apps/web/src/components/layout/EnsureOnboardingComplete.tsx new file mode 100644 index 00000000000..7f04619df67 --- /dev/null +++ b/apps/web/src/components/layout/EnsureOnboardingComplete.tsx @@ -0,0 +1,15 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { ROUTES } from '../../constants/routes.enum'; +import { useBlueprint, useAuthController } from '../../hooks/index'; + +export function EnsureOnboardingComplete({ children }: any) { + useBlueprint(); + const location = useLocation(); + const { user } = useAuthController(); + + if ((!user?.organizationId || !user?.environmentId) && location.pathname !== ROUTES.AUTH_APPLICATION) { + return ; + } else { + return children; + } +} diff --git a/apps/web/src/components/layout/RequiredAuth.tsx b/apps/web/src/components/layout/RequiredAuth.tsx deleted file mode 100644 index 251ccd9eb6e..00000000000 --- a/apps/web/src/components/layout/RequiredAuth.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Navigate, useLocation } from 'react-router-dom'; -import { ROUTES } from '../../constants/routes.enum'; -import { useBlueprint, getToken, getTokenPayload } from '../../hooks'; -import { useAuthContext } from '../providers/AuthProvider'; -import decode from 'jwt-decode'; -import { IJwtPayload } from '@novu/shared'; - -function jwtHasKey(key: string) { - const token = getToken(); - - if (!token) return false; - const jwt = decode(token); - - return jwt && jwt[key]; -} - -export function RequiredAuth({ children }: any) { - useBlueprint(); - const { logout } = useAuthContext(); - const location = useLocation(); - - // TODO: remove after env migration - const payload = getTokenPayload(); - if (payload && (payload as any).applicationId) { - logout(); - window.location.reload(); - } - - // Logout if token.exp is in the past (expired) - if (payload && payload.exp && payload.exp <= Date.now() / 1000) { - logout(); - - return null; - } - - if (!getToken()) { - return ; - } else if ( - !jwtHasKey('organizationId') || - (!jwtHasKey('environmentId') && location.pathname !== ROUTES.AUTH_APPLICATION) - ) { - return ; - } else { - return children; - } -} diff --git a/apps/web/src/hooks/useAuthController.ts b/apps/web/src/hooks/useAuthController.ts index 04da0f0d1eb..bebd249339a 100644 --- a/apps/web/src/hooks/useAuthController.ts +++ b/apps/web/src/hooks/useAuthController.ts @@ -1,3 +1,3 @@ -import { useAuthController, applyToken, getTokenPayload, getToken } from '@novu/shared-web'; +import { useAuthController, applyToken } from '@novu/shared-web'; -export { useAuthController, applyToken, getTokenPayload, getToken }; +export { useAuthController, applyToken }; diff --git a/apps/web/src/hooks/useBlueprint.ts b/apps/web/src/hooks/useBlueprint.ts index 32126ca934e..a6bc79ee553 100644 --- a/apps/web/src/hooks/useBlueprint.ts +++ b/apps/web/src/hooks/useBlueprint.ts @@ -1,7 +1,7 @@ import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { useEffect } from 'react'; -import { getToken } from './useAuthController'; +import { useAuthController } from './useAuthController'; import { useSegment } from '../components/providers/SegmentProvider'; import { ROUTES } from '../constants/routes.enum'; @@ -12,10 +12,10 @@ export const useBlueprint = () => { const { pathname } = useLocation(); const segment = useSegment(); const id = localStorage.getItem('blueprintId'); - const token = getToken(); + const { token } = useAuthController(); useEffect(() => { - if (id && token !== null) { + if (id && !!token) { navigate(ROUTES.WORKFLOWS_CREATE, { replace: true, }); diff --git a/apps/web/src/hooks/useVercelIntegration.ts b/apps/web/src/hooks/useVercelIntegration.ts index 0150c3f72b6..90a34723a24 100644 --- a/apps/web/src/hooks/useVercelIntegration.ts +++ b/apps/web/src/hooks/useVercelIntegration.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import { useCallback } from 'react'; import { useMutation } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; @@ -11,11 +10,10 @@ import { vercelIntegrationSetup } from '../api/vercel-integration'; export function useVercelIntegration() { const { token } = useAuthContext(); const isLoggedIn = !!token; - const isAxiosAuthorized = axios.defaults.headers.common.Authorization; const { code, next, configurationId } = useVercelParams(); - const canStartSetup = Boolean(code && next && isLoggedIn && isAxiosAuthorized); + const canStartSetup = Boolean(code && next && isLoggedIn); const navigate = useNavigate(); diff --git a/apps/web/src/initializeApp.ts b/apps/web/src/initializeApp.ts index 9ab1dfeb6db..5e120726968 100644 --- a/apps/web/src/initializeApp.ts +++ b/apps/web/src/initializeApp.ts @@ -1,7 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { far } from '@fortawesome/free-regular-svg-icons'; import { fas } from '@fortawesome/free-solid-svg-icons'; -import { applyToken, getToken } from '@novu/shared-web'; import * as Sentry from '@sentry/react'; import { Integrations } from '@sentry/tracing'; import { ENV, SENTRY_DSN } from './config'; @@ -57,8 +56,4 @@ export const initializeApp = () => { }, }); } - - const tokenStoredToken: string = getToken(); - - applyToken(tokenStoredToken); }; diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 9d466b290d1..a36ebe30aaa 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -1,7 +1,5 @@ import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import jwtDecode from 'jwt-decode'; -import { IJwtPayload } from '@novu/shared'; import { useAuthContext } from '../../components/providers/AuthProvider'; import { LoginForm } from './components/LoginForm'; @@ -15,7 +13,7 @@ import { ROUTES } from '../../constants/routes.enum'; export default function LoginPage() { useBlueprint(); - const { setToken, token: oldToken } = useAuthContext(); + const { setToken, token: oldToken, currentUser } = useAuthContext(); const segment = useSegment(); const navigate = useNavigate(); const [params] = useSearchParams(); @@ -31,9 +29,7 @@ export default function LoginPage() { useEffect(() => { if (token) { - const user = jwtDecode(token); - - if (!invitationToken && (!user.organizationId || !user.environmentId)) { + if (!invitationToken && currentUser?._id && (!currentUser?.organizationId || !currentUser?.environmentId)) { const authApplicationLink = isFromVercel ? `${ROUTES.AUTH_APPLICATION}?code=${code}&next=${next}` : ROUTES.AUTH_APPLICATION; diff --git a/apps/web/src/pages/auth/components/HubspotSignupForm.tsx b/apps/web/src/pages/auth/components/HubspotSignupForm.tsx index 9d1f7c8d29c..138c9f9dee6 100644 --- a/apps/web/src/pages/auth/components/HubspotSignupForm.tsx +++ b/apps/web/src/pages/auth/components/HubspotSignupForm.tsx @@ -1,12 +1,11 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; -import decode from 'jwt-decode'; import { useMantineColorScheme } from '@mantine/core'; import { JobTitleEnum } from '@novu/shared'; import type { ProductUseCases, IResponseError, ICreateOrganizationDto, IJwtPayload } from '@novu/shared'; -import { HubspotForm, useSegment } from '@novu/shared-web'; +import { HubspotForm, useAuthController, useSegment } from '@novu/shared-web'; import { api } from '../../../api/api.client'; import { useAuthContext } from '../../../components/providers/AuthProvider'; @@ -25,6 +24,7 @@ export function HubspotSignupForm() { const { colorScheme } = useMantineColorScheme(); const segment = useSegment(); + const { user } = useAuthController(); const { mutateAsync: createOrganizationMutation } = useMutation< { _id: string }, @@ -34,9 +34,7 @@ export function HubspotSignupForm() { useEffect(() => { if (token) { - const userData = decode(token); - - if (userData.environmentId) { + if (user?.environmentId) { if (isFromVercel) { startVercelSetup(); @@ -46,7 +44,7 @@ export function HubspotSignupForm() { navigate(ROUTES.HOME); } } - }, [token, navigate, isFromVercel, startVercelSetup]); + }, [token, navigate, isFromVercel, startVercelSetup, user]); async function createOrganization(data: IOrganizationCreateForm) { const { organizationName, jobTitle, ...rest } = data; @@ -60,13 +58,6 @@ export function HubspotSignupForm() { setToken(organizationResponseToken); } - function jwtHasKey(key: string) { - if (!token) return false; - const jwt = decode(token); - - return jwt && jwt[key]; - } - const handleCreateOrganization = async (data: IOrganizationCreateForm) => { if (!data?.organizationName) return; @@ -74,7 +65,7 @@ export function HubspotSignupForm() { setLoading(true); - if (!jwtHasKey('organizationId')) { + if (!user?.organizationId) { await createOrganization({ ...data }); } diff --git a/apps/web/src/pages/auth/components/QuestionnaireForm.tsx b/apps/web/src/pages/auth/components/QuestionnaireForm.tsx index aaa72d8cff4..f3e06e73fa6 100644 --- a/apps/web/src/pages/auth/components/QuestionnaireForm.tsx +++ b/apps/web/src/pages/auth/components/QuestionnaireForm.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Controller, useForm } from 'react-hook-form'; import { useMutation } from '@tanstack/react-query'; -import decode from 'jwt-decode'; import { Group, Input as MantineInput } from '@mantine/core'; import { JobTitleEnum, jobTitleToLabelMapper, ProductUseCasesEnum } from '@novu/shared'; @@ -21,7 +20,7 @@ import { import { api } from '../../../api/api.client'; import { useAuthContext } from '../../../components/providers/AuthProvider'; -import { useVercelIntegration, useVercelParams } from '../../../hooks'; +import { useAuthController, useVercelIntegration, useVercelParams } from '../../../hooks'; import { ROUTES } from '../../../constants/routes.enum'; import { DynamicCheckBox } from './dynamic-checkbox/DynamicCheckBox'; import styled from '@emotion/styled/macro'; @@ -40,6 +39,7 @@ export function QuestionnaireForm() { const { isFromVercel } = useVercelParams(); const { parse } = useDomainParser(); + const { user } = useAuthController(); const { mutateAsync: createOrganizationMutation } = useMutation< { _id: string }, IResponseError, @@ -48,9 +48,7 @@ export function QuestionnaireForm() { useEffect(() => { if (token) { - const userData = decode(token); - - if (userData.environmentId) { + if (user?.environmentId) { if (isFromVercel) { startVercelSetup(); @@ -60,7 +58,7 @@ export function QuestionnaireForm() { navigate(ROUTES.HOME); } } - }, [token, navigate, isFromVercel, startVercelSetup]); + }, [token, navigate, isFromVercel, startVercelSetup, user]); async function createOrganization(data: IOrganizationCreateForm) { const { organizationName, ...rest } = data; @@ -70,19 +68,12 @@ export function QuestionnaireForm() { setToken(organizationResponseToken); } - function jwtHasKey(key: string) { - if (!token) return false; - const jwt = decode(token); - - return jwt && jwt[key]; - } - const onCreateOrganization = async (data: IOrganizationCreateForm) => { if (!data?.organizationName) return; setLoading(true); - if (!jwtHasKey('organizationId')) { + if (!user?.organizationId) { await createOrganization({ ...data }); } diff --git a/apps/widget/package.json b/apps/widget/package.json index 40d174d02d8..832f1f8aaea 100644 --- a/apps/widget/package.json +++ b/apps/widget/package.json @@ -36,7 +36,6 @@ "chroma-js": "^2.4.2", "eslint-plugin-cypress": "^2.15.1", "iframe-resizer": "^4.3.1", - "jwt-decode": "^3.1.2", "polished": "^4.1.2", "react": "^17.0.1", "react-dom": "^17.0.1", diff --git a/libs/design-system/package.json b/libs/design-system/package.json index 22d28edeaaa..04fe4fa13fc 100644 --- a/libs/design-system/package.json +++ b/libs/design-system/package.json @@ -44,7 +44,6 @@ "@sentry/react": "^7.40.0", "@tanstack/react-query": "^4.20.4", "axios": "^1.3.3", - "jwt-decode": "^3.1.2", "react-helmet-async": "^1.3.0", "react-hook-form": "7.43.9", "react-icons": "^5.0.1", diff --git a/libs/shared-web/src/api/api.client.ts b/libs/shared-web/src/api/api.client.ts index c7000585f77..f928650287b 100644 --- a/libs/shared-web/src/api/api.client.ts +++ b/libs/shared-web/src/api/api.client.ts @@ -89,5 +89,5 @@ function getHeaders() { ? { Authorization: `Bearer ${token}`, } - : undefined; + : {}; } diff --git a/libs/shared-web/src/hooks/useAuthController.ts b/libs/shared-web/src/hooks/useAuthController.ts index a76f86c5b43..c153b888ca1 100644 --- a/libs/shared-web/src/hooks/useAuthController.ts +++ b/libs/shared-web/src/hooks/useAuthController.ts @@ -1,13 +1,22 @@ import { useEffect, useCallback, useState } from 'react'; -import axios from 'axios'; import jwtDecode from 'jwt-decode'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import type { IJwtPayload, IOrganizationEntity, IUserEntity } from '@novu/shared'; import { useSegment } from '../providers'; import { api } from '../api'; +import { ROUTES } from '../constants'; + +const LOCAL_STORAGE_AUTH_TOKEN_KEY = 'auth_token'; +const UNAUTHENTICATED_STATUS_CODE = 401; +const UNAUTHORIZED_ROUTES = [ROUTES.AUTH_LOGIN, ROUTES.AUTH_SIGNUP, ROUTES.AUTH_RESET_REQUEST, ROUTES.AUTH_RESET_TOKEN]; + +export interface IUserWithContext extends IUserEntity { + organizationId?: string; + environmentId?: string; +} function getUser() { return api.get('/v1/users/me'); @@ -18,56 +27,61 @@ function getOrganizations() { } export function applyToken(token: string | null) { - if (token) { - localStorage.setItem('auth_token', token); - axios.defaults.headers.common.Authorization = `Bearer ${token}`; + if (token !== null) { + localStorage.setItem(LOCAL_STORAGE_AUTH_TOKEN_KEY, token); } else { - localStorage.removeItem('auth_token'); - delete axios.defaults.headers.common.Authorization; + localStorage.removeItem(LOCAL_STORAGE_AUTH_TOKEN_KEY); } } -export function getTokenPayload() { - const token = getToken(); - if (!token) return null; - - return jwtDecode(token); -} - -export function getToken(): string { - return localStorage.getItem('auth_token') as string; +function getToken(): string | null { + const token = localStorage.getItem(LOCAL_STORAGE_AUTH_TOKEN_KEY); + if (!token) { + return null; + } else { + return token; + } } export function useAuthController() { const segment = useSegment(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const [token, setToken] = useState(() => { - const initialToken = getToken(); - applyToken(initialToken); + const location = useLocation(); + + const [token, setToken] = useState(getToken()); - return initialToken; - }); - const [jwtPayload, setJwtPayload] = useState(() => { - const initialToken = getToken(); - if (initialToken) { - return jwtDecode(initialToken); - } - }); const [organization, setOrganization] = useState(); - const isLoggedIn = !!token; + const isLoginPage = UNAUTHORIZED_ROUTES.includes(location.pathname as any); + const isLoggedIn = !!token && !isLoginPage; + + /* + * TODO: Decoding a JWT token on a browser is a security risk. + * We should modify the `/users/me` endpoint to return the organizationId and environmentId + * and then we can remove the jwtPayload state and the setJwtPayload function. + */ + const [jwtPayload, setJwtPayload] = useState(token && jwtDecode(token)); + + useEffect(() => { + if (!token && !isLoginPage) { + navigate(ROUTES.AUTH_LOGIN); + } + }, [token, navigate, isLoginPage]); const { data: user, isLoading: isUserLoading } = useQuery(['/v1/users/me'], getUser, { - enabled: Boolean(isLoggedIn && axios.defaults.headers.common.Authorization), + retry: false, + enabled: isLoggedIn, + onError: (error: any) => { + if (error?.statusCode === UNAUTHENTICATED_STATUS_CODE) { + applyToken(null); + logout(); + } + }, }); - const authorization = axios.defaults.headers.common.Authorization as string; const { data: organizations } = useQuery(['/v1/organizations'], getOrganizations, { - enabled: Boolean( - isLoggedIn && - axios.defaults.headers.common.Authorization && - jwtDecode(authorization?.split(' ')[1])?.organizationId - ), + enabled: isLoggedIn, + retry: 0, }); useEffect(() => { @@ -105,7 +119,6 @@ export function useAuthController() { if (refetch) { queryClient.refetchQueries({ predicate: (query) => - // !query.isFetching && !query.queryKey.includes('/v1/users/me') && !query.queryKey.includes('/v1/environments') && !query.queryKey.includes('/v1/organizations') && @@ -122,19 +135,22 @@ export function useAuthController() { const logout = () => { setTokenCallback(null); queryClient.clear(); - navigate('/auth/login'); + navigate(ROUTES.AUTH_LOGIN); segment.reset(); }; return { isLoggedIn, - user, - isUserLoading, + user: { + ...user, + organizationId: jwtPayload?.organizationId, + environmentId: jwtPayload?.environmentId, + } satisfies IUserWithContext, + isUserLoading: isUserLoading && isLoggedIn, organizations, organization, token, logout, - jwtPayload, setToken: setTokenCallback, }; } diff --git a/libs/shared-web/src/hooks/useEnvController.ts b/libs/shared-web/src/hooks/useEnvController.ts index e2829fdc4c8..16db2aa2eab 100644 --- a/libs/shared-web/src/hooks/useEnvController.ts +++ b/libs/shared-web/src/hooks/useEnvController.ts @@ -10,7 +10,7 @@ import { useNavigate } from 'react-router-dom'; import { ROUTES } from '../constants/routes.enum'; import { api } from '../api'; import { IS_DOCKER_HOSTED } from '../config'; -import { BaseEnvironmentEnum } from 'src/constants'; +import { BaseEnvironmentEnum } from '../constants'; interface ISetEnvironmentOptions { /** using null will prevent a reroute */ diff --git a/libs/shared-web/src/providers/AuthProvider.tsx b/libs/shared-web/src/providers/AuthProvider.tsx index 4534bf64587..b160eb69094 100644 --- a/libs/shared-web/src/providers/AuthProvider.tsx +++ b/libs/shared-web/src/providers/AuthProvider.tsx @@ -1,17 +1,16 @@ -import React, { useContext } from 'react'; -import { IOrganizationEntity, IUserEntity, IJwtPayload } from '@novu/shared'; -import { useAuthController } from '../hooks'; +import React, { PropsWithChildren, useContext } from 'react'; +import { IOrganizationEntity } from '@novu/shared'; +import { IUserWithContext, useAuthController } from '../hooks'; export type UserContext = { token: string | null; isLoggedIn: boolean; - currentUser: IUserEntity | undefined; + currentUser: IUserWithContext | undefined; isUserLoading: boolean; currentOrganization: IOrganizationEntity | undefined; organizations: IOrganizationEntity[] | undefined; setToken: (token: string, refetch?: boolean) => void; logout: () => void; - jwtPayload?: IJwtPayload; }; const AuthContext = React.createContext({ @@ -23,14 +22,21 @@ const AuthContext = React.createContext({ logout: undefined as any, currentOrganization: undefined as any, organizations: undefined as any, - jwtPayload: undefined, }); export const useAuthContext = (): UserContext => useContext(AuthContext); -export const AuthProvider = ({ children }: { children: React.ReactNode }) => { - const { token, setToken, user, organization, isUserLoading, logout, jwtPayload, organizations, isLoggedIn } = - useAuthController(); +export interface AuthProviderProps { + /** Renders when User is loading */ + fallbackComponent: React.ReactNode; +} + +export const AuthProvider: React.FC> = ({ children, fallbackComponent }) => { + const { token, setToken, user, organization, isUserLoading, logout, organizations, isLoggedIn } = useAuthController(); + + if (isUserLoading) { + return <>{fallbackComponent}; + } return ( { token, logout, setToken, - jwtPayload, }} > {children} diff --git a/libs/shared-web/src/utils/auth-selectors/index.ts b/libs/shared-web/src/utils/auth-selectors/index.ts deleted file mode 100644 index 53240e5334f..00000000000 --- a/libs/shared-web/src/utils/auth-selectors/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './selectHasUserCompletedSignUp'; diff --git a/libs/shared-web/src/utils/auth-selectors/selectHasUserCompletedSignUp.ts b/libs/shared-web/src/utils/auth-selectors/selectHasUserCompletedSignUp.ts deleted file mode 100644 index 31900f917cf..00000000000 --- a/libs/shared-web/src/utils/auth-selectors/selectHasUserCompletedSignUp.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { UserContext } from '../../providers'; - -/** - * Determine if a user is fully-registered; if not, they're still in onboarding. - */ -export const selectHasUserCompletedSignUp = (userCtx: UserContext): boolean => { - if (!userCtx) { - return false; - } - - // User has completed registration if they have an associated orgId. - return !!userCtx.jwtPayload?.organizationId; -}; diff --git a/libs/shared-web/src/utils/index.ts b/libs/shared-web/src/utils/index.ts index 6cd450c1251..a8a6e27eb8a 100644 --- a/libs/shared-web/src/utils/index.ts +++ b/libs/shared-web/src/utils/index.ts @@ -1,3 +1,2 @@ export * from './segment'; export * from './checkShouldUseLaunchDarkly'; -export * from './auth-selectors'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ad86800e69..266bd899dbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -886,9 +886,6 @@ importers: html-webpack-plugin: specifier: 5.5.3 version: 5.5.3(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.2))(esbuild@0.18.20)) - jwt-decode: - specifier: ^3.1.2 - version: 3.1.2 launchdarkly-react-client-sdk: specifier: ^3.0.6 version: 3.0.6(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -1312,9 +1309,6 @@ importers: iframe-resizer: specifier: ^4.3.1 version: 4.3.6 - jwt-decode: - specifier: ^3.1.2 - version: 3.1.2 polished: specifier: ^4.1.2 version: 4.2.2 @@ -3031,9 +3025,6 @@ importers: axios: specifier: ^1.3.3 version: 1.6.2 - jwt-decode: - specifier: ^3.1.2 - version: 3.1.2 react-helmet-async: specifier: ^1.3.0 version: 1.3.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -52614,7 +52605,7 @@ snapshots: grapheme-splitter: 1.0.4 ignore: 5.2.4 natural-compare-lite: 1.4.0 - semver: 7.6.2 + semver: 7.5.4 tsutils: 3.21.0(typescript@4.9.5) optionalDependencies: typescript: 4.9.5 @@ -58854,7 +58845,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.7 eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.58.0(eslint@8.57.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-webpack@0.13.7(eslint-plugin-import@2.28.1)(webpack@5.88.2(@swc/core@1.3.107)))(eslint@8.57.0) - has: 1.0.3 + has: 1.0.4 is-core-module: 2.13.0 is-glob: 4.0.3 minimatch: 3.1.2