diff --git a/next.config.js b/next.config.js index 69066650..db40cb3a 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,4 @@ -const { extendNextConfig } = require("./packages/@vulcan/next"); // TODO: load from @vulcan/next when it's on NPM +const { extendNextConfig } = require("./packages/@vulcan/next-config"); // TODO: load from @vulcan/next when it's on NPM const withMDX = require("@next/mdx")({ extension: /\.mdx?$/ }); const flowRight = require("lodash/flowRight"); diff --git a/packages/@vulcan/next/extendNextConfig.js b/packages/@vulcan/next-config/extendNextConfig.js similarity index 100% rename from packages/@vulcan/next/extendNextConfig.js rename to packages/@vulcan/next-config/extendNextConfig.js diff --git a/packages/@vulcan/next/index.js b/packages/@vulcan/next-config/index.js similarity index 100% rename from packages/@vulcan/next/index.js rename to packages/@vulcan/next-config/index.js diff --git a/packages/@vulcan/next-utils/index.ts b/packages/@vulcan/next-utils/index.ts index 178cd64f..952d7b44 100644 --- a/packages/@vulcan/next-utils/index.ts +++ b/packages/@vulcan/next-utils/index.ts @@ -1 +1,2 @@ -export * from "./utils"; +export * from "./ssr"; +export * from "./routing"; diff --git a/packages/@vulcan/next-utils/routing.tsx b/packages/@vulcan/next-utils/routing.tsx new file mode 100644 index 00000000..aaf08b4c --- /dev/null +++ b/packages/@vulcan/next-utils/routing.tsx @@ -0,0 +1,148 @@ +/** + * Demo a private page + * + * @see https://github.com/VulcanJS/vulcan-next-starter/issues/49 + * @see https://github.com/vercel/next.js/discussions/14531 + */ +import { NextPage, NextPageContext } from "next"; +import Router, { useRouter } from "next/router"; +import { isServerRenderCtx, isClientRender, isStaticExportCtx } from "./ssr"; +import debug from "debug"; +import { useEffect, useState } from "react"; +const debugNext = debug("vns:next"); +import _merge from "lodash/merge"; + +// @ssr-only +export const redirectServer = (ctx: NextPageContext) => (pathname: string) => { + ctx.res.writeHead(302, { Location: pathname }); + ctx.res.end(); +}; + +interface RedirectResult { + authProps?: Object; + redirection?: string; + isAllowed: boolean; +} +interface PrivateAccessOptions { + defaultRedirection?: string; + isAllowedServer: ( + pageProps: any, + ctx: NextPageContext + ) => Promise; // return false if the user is allowed + isAllowedClient: (props: any, ctx?: NextPageContext) => RedirectResult; // return false if the user is not allowed +} +interface PrivatePageProps { + isStaticExport?: boolean; + isServerRender?: boolean; + isAllowedDuringSSR?: boolean; +} +/** + * TODO: update on Vulcan AccessControl component + */ +export const withPrivateAccess = ( + hocOptions: Partial +) => (Page: NextPage, pageOptions?: Partial) => { + const options: PrivateAccessOptions = _merge({}, hocOptions, pageOptions); + const { isAllowedClient, isAllowedServer, defaultRedirection } = options; + + const PrivatePage: NextPage = (props) => { + // SCENARIO 1: handle redirection and rendering purely client-side, after static export or during a client-side redirect + const router = useRouter(); + const [isAllowedState, setAllowedState] = useState(false); // use state to avoid the flash + const { isAllowedDuringSSR } = props; + useEffect(() => { + const { isAllowed, redirection } = isAllowedClient(props); + if (!isAllowed) { + debugNext("Redirecting client-side"); + router.push(redirection || defaultRedirection); + } else { + setAllowedState(true); + } + }, [router, props]); + // SCENARIO 1.1: static export (rendering server side at build time) + const { isStaticExport } = props; + if (isStaticExport && !isClientRender()) { + debugNext( + "We render nothing during static export, server side this is a private page (only rendered client side)" + ); + return <>; + // SCNEARIO 1.2: client render + // SCENARIO 1.2.1 : client render after a server render (we know if user is allowed thanks to initialProps) + // and 1.2.2: client render after a client redirect (we must wait for the useEffect to run) + } else if (isClientRender() && !(isAllowedDuringSSR || isAllowedState)) { + debugNext( + "We render nothing if user is not allowed or a redirect is happening or we simply wait for the effect to run" + ); + // we render nothing when waiting for a redirect or to check that we are allowed or not being auth (avoids a flash) + return <>; + } + debugNext("Rendering private page"); + + return ( + <> + + + ); + }; + + // Initial Props + + const pageGetInitialProps = PrivatePage.getInitialProps; + /** + * At the time of writing, using getServerSideProps would be cleaner, but would disable + * static export. + * Instead we use getInitialProps, and treat SSR as special case + * + */ + PrivatePage.getInitialProps = async (ctx?: NextPageContext) => { + debugNext("Running private page getInitialProps"); + const pageInitialProps = pageGetInitialProps + ? pageGetInitialProps(ctx) + : {}; // get the page initial props if any + + // SCENARIO 2: we are doing dynamic SSR + // We redirect using HTTP + if (isServerRenderCtx(ctx)) { + debugNext("Detected dynamic server-side rendering"); + const { redirection, isAllowed, authProps = {} } = await isAllowedServer( + pageInitialProps, + ctx + ); + if (!isAllowed) { + debugNext("Redirecting (dynamic server render)"); + redirectServer(ctx)(redirection || defaultRedirection); + } else { + return { + ...pageInitialProps, + ...authProps, + redirection, + isServerRender: true, + isStaticExport: false, + }; + } + } else if (isStaticExportCtx(ctx)) { + debugNext("Detected static export"); + return { + ...pageInitialProps, + isStaticExport: true, + isServerRender: false, + }; + // SCENARIO 3: getInitialProps is called by a page change client side, we redirect directly here to avoid page flash + } else if (isClientRender()) { + debugNext("Detected client render"); + const { isAllowed, redirection, authProps = {} } = isAllowedClient( + pageInitialProps, + ctx + ); + if (!isAllowed) { + debugNext("Redirecting (client-side, during getInitialProps call)"); + Router.push(redirection || defaultRedirection); + } + return { ...pageInitialProps, ...authProps }; + } + + return pageInitialProps; + }; + + return PrivatePage; +}; diff --git a/packages/@vulcan/next-utils/utils.ts b/packages/@vulcan/next-utils/ssr.ts similarity index 100% rename from packages/@vulcan/next-utils/utils.ts rename to packages/@vulcan/next-utils/ssr.ts diff --git a/src/pages/vns/debug/private-raw.tsx b/src/pages/vns/debug/private-raw.tsx new file mode 100644 index 00000000..9fce65b0 --- /dev/null +++ b/src/pages/vns/debug/private-raw.tsx @@ -0,0 +1,124 @@ +/** + * Demo a private page + * + * @see https://github.com/VulcanJS/vulcan-next-starter/issues/49 + * @see https://github.com/vercel/next.js/discussions/14531 + */ +import Link from "next/link"; +import { NextPage, NextPageContext } from "next"; +import Router, { useRouter } from "next/router"; +import { + isServerRenderCtx, + isClientRender, + isStaticExportCtx, +} from "@vulcan/next-utils"; +import debug from "debug"; +import { useEffect, useState } from "react"; +const debugNext = debug("vns:next"); + +// @ssr-only +export const redirectServer = (ctx: NextPageContext) => (pathname: string) => { + ctx.res.writeHead(302, { Location: pathname }); + ctx.res.end(); +}; + +/** + * @client-only + * Your logic to check if user is allowed, client-side + * + * Here we use a query param, in real life you might want to check + * the presence of a cookie or a token in the localStorage + * NOTE: router.query is sometimes empty, henve the URLSearchParams + */ +const isAllowedClient = () => { + const urlParams = new URLSearchParams(window.location.search); + return !!urlParams.get("allowed"); +}; + +interface PrivatePageProps { + isStaticExport?: boolean; + isServerRender?: boolean; + isAllowedDuringSSR?: boolean; +} +const PrivatePage: NextPage = (props) => { + // SCENARIO 1: handle redirection and rendering purely client-side, after static export or during a client-side redirect + const router = useRouter(); + const [isAllowedState, setAllowedState] = useState(false); // use state to avoid the flash + const { isAllowedDuringSSR } = props; + useEffect(() => { + const isAllowed = isAllowedClient(); + if (!isAllowed) { + debugNext("Redirecting client-side"); + router.push("/vns/debug/public"); + } else { + setAllowedState(true); + } + }, [router]); + // SCENARIO 1.1: static export (rendering server side at build time) + const { isStaticExport } = props; + if (isStaticExport && !isClientRender()) { + debugNext( + "We render nothing during static export, server side this is a private page (only rendered client side)" + ); + return <>; + + // SCNEARIO 1.2: client render + // SCENARIO 1.2.1 : client render after a server render (we know if user is allowed thanks to initialProps) + // and 1.2.2: client render after a client redirect (we must wait for the useEffect to run) + } else if (isClientRender() && !(isAllowedDuringSSR || isAllowedState)) { + debugNext( + "We render nothing if user is not allowed or a redirect is happening or we simply wait for the effect to run" + ); + // we render nothing when waiting for a redirect or to check that we are allowed or not being auth (avoids a flash) + return <>; + } + debugNext("Rendering private page"); + + return ( + <> +

