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';