Skip to content
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

FR-18442 - Fix Nextjs session store injection and support SSG pages #381

Merged
merged 5 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
{
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended"
"plugin:react/recommended"
],
"plugins": [
"@typescript-eslint",
"react",
"react-hooks"
"react"
],
"env": {
"browser": true,
Expand Down
1 change: 0 additions & 1 deletion packages/example-app-directory/app/UserState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export const UserState = () => {
</button>
<br />
<br />
<Link href='/account/logout'>logout embedded</Link>
</div>
);
};
20 changes: 20 additions & 0 deletions packages/example-app-directory/app/no-ssr/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client';

import { useAuth } from '@frontegg/nextjs';
import Link from 'next/link';

export default function MainPage() {
const { user } = useAuth();
return (
<div>
<h3>Next JS application with frontegg</h3>

<br />
<br />
<div>{user?.email ?? 'not logged in'}</div>
<br />
<br />
<Link href='/session'>check session</Link>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/example-app-directory/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export default function MainPage() {
<br />
<br />
<Link href='/session'>check session</Link>

<Link href='/no-ssr'>Go to SSG page</Link>
</div>
);
}
3 changes: 2 additions & 1 deletion packages/example-pages/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@next/next/no-html-link-for-pages": ["error", "apps/example-*/**"]
"no-console": "off",
"@next/next/no-html-link-for-pages": ["error", "example-*/**"]
}
},
{
Expand Down
8 changes: 8 additions & 0 deletions packages/example-pages/components/TestComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function TestComponent() {
return (
<div>
<h1>Test Component</h1>
<p>Test Component</p>
</div>
);
}
2 changes: 1 addition & 1 deletion packages/example-pages/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ function CustomApp({ Component, pageProps }: AppProps) {

const options = {
hostedLoginBox: false,
customLoader: true,
// customLoader: true,
authOptions: {
keepSessionAlive: true,
},
Expand Down
7 changes: 6 additions & 1 deletion packages/example-pages/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from 'next/link';
import { useAuth, useLoginWithRedirect, AdminPortal, useAuthActions, useLogoutHostedLogin } from '@frontegg/nextjs';
import { useState } from 'react';
import TestComponent from '../components/TestComponent';

export function Index() {
const { user, isAuthenticated } = useAuth();
Expand Down Expand Up @@ -62,7 +63,11 @@ export function Index() {
<input data-testid='test-middleware-id' readOnly value={state.id} />
<br />
<br />
<Link href='/force-session'>check force session</Link>
<Link href='/force-session'>Go to force session page</Link>
<TestComponent />
<br />
<br />
<Link href='/no-ssr'>Go to SSG page</Link>
<br />
<br />
<Link href='/account/logout'>logout embedded</Link>
Expand Down
16 changes: 15 additions & 1 deletion packages/example-pages/pages/no-ssr.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { AdminPortal } from '@frontegg/nextjs';
import { AdminPortal, useAuth } from '@frontegg/nextjs';
import TestComponent from '../components/TestComponent';
import Link from 'next/link';

export default function NoSsr() {
const { user, silentRefreshing = true } = useAuth() as any;

return (
<div>
<h1>NO SSR Session</h1>

{silentRefreshing && <h2>Loading..</h2>}
<code>{/*<pre>{JSON.stringify({user}, null, 2)}</pre>*/}</code>
{user ? <div>Logged in as: {user.email}</div> : <div>SSG not authorized</div>}
<br />
<TestComponent />
<button
onClick={() => {
AdminPortal.show();
}}
>
Open AdminPortal
</button>

<br />
<br />
<br />
<Link href='/force-session'>Go to force session page</Link>
</div>
);
}
Expand Down
3 changes: 1 addition & 2 deletions packages/nextjs/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
],
"plugins": [
"@typescript-eslint",
"react",
"react-hooks"
"react"
],
"env": {
"browser": true,
Expand Down
4 changes: 2 additions & 2 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@types/http-proxy": "^1.17.9"
},
"peerDependencies": {
"react": ">16.9.0",
"react-dom": ">16.9.0"
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
}
58 changes: 48 additions & 10 deletions packages/nextjs/src/common/FronteggBaseProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
'use client';

import React, { FC, useMemo, useRef } from 'react';
import { FronteggStoreProvider, CustomComponentRegister } from '@frontegg/react-hooks';
import { ContextHolder, IUserProfile } from '@frontegg/rest-api';
import React, { FC, useEffect, useMemo, useRef } from 'react';
import { FronteggStoreProvider, CustomComponentRegister, useAuthActions, useStore } from '@frontegg/react-hooks';
import { ContextHolder } from '@frontegg/rest-api';
import type { FronteggProviderProps } from '../types';
import AppContext from './AppContext';
import initializeFronteggApp from '../utils/initializeFronteggApp';
import useRequestAuthorizeSSR from './useRequestAuthorizeSSR';
import useOnRedirectTo from '../utils/useOnRedirectTo';
import config from '../config';

const SSGRequestAuthorize: FC<{ isSSG?: boolean; shouldRequestAuthorize?: boolean }> = ({
isSSG,
shouldRequestAuthorize,
}) => {
const { store } = useStore();
const { requestAuthorize, setAuthState } = useAuthActions();

useEffect(
() => {
if (isSSG && shouldRequestAuthorize && !(store.auth as any).silentRefreshing) {
setAuthState({ silentRefreshing: true } as any);
requestAuthorize().finally(() => {
setAuthState({ silentRefreshing: false } as any);
});
} else {
setAuthState({ silentRefreshing: false } as any);
}
},
[
/* DON'T add any dependency to make sure this useEffect called once on app mount */
]
);

return <></>;
};

const Connector: FC<FronteggProviderProps> = ({ router, appName = 'default', ...props }) => {
const isSSR = typeof window === 'undefined';
const { user, session, tenants, activeTenant } = props;
Expand All @@ -30,15 +56,27 @@ const Connector: FC<FronteggProviderProps> = ({ router, appName = 'default', ...
);
ContextHolder.for(appName).setOnRedirectTo(onRedirectTo);

ContextHolder.for(appName).setAccessToken(session?.accessToken ?? null);
ContextHolder.for(appName).setUser(session?.['user'] as any);
useRequestAuthorizeSSR({ app, user, tenants, activeTenant, session });
useEffect(() => {
if (props.shouldRequestAuthorize && !props.isSSG) {
if (session?.accessToken) {
ContextHolder.for(appName).setAccessToken(session?.accessToken ?? null);
}
if (user) {
ContextHolder.for(appName).setUser(user);
}
useRequestAuthorizeSSR({ app, user, tenants, activeTenant, session });
}
}, []);

const alwaysVisibleChildren = isSSR ? undefined : (
<>
<SSGRequestAuthorize isSSG={props.isSSG} shouldRequestAuthorize={props.shouldRequestAuthorize} />
<CustomComponentRegister app={app} themeOptions={props.themeOptions} />
</>
);
return (
<AppContext.Provider value={app}>
<FronteggStoreProvider
{...({ ...props, app } as any)}
alwaysVisibleChildren={!isSSR && <CustomComponentRegister app={app} themeOptions={props.themeOptions} />}
>
<FronteggStoreProvider {...({ ...props, app } as any)} alwaysVisibleChildren={alwaysVisibleChildren}>
{props.children}
</FronteggStoreProvider>
</AppContext.Provider>
Expand Down
7 changes: 3 additions & 4 deletions packages/nextjs/src/common/useRequestAuthorizeSSR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ export default function useRequestAuthorizeSSR({ app, user, tenants, session }:
? {
...user,
refreshToken: session?.refreshToken,
accessToken: user.accessToken ?? session?.accessToken,
accessToken: user?.accessToken ?? session?.accessToken,
}
: null;

// TODO: consider using useMemo instead of useEffect
useEffect(() => {
if (typeof window !== 'undefined') {
app?.store.dispatch({
type: 'auth/requestAuthorizeSSR',
payload: {
Expand All @@ -23,5 +22,5 @@ export default function useRequestAuthorizeSSR({ app, user, tenants, session }:
tenants,
},
});
}, [app]);
}
}
59 changes: 41 additions & 18 deletions packages/nextjs/src/pages/withFronteggApp/withFronteggApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,44 @@ export const withFronteggApp = (app: FronteggCustomAppClass, options?: WithFront
const originalGetInitialProps = app.getInitialProps;

app.getInitialProps = async (appContext: AppContext & AllUserData): Promise<AppInitialProps> => {
const { ctx, Component } = appContext;
const { ctx, router, Component } = appContext;

const isSSG = router.isReady == false && router.isPreview == false;

let appEnvConfig = {};
let appContextSessionData: AllUserData = {
session: null,
user: null,
tenants: null,
};
let shouldRequestAuthorize = false;

if (ctx.req) {
appEnvConfig = config.appEnvConfig;
const url = ctx.req?.url;

if (url && isRuntimeNextRequest(url)) {
let session = await refreshAccessTokenIfNeeded(ctx);
if (process.env['FRONTEGG_SECURE_JWT_ENABLED'] === 'true') {
session = removeJwtSignatureFrom(session);
}
Object.assign(appContextSessionData, { session });
if (isSSG) {
shouldRequestAuthorize = true;
} else {
let userData = await fetchUserData({
getSession: async () => await refreshAccessTokenIfNeeded(ctx),
getHeaders: async () => ctx.req?.headers ?? {},
});
if (process.env['FRONTEGG_SECURE_JWT_ENABLED'] === 'true' && userData) {
userData = removeJwtSignatureFrom(userData);
userData.session = removeJwtSignatureFrom(userData?.session);
const url = ctx.req?.url;

if (url && isRuntimeNextRequest(url)) {
let session = await refreshAccessTokenIfNeeded(ctx);
if (process.env['FRONTEGG_SECURE_JWT_ENABLED'] === 'true') {
session = removeJwtSignatureFrom(session);
}
Object.assign(appContextSessionData, { session });
} else {
let userData = await fetchUserData({
getSession: async () => await refreshAccessTokenIfNeeded(ctx),
getHeaders: async () => ctx.req?.headers ?? {},
});
if (process.env['FRONTEGG_SECURE_JWT_ENABLED'] === 'true' && userData) {
userData = removeJwtSignatureFrom(userData);
userData.session = removeJwtSignatureFrom(userData?.session);
}
shouldRequestAuthorize = true;
Object.assign(appContextSessionData, userData);
}
Object.assign(appContextSessionData, userData);
}
}

Expand All @@ -52,13 +61,25 @@ export const withFronteggApp = (app: FronteggCustomAppClass, options?: WithFront
...(Component.getInitialProps ? await Component.getInitialProps(ctx) : {}),
...(appContextSessionData.session == null ? {} : appContextSessionData),
...appEnvConfig,
shouldRequestAuthorize,
},
};
};

function CustomFronteggApp(appProps: AppProps) {
const { user, tenants, activeTenant, session, envAppUrl, envBaseUrl, envClientId, secureJwtEnabled, envAppId } =
appProps.pageProps;
const {
user,
tenants,
activeTenant,
session,
envAppUrl,
envBaseUrl,
envClientId,
secureJwtEnabled,
envAppId,
shouldRequestAuthorize,
} = appProps.pageProps;

return (
<FronteggProvider
{...options}
Expand All @@ -70,6 +91,8 @@ export const withFronteggApp = (app: FronteggCustomAppClass, options?: WithFront
envAppUrl,
envBaseUrl,
secureJwtEnabled,
shouldRequestAuthorize,
isSSG: appProps.__N_SSG,
envClientId,
envAppId,
}}
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface FronteggProviderOptions extends Omit<FronteggAppOptions, 'conte
envAppUrl: string;
envBaseUrl: string;
envClientId: string;
shouldRequestAuthorize?: boolean;
isSSG?: boolean;
envAppId?: string;
secureJwtEnabled?: boolean;
contextOptions?: Omit<FronteggAppOptions['contextOptions'], 'baseUrl'>;
Expand Down
5 changes: 4 additions & 1 deletion packages/nextjs/src/utils/cookies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,10 +282,13 @@ class CookieManager {
const cookiesToRemove = this.getCookiesToRemove(req);
const cookieValue = this.createEmptyCookies(isSecured, cookieDomain, cookieNames ?? cookiesToRemove);
let existingSetCookie = (res.getHeader('set-cookie') as string[] | string) ?? [];

if (existingSetCookie != null && typeof existingSetCookie === 'object' && !Array.isArray(existingSetCookie)) {
existingSetCookie = Object.values(existingSetCookie);
}
if (typeof existingSetCookie === 'string') {
existingSetCookie = [existingSetCookie];
}

const setCookieHeaders = [...existingSetCookie, ...cookieValue];
logger.debug(`removing headers (count: ${setCookieHeaders.length})`);
res.setHeader('set-cookie', setCookieHeaders);
Expand Down
Loading
Loading