private

+
Seeing a private page.
+ + + ); +}; + +// NOTE: we use getInitialProps to demo redirect, in order to keep things consistent +// with _app, that do not support getServerSideProps and getStaticProps at the time of writing (Next 9.4) +// When redirecting in a page, you could achieve a cleaner setup using getServerSideProps (not demonstrated here) +PrivatePage.getInitialProps = async (ctx?: NextPageContext) => { + //throw new Error("STATIC"); + debugNext("Running page getInitialProps"); + const namespacesRequired = ["common"]; // i18n + // We simulate private connexion + const isAllowed = !!ctx.query.allowed; // demo + const pageProps = { namespacesRequired, isAllowedDuringSSR: isAllowed }; + // SCENARIO 2: we are doing dynamic SSR + // We redirect using HTTP + if (isServerRenderCtx(ctx)) { + debugNext("Detected dynamic server-side rendering"); + if (!isAllowed) { + debugNext("Redirecting (dynamic server render)"); + redirectServer(ctx)("/vns/debug/public"); + } else { + return { ...pageProps, isServerRender: true, isStaticExport: false }; + } + } else if (isStaticExportCtx(ctx)) { + debugNext("Detected static export"); + return { ...pageProps, isStaticExport: true, isServerRender: false }; + // SCENARIO 3: getInitialProps is called by a page change client side, we redirect directly here to avoid page flash + } else if (isClientRender()) { + debugNext("Detected client render"); + if (!isAllowed) { + debugNext("Redirecting (client-side)"); + Router.push("/vns/debug/public"); + } + } + + return pageProps; +}; +export default PrivatePage; diff --git a/src/pages/vns/debug/private.tsx b/src/pages/vns/debug/private.tsx index 9fce65b0..9f37bb33 100644 --- a/src/pages/vns/debug/private.tsx +++ b/src/pages/vns/debug/private.tsx @@ -5,75 +5,13 @@ * @see https://github.com/vercel/next.js/discussions/14531 */ import Link from "next/link"; -import { NextPage, NextPageContext } from "next"; -import Router, { useRouter } from "next/router"; -import { - isServerRenderCtx, - isClientRender, - isStaticExportCtx, -} from "@vulcan/next-utils"; -import debug from "debug"; -import { useEffect, useState } from "react"; -const debugNext = debug("vns:next"); +import { NextPage } from "next"; +import { withPrivateAccess } from "@vulcan/next-utils"; +//import debug from "debug"; +// const debugNext = debug("vns:next"); -// @ssr-only -export const redirectServer = (ctx: NextPageContext) => (pathname: string) => { - ctx.res.writeHead(302, { Location: pathname }); - ctx.res.end(); -}; - -/** - * @client-only - * Your logic to check if user is allowed, client-side - * - * Here we use a query param, in real life you might want to check - * the presence of a cookie or a token in the localStorage - * NOTE: router.query is sometimes empty, henve the URLSearchParams - */ -const isAllowedClient = () => { - const urlParams = new URLSearchParams(window.location.search); - return !!urlParams.get("allowed"); -}; - -interface PrivatePageProps { - isStaticExport?: boolean; - isServerRender?: boolean; - isAllowedDuringSSR?: boolean; -} +interface PrivatePageProps {} const PrivatePage: NextPage = (props) => { - // SCENARIO 1: handle redirection and rendering purely client-side, after static export or during a client-side redirect - const router = useRouter(); - const [isAllowedState, setAllowedState] = useState(false); // use state to avoid the flash - const { isAllowedDuringSSR } = props; - useEffect(() => { - const isAllowed = isAllowedClient(); - if (!isAllowed) { - debugNext("Redirecting client-side"); - router.push("/vns/debug/public"); - } else { - setAllowedState(true); - } - }, [router]); - // SCENARIO 1.1: static export (rendering server side at build time) - const { isStaticExport } = props; - if (isStaticExport && !isClientRender()) { - debugNext( - "We render nothing during static export, server side this is a private page (only rendered client side)" - ); - return <>; - - // SCNEARIO 1.2: client render - // SCENARIO 1.2.1 : client render after a server render (we know if user is allowed thanks to initialProps) - // and 1.2.2: client render after a client redirect (we must wait for the useEffect to run) - } else if (isClientRender() && !(isAllowedDuringSSR || isAllowedState)) { - debugNext( - "We render nothing if user is not allowed or a redirect is happening or we simply wait for the effect to run" - ); - // we render nothing when waiting for a redirect or to check that we are allowed or not being auth (avoids a flash) - return <>; - } - debugNext("Rendering private page"); - return ( <>

