-
Notifications
You must be signed in to change notification settings - Fork 3.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[NV-3735]: Improve launch darkly #5517
Changes from 21 commits
53647b3
4200cd7
1066caf
0e7da6a
9389165
952ec8f
e992ffd
a98073d
961a334
00bf34e
a34a72b
953339e
037d946
59b0edb
c1cc6c9
30da614
43273d9
2ab55ab
6ed5769
81ad742
1a3ad79
9bf9e28
be3c6b9
c511889
f155b3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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('[email protected]'); | ||
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('[email protected]'); | ||
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('[email protected]'); | ||
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('[email protected]'); | ||
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('[email protected]'); | ||
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('[email protected]'); | ||
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('[email protected]'); | ||
cy.getByTestId('password').type('123456'); | ||
|
@@ -192,7 +223,9 @@ describe('User Sign-up and Login', function () { | |
|
||
it('should logout user when auth token is expired', function () { | ||
// login the user | ||
cy.visit('/auth/login'); | ||
cy.waitLoadFeatureFlags(() => { | ||
cy.visit('/auth/login'); | ||
}); | ||
cy.getByTestId('email').type('[email protected]'); | ||
cy.getByTestId('password').type('123qwe!@#'); | ||
cy.getByTestId('submit-btn').click(); | ||
|
@@ -205,7 +238,9 @@ describe('User Sign-up and Login', function () { | |
const date = new Date(Date.now() + THIRTY_DAYS + ONE_MINUTE); | ||
cy.clock(date); | ||
|
||
cy.visit('/subscribers'); | ||
cy.waitLoadFeatureFlags(() => { | ||
cy.visit('/subscribers'); | ||
}); | ||
|
||
// checking if token is removed from local storage | ||
cy.getLocalStorage('auth_token').should('be.null'); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = ( | ||
<div | ||
className={css({ | ||
h: '100dvh', | ||
w: '100dvw', | ||
display: 'grid', | ||
placeItems: 'center', | ||
bg: 'surface.page', | ||
// Root element may not have loaded so rely on OS | ||
_osDark: { bg: 'legacy.BGDark' }, | ||
_osLight: { bg: 'legacy.BGLight' }, | ||
})} | ||
> | ||
<Loader size={64} variant="bars" color={colors.gradientMiddle} /> | ||
</div> | ||
); | ||
|
||
/** | ||
* Centralized Provider hierarchy. | ||
*/ | ||
const Providers: React.FC<PropsWithChildren<{}>> = ({ children }) => { | ||
return ( | ||
<SegmentProvider> | ||
<HelmetProvider> | ||
<BrowserRouter basename={CONTEXT_PATH}> | ||
<QueryClientProvider client={queryClient}> | ||
<AuthProvider>{children}</AuthProvider> | ||
</QueryClientProvider> | ||
</BrowserRouter> | ||
</HelmetProvider> | ||
<QueryClientProvider client={queryClient}> | ||
<AuthProvider> | ||
<LaunchDarklyProvider fallbackDisplay={fallbackDisplay}> | ||
<HelmetProvider> | ||
<BrowserRouter basename={CONTEXT_PATH}>{children}</BrowserRouter> | ||
</HelmetProvider> | ||
</LaunchDarklyProvider> | ||
</AuthProvider> | ||
</QueryClientProvider> | ||
</SegmentProvider> | ||
); | ||
}; | ||
|
||
export default Sentry.withProfiler( | ||
withLDProvider({ | ||
clientSideID: LAUNCH_DARKLY_CLIENT_SIDE_ID, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗒 note (non-blocking): Moved LD provider to its own file in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❓ question: why move to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Most of our other similar providers are there, and many of the related behaviors / imports (i.e. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤓 nitpick (non-blocking): I suggest keeping it closer to the source of the web app unless we are sure it would be reused. Otherwise, we might end up with a landfill. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with @LetItRock. I feel that we might be overusing shared packages on some occasions. Other than that, great choice to split it into a separate component. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good, thanks for the feedback! Updated |
||
reactOptions: { | ||
useCamelCaseFlagKeys: false, | ||
}, | ||
})(Providers) | ||
); | ||
export default Sentry.withProfiler(Providers); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
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 { 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<ReturnType<typeof asyncWithLDProvider>>; | ||
|
||
/** 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<PropsWithChildren<ILaunchDarklyProviderProps>> = ({ | ||
children, | ||
fallbackDisplay, | ||
}) => { | ||
const LDProvider = useRef<GenericLDProvider>(DEFAULT_GENERIC_PROVIDER); | ||
const [isLDReady, setIsLDReady] = useState<boolean>(false); | ||
|
||
const authContext = useAuthContext(); | ||
if (!authContext) { | ||
throw new Error('LaunchDarklyProvider must be used within <AuthProvider>!'); | ||
} | ||
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 ( | ||
<LDProvider.current> | ||
<LaunchDarklyClientWrapper org={currentOrganization}>{children}</LaunchDarklyClientWrapper> | ||
</LDProvider.current> | ||
); | ||
}; | ||
|
||
/** | ||
* Refreshes feature flags on org change using the LaunchDarkly client from the provider. | ||
*/ | ||
function LaunchDarklyClientWrapper({ children, org }: PropsWithChildren<{ org?: IOrganizationEntity }>) { | ||
useFeatureFlags(org); | ||
|
||
return <>{children}</>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './LaunchDarklyProvider'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒 note (non-blocking): the
/auth/application
is not a protected route 😨