diff --git a/apps/web/cypress/tests/auth.spec.ts b/apps/web/cypress/tests/auth.spec.ts index 2c7b61b8577..67e20fef42f 100644 --- a/apps/web/cypress/tests/auth.spec.ts +++ b/apps/web/cypress/tests/auth.spec.ts @@ -1,7 +1,10 @@ import * as capitalize from 'lodash.capitalize'; -import { JobTitleEnum, jobTitleToLabelMapper } from '@novu/shared'; +import { FeatureFlagsKeysEnum, JobTitleEnum, jobTitleToLabelMapper } from '@novu/shared'; describe('User Sign-up and Login', function () { + beforeEach(function () { + cy.mockFeatureFlags({ [FeatureFlagsKeysEnum.IS_INFORMATION_ARCHITECTURE_ENABLED]: false }); + }); describe('Sign up', function () { beforeEach(function () { cy.clearDatabase(); @@ -10,7 +13,9 @@ describe('User Sign-up and Login', function () { it('should allow a visitor to sign-up, login, and logout', function () { cy.intercept('**/organization/**/switch').as('appSwitch'); - cy.visit('/auth/signup'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/signup'); + }); cy.getByTestId('fullName').type('Test User'); cy.getByTestId('email').type('example@example.com'); cy.getByTestId('password').type('usEr_password_123!'); @@ -30,7 +35,9 @@ describe('User Sign-up and Login', function () { }); it('should show account already exists when signing up with already registered mail', function () { - cy.visit('/auth/signup'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/signup'); + }); cy.getByTestId('fullName').type('Test User'); cy.getByTestId('email').type('test-user-1@example.com'); cy.getByTestId('password').type('usEr_password_123!'); @@ -40,7 +47,9 @@ describe('User Sign-up and Login', function () { }); it('should show invalid email error when signing up with invalid email', function () { - cy.visit('/auth/signup'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/signup'); + }); cy.getByTestId('fullName').type('Test User'); cy.getByTestId('email').type('test-user-1@example.c'); cy.getByTestId('password').type('usEr_password_123!'); @@ -54,7 +63,9 @@ describe('User Sign-up and Login', function () { if (!isCI) return; cy.intercept('**/organization/**/switch').as('appSwitch'); - cy.visit('/auth/signup'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/signup'); + }); cy.loginWithGitHub(); @@ -82,7 +93,9 @@ describe('User Sign-up and Login', function () { const gitHubUserEmail = Cypress.env('GITHUB_USER_EMAIL'); cy.intercept('**/organization/**/switch').as('appSwitch'); - cy.visit('/auth/signup'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/signup'); + }); cy.getByTestId('fullName').type('Test User'); cy.getByTestId('email').type(gitHubUserEmail); cy.getByTestId('password').type('usEr_password_123!'); @@ -115,13 +128,19 @@ describe('User Sign-up and Login', function () { }); it('should request a password reset flow', function () { - cy.visit('/auth/reset/request'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/reset/request'); + }); cy.getByTestId('email').type(this.session.user.email); cy.getByTestId('submit-btn').click(); cy.getByTestId('success-screen-reset').should('be.visible'); + cy.task('passwordResetToken', this.session.user._id).then((token) => { cy.visit('/auth/reset/' + token); }); + + // unfortunately there seems to be a timing issue in in which inputs are disabled + cy.wait(500); cy.getByTestId('password').type('A123e3e3e3!'); cy.getByTestId('password-repeat').focus().type('A123e3e3e3!'); @@ -137,18 +156,24 @@ describe('User Sign-up and Login', function () { it('should redirect to the dashboard page when a token exists in query', function () { cy.initializeSession({ disableLocalStorage: true }).then((session) => { - cy.visit('/auth/login?token=' + session.token); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/login?token=' + session.token); + }); cy.location('pathname').should('equal', '/workflows'); }); }); it('should be redirect login with no auth', function () { - cy.visit('/'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/'); + }); cy.location('pathname').should('equal', '/auth/login'); }); it('should successfully login the user', function () { - cy.visit('/auth/login'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/login'); + }); cy.getByTestId('email').type('test-user-1@example.com'); cy.getByTestId('password').type('123qwe!@#'); @@ -157,7 +182,9 @@ describe('User Sign-up and Login', function () { }); it('should show incorrect email or password error when authenticating with bad credentials', function () { - cy.visit('/auth/login'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/login'); + }); cy.getByTestId('email').type('test-user-1@example.com'); cy.getByTestId('password').type('123456'); @@ -166,7 +193,9 @@ describe('User Sign-up and Login', function () { }); it('should show invalid email error when authenticating with invalid email', function () { - cy.visit('/auth/login'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/login'); + }); cy.getByTestId('email').type('test-user-1@example.c'); cy.getByTestId('password').type('123456'); @@ -175,7 +204,9 @@ describe('User Sign-up and Login', function () { }); it('should show incorrect email or password error when authenticating with non-existing email', function () { - cy.visit('/auth/login'); + cy.waitLoadFeatureFlags(() => { + cy.visit('/auth/login'); + }); cy.getByTestId('email').type('test-user-1@example.de'); cy.getByTestId('password').type('123456'); @@ -197,6 +228,8 @@ describe('User Sign-up and Login', function () { cy.getByTestId('password').type('123qwe!@#'); cy.getByTestId('submit-btn').click(); + cy.waitLoadFeatureFlags(); + cy.location('pathname').should('equal', '/workflows'); // setting current time in future, to simulate expired token @@ -207,6 +240,8 @@ describe('User Sign-up and Login', function () { cy.visit('/subscribers'); + cy.waitLoadFeatureFlags(); + // checking if token is removed from local storage cy.getLocalStorage('auth_token').should('be.null'); // checking if user is redirected to login page diff --git a/apps/web/cypress/tests/invites.spec.ts b/apps/web/cypress/tests/invites.spec.ts index 54733a8c16c..3e84386a203 100644 --- a/apps/web/cypress/tests/invites.spec.ts +++ b/apps/web/cypress/tests/invites.spec.ts @@ -1,7 +1,9 @@ +import { FeatureFlagsKeysEnum } from '@novu/shared'; import * as capitalize from 'lodash.capitalize'; describe('Invites module', function () { beforeEach(function () { + cy.mockFeatureFlags({ [FeatureFlagsKeysEnum.IS_INFORMATION_ARCHITECTURE_ENABLED]: false }); cy.task('clearDatabase'); }); @@ -120,7 +122,9 @@ describe('Invites module', function () { cy.initializeSession().as('session'); const invitationPath = `/auth/invitation/${this.token}`; - cy.visit(invitationPath); + cy.waitLoadFeatureFlags(() => { + cy.visit(invitationPath); + }); cy.getByTestId('success-screen-reset').click(); // checking if token is removed from local storage diff --git a/apps/web/src/AppRoutes.tsx b/apps/web/src/AppRoutes.tsx index 0b5fb6b2b50..514f9b219ab 100644 --- a/apps/web/src/AppRoutes.tsx +++ b/apps/web/src/AppRoutes.tsx @@ -49,6 +49,7 @@ import { useSettingsRoutes } from './SettingsRoutes'; export const AppRoutes = () => { const isImprovedOnboardingEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_IMPROVED_ONBOARDING_ENABLED); + const isInformationArchitectureEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_INFORMATION_ARCHITECTURE_ENABLED); return ( @@ -116,13 +117,16 @@ export const AppRoutes = () => { } /> } /> } /> - }> - } /> - } /> - - }> - } /> - + {!isInformationArchitectureEnabled ? ( + }> + } /> + } /> + + ) : ( + }> + } /> + + )} } /> diff --git a/apps/web/src/Providers.tsx b/apps/web/src/Providers.tsx index 8b9a0b1eed9..bdc47fd51f7 100644 --- a/apps/web/src/Providers.tsx +++ b/apps/web/src/Providers.tsx @@ -1,12 +1,15 @@ -import { CONTEXT_PATH, LAUNCH_DARKLY_CLIENT_SIDE_ID, SegmentProvider } from '@novu/shared-web'; +import { Loader } from '@mantine/core'; +import { colors } from '@novu/design-system'; +import { CONTEXT_PATH, SegmentProvider } from '@novu/shared-web'; import * as Sentry from '@sentry/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { withLDProvider } from 'launchdarkly-react-client-sdk'; import { PropsWithChildren } from 'react'; import { HelmetProvider } from 'react-helmet-async'; import { BrowserRouter } from 'react-router-dom'; import { api } from './api/api.client'; +import { LaunchDarklyProvider } from './components/launch-darkly'; import { AuthProvider } from './components/providers/AuthProvider'; +import { css } from './styled-system/css'; const defaultQueryFn = async ({ queryKey }: { queryKey: string }) => { const response = await api.get(`${queryKey[0]}`); @@ -22,28 +25,41 @@ const queryClient = new QueryClient({ }, }); +/** Full-page loader that uses color-preferences for background */ +const fallbackDisplay = ( +
+ +
+); + /** * Centralized Provider hierarchy. */ const Providers: React.FC> = ({ children }) => { return ( - + - - {children} - + + + {children} + + - + ); }; -export default Sentry.withProfiler( - withLDProvider({ - clientSideID: LAUNCH_DARKLY_CLIENT_SIDE_ID, - reactOptions: { - useCamelCaseFlagKeys: false, - }, - })(Providers) -); +export default Sentry.withProfiler(Providers); diff --git a/apps/web/src/SettingsRoutes.tsx b/apps/web/src/SettingsRoutes.tsx index 1d5c75e513a..b469ccc6726 100644 --- a/apps/web/src/SettingsRoutes.tsx +++ b/apps/web/src/SettingsRoutes.tsx @@ -6,7 +6,6 @@ import { ProductLead } from './components/utils/ProductLead'; import { ROUTES } from './constants/routes.enum'; import { useFeatureFlag } from './hooks'; import { BillingRoutes } from './pages/BillingPages'; -import { BrandingForm as BrandingFormOld } from './pages/brand/tabs'; import { BrandingPage } from './pages/brand/tabs/v2'; import { MembersInvitePage as MembersInvitePageNew } from './pages/invites/v2/MembersInvitePage'; import { AccessSecurityPage, ApiKeysPage, BillingPage, TeamPage, UserProfilePage } from './pages/settings'; @@ -42,7 +41,7 @@ export const useSettingsRoutes = () => { ); } - /* TODO: remove all routes above once information architecture is fully enabled */ + /* TODO: remove all routes below once information architecture is fully enabled */ return ( <> }> @@ -50,7 +49,6 @@ export const useSettingsRoutes = () => { } /> } /> } /> - } /> >; + +/** 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, +}) => { + const LDProvider = useRef(DEFAULT_GENERIC_PROVIDER); + const [isLDReady, setIsLDReady] = useState(false); + + const authContext = useAuthContext(); + if (!authContext) { + throw new Error('LaunchDarklyProvider must be used within !'); + } + const { currentOrganization } = authContext; + + const shouldInitializeLd = useMemo(() => selectShouldInitializeLaunchDarkly(authContext), [authContext]); + + useEffect(() => { + if (!shouldInitializeLd) { + return; + } + + const fetchLDProvider = async () => { + try { + LDProvider.current = await asyncWithLDProvider({ + clientSideID: LAUNCH_DARKLY_CLIENT_SIDE_ID, + reactOptions: { + useCamelCaseFlagKeys: false, + }, + // determine which context to use based on if an organization is available + context: currentOrganization + ? { + kind: 'organization', + key: currentOrganization._id, + name: currentOrganization.name, + } + : { + /** + * When user is not authenticated, assigns an id to them to ensure consistent results. + * https://docs.launchdarkly.com/sdk/features/anonymous#javascript + */ + kind: 'user', + anonymous: true, + }, + }); + } catch (err: unknown) { + Sentry.captureException(err); + } finally { + setIsLDReady(true); + } + }; + + fetchLDProvider(); + }, [setIsLDReady, shouldInitializeLd, currentOrganization]); + + /** + * For self-hosted, LD will not be enabled, so do not block initialization. + * Must not show the fallback if the user isn't logged-in to avoid issues with un-authenticated routes (i.e. login). + */ + if (selectShouldShowLaunchDarklyFallback(authContext, isLDReady)) { + return <>{fallbackDisplay}; + } + + return ( + + {children} + + ); +}; + +/** + * Refreshes feature flags on org change using the LaunchDarkly client from the provider. + */ +function LaunchDarklyClientWrapper({ children, org }: PropsWithChildren<{ org?: IOrganizationEntity }>) { + useFeatureFlags(org); + + return <>{children}; +} diff --git a/apps/web/src/components/launch-darkly/index.ts b/apps/web/src/components/launch-darkly/index.ts new file mode 100644 index 00000000000..b1c5cfef1eb --- /dev/null +++ b/apps/web/src/components/launch-darkly/index.ts @@ -0,0 +1 @@ +export * from './LaunchDarklyProvider'; diff --git a/apps/web/src/components/launch-darkly/utils/selectShouldInitializeLaunchDarkly.tsx b/apps/web/src/components/launch-darkly/utils/selectShouldInitializeLaunchDarkly.tsx new file mode 100644 index 00000000000..d8131950cfd --- /dev/null +++ b/apps/web/src/components/launch-darkly/utils/selectShouldInitializeLaunchDarkly.tsx @@ -0,0 +1,31 @@ +import { selectHasUserCompletedSignUp, 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; + // don't show fallback if LaunchDarkly isn't enabled + if (!checkShouldUseLaunchDarkly()) { + return false; + } + + // enable feature flags for unauthenticated areas of the app + if (!isLoggedIn) { + return true; + } + + /** + * Allow LD to load when the user is created but still in onboarding. + * + * After users provide their name, email, and password, we take them to an onboarding step where they provide details + * such as job title, use cases, company name, etc. When they reach this page, isLoggedIn is true, but they don't + * 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)) { + return true; + } + + // if a user is fully on-boarded, but no organization has loaded, we must wait for the organization to initialize the client. + return !!currentOrganization; +} diff --git a/apps/web/src/components/launch-darkly/utils/selectShouldShowLaunchDarklyFallback.tsx b/apps/web/src/components/launch-darkly/utils/selectShouldShowLaunchDarklyFallback.tsx new file mode 100644 index 00000000000..aa4e9839abe --- /dev/null +++ b/apps/web/src/components/launch-darkly/utils/selectShouldShowLaunchDarklyFallback.tsx @@ -0,0 +1,23 @@ +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/libs/shared-web/src/hooks/useFeatureFlags.ts b/libs/shared-web/src/hooks/useFeatureFlags.ts index 488b0441eef..91a399480f4 100644 --- a/libs/shared-web/src/hooks/useFeatureFlags.ts +++ b/libs/shared-web/src/hooks/useFeatureFlags.ts @@ -1,19 +1,19 @@ import { FeatureFlagsKeysEnum, IOrganizationEntity, prepareBooleanStringFeatureFlag } from '@novu/shared'; -import { useFlags } from 'launchdarkly-react-client-sdk'; -import { useLDClient } from 'launchdarkly-react-client-sdk'; +import { useFlags, useLDClient } from 'launchdarkly-react-client-sdk'; import { useEffect } from 'react'; +import { checkShouldUseLaunchDarkly } from '../utils/checkShouldUseLaunchDarkly'; import { FEATURE_FLAGS } from '../config'; -export const useFeatureFlags = (organization: IOrganizationEntity) => { +export const useFeatureFlags = (organization?: IOrganizationEntity) => { const ldClient = useLDClient(); useEffect(() => { - if (!organization?._id) { + if (!checkShouldUseLaunchDarkly() || !organization?._id || !ldClient) { return; } - ldClient?.identify({ + ldClient.identify({ kind: 'organization', key: organization._id, name: organization.name, @@ -24,10 +24,12 @@ export const useFeatureFlags = (organization: IOrganizationEntity) => { }; export const useFeatureFlag = (key: FeatureFlagsKeysEnum): boolean => { - const { [key]: featureFlag } = useFlags(); + /** We knowingly break the rule of hooks here to avoid making any LaunchDarkly calls when it is disabled */ + // eslint-disable-next-line + const flagValue = checkShouldUseLaunchDarkly() ? useFlags()[key] : undefined; const fallbackValue = false; const value = FEATURE_FLAGS[key]; const defaultValue = prepareBooleanStringFeatureFlag(value, fallbackValue); - return featureFlag ?? defaultValue; + return flagValue ?? defaultValue; }; diff --git a/libs/shared-web/src/index.ts b/libs/shared-web/src/index.ts index c903bad34fb..493e3ce358d 100644 --- a/libs/shared-web/src/index.ts +++ b/libs/shared-web/src/index.ts @@ -5,3 +5,4 @@ export * from './hooks'; export * from './providers'; export * from './constants'; export * from './components'; +export * from './utils'; diff --git a/libs/shared-web/src/providers/AuthProvider.tsx b/libs/shared-web/src/providers/AuthProvider.tsx index 04c72b2cc72..4534bf64587 100644 --- a/libs/shared-web/src/providers/AuthProvider.tsx +++ b/libs/shared-web/src/providers/AuthProvider.tsx @@ -1,9 +1,10 @@ import React, { useContext } from 'react'; import { IOrganizationEntity, IUserEntity, IJwtPayload } from '@novu/shared'; -import { useAuthController, useFeatureFlags } from '../hooks'; +import { useAuthController } from '../hooks'; -type UserContext = { +export type UserContext = { token: string | null; + isLoggedIn: boolean; currentUser: IUserEntity | undefined; isUserLoading: boolean; currentOrganization: IOrganizationEntity | undefined; @@ -15,6 +16,7 @@ type UserContext = { const AuthContext = React.createContext({ token: null, + isLoggedIn: false, currentUser: undefined, isUserLoading: true, setToken: undefined as any, @@ -27,13 +29,14 @@ const AuthContext = React.createContext({ export const useAuthContext = (): UserContext => useContext(AuthContext); export const AuthProvider = ({ children }: { children: React.ReactNode }) => { - const { token, setToken, user, organization, isUserLoading, logout, jwtPayload, organizations } = useAuthController(); - useFeatureFlags(organization); + const { token, setToken, user, organization, isUserLoading, logout, jwtPayload, organizations, isLoggedIn } = + useAuthController(); return ( { + 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/checkShouldUseLaunchDarkly.ts b/libs/shared-web/src/utils/checkShouldUseLaunchDarkly.ts new file mode 100644 index 00000000000..6cc3a28f6f0 --- /dev/null +++ b/libs/shared-web/src/utils/checkShouldUseLaunchDarkly.ts @@ -0,0 +1,6 @@ +import { LAUNCH_DARKLY_CLIENT_SIDE_ID } from '../config'; + +/** Determine if client-side LaunchDarkly should be enabled */ +export const checkShouldUseLaunchDarkly = (): boolean => { + return !!LAUNCH_DARKLY_CLIENT_SIDE_ID; +}; diff --git a/libs/shared-web/src/utils/index.ts b/libs/shared-web/src/utils/index.ts index 18c52ee5ae1..6cd450c1251 100644 --- a/libs/shared-web/src/utils/index.ts +++ b/libs/shared-web/src/utils/index.ts @@ -1 +1,3 @@ export * from './segment'; +export * from './checkShouldUseLaunchDarkly'; +export * from './auth-selectors';