private

@@ -90,35 +28,39 @@ const PrivatePage: NextPage = (props) => { // NOTE: we use getInitialProps to demo redirect, in order to keep things consistent // with _app, that do not support getServerSideProps and getStaticProps at the time of writing (Next 9.4) // When redirecting in a page, you could achieve a cleaner setup using getServerSideProps (not demonstrated here) -PrivatePage.getInitialProps = async (ctx?: NextPageContext) => { - //throw new Error("STATIC"); - debugNext("Running page getInitialProps"); - const namespacesRequired = ["common"]; // i18n - // We simulate private connexion - const isAllowed = !!ctx.query.allowed; // demo - const pageProps = { namespacesRequired, isAllowedDuringSSR: isAllowed }; - // SCENARIO 2: we are doing dynamic SSR - // We redirect using HTTP - if (isServerRenderCtx(ctx)) { - debugNext("Detected dynamic server-side rendering"); - if (!isAllowed) { - debugNext("Redirecting (dynamic server render)"); - redirectServer(ctx)("/vns/debug/public"); +//PrivatePage.getInitialProps = async (ctx?: NextPageContext) => { +// return pageProps; +//}; +export default withPrivateAccess({ + isAllowedClient: (props, ctx) => { + /** + * @client-only + * Your logic to check if user is allowed, client-side + * + * Here we use a query param, in real life you might want to check + * the presence of a cookie or a token in the localStorage + * + * The function is overly complex because router.query is sometimes empty, + * but location.search is also outdated when running getInitialProps + * So I have to differentiate scenarios + * It would be simpler in a normal app (just a localStorage.get call) + */ + let isAllowed; + if (ctx) { + // We are in a getInitialProps call, ctx is providing infos about the URL + // (window.location is still poiting to previous page so we can't use it) + isAllowed = !!ctx.query.allowed; // demo } else { - return { ...pageProps, isServerRender: true, isStaticExport: false }; + // We are in the component render, we can use window + const urlParams = new URLSearchParams(window.location.search); + isAllowed = !!urlParams.get("allowed"); } - } else if (isStaticExportCtx(ctx)) { - debugNext("Detected static export"); - return { ...pageProps, isStaticExport: true, isServerRender: false }; - // SCENARIO 3: getInitialProps is called by a page change client side, we redirect directly here to avoid page flash - } else if (isClientRender()) { - debugNext("Detected client render"); - if (!isAllowed) { - debugNext("Redirecting (client-side)"); - Router.push("/vns/debug/public"); - } - } - - return pageProps; -}; -export default PrivatePage; + return { isAllowed }; + }, + isAllowedServer: async (props, ctx) => { + // We can do async calls here + const isAllowed = !!ctx.query.allowed; + return { isAllowed }; + }, + defaultRedirection: "/vns/debug/public", +})(PrivatePage);