From b1c405afc735419de870f5d150ada879f3e324f3 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 21 Nov 2024 20:59:19 +0100 Subject: [PATCH] Add forbidden and unauthorized APIs (#72785) --- crates/next-core/src/app_page_loader_tree.rs | 6 + crates/next-core/src/app_structure.rs | 29 +++- crates/next-core/src/base_loader_tree.rs | 4 + packages/next/src/build/index.ts | 5 + packages/next/src/build/utils.ts | 6 + .../webpack/loaders/next-app-loader/index.ts | 50 +++++-- .../webpack/plugins/define-env-plugin.ts | 2 + ...dev-root-http-access-fallback-boundary.tsx | 1 + .../src/client/components/forbidden-error.tsx | 10 ++ .../next/src/client/components/forbidden.ts | 33 +++++ .../http-access-fallback/error-boundary.tsx | 52 +++++-- .../http-access-fallback.ts | 15 +- .../src/client/components/layout-router.tsx | 10 +- .../components/navigation.react-server.ts | 2 + .../next/src/client/components/navigation.ts | 2 + .../client/components/unauthorized-error.tsx | 10 ++ .../src/client/components/unauthorized.ts | 34 +++++ packages/next/src/export/index.ts | 1 + packages/next/src/export/routes/app-route.ts | 4 +- .../next/src/lib/metadata/resolve-metadata.ts | 2 +- .../next/src/server/app-render/app-render.tsx | 1 + .../app-render/create-component-tree.tsx | 133 ++++++++++++++++-- packages/next/src/server/app-render/types.ts | 1 + .../walk-tree-with-flight-router-state.tsx | 1 + .../src/server/async-storage/work-store.ts | 2 +- packages/next/src/server/base-server.ts | 2 + packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 7 + .../next/src/server/dev/next-dev-server.ts | 1 + .../src/server/dev/static-paths-worker.ts | 3 + .../next/src/server/lib/app-dir-module.ts | 2 +- packages/next/src/server/web/adapter.ts | 2 + .../server/web/edge-route-module-wrapper.ts | 1 + packages/next/src/server/web/types.ts | 5 +- .../acceptance-app/hydration-error.test.ts | 8 +- .../[id]/page.js | 16 +++ .../layout.js | 8 ++ .../dynamic-layout-without-forbidden/page.js | 3 + .../basic/app/dynamic/[id]/forbidden.js | 3 + .../forbidden/basic/app/dynamic/[id]/page.js | 13 ++ .../forbidden/basic/app/dynamic/page.js | 3 + .../app-dir/forbidden/basic/app/forbidden.js | 9 ++ .../e2e/app-dir/forbidden/basic/app/layout.js | 16 +++ test/e2e/app-dir/forbidden/basic/app/page.js | 3 + .../forbidden/basic/forbidden-basic.test.ts | 41 ++++++ .../app-dir/forbidden/basic/next.config.js | 10 ++ .../app/(group)/group-dynamic/[id]/page.js | 10 ++ .../forbidden/default/app/(group)/layout.js | 3 + .../default/app/forbidden-trigger.js | 12 ++ .../app-dir/forbidden/default/app/layout.js | 28 ++++ .../app/metadata-layout-forbidden/layout.js | 9 ++ .../app/metadata-layout-forbidden/page.js | 3 + .../default/app/navigate-forbidden/page.js | 5 + .../e2e/app-dir/forbidden/default/app/page.js | 19 +++ .../default/forbidden-default.test.ts | 79 +++++++++++ .../app-dir/forbidden/default/next.config.js | 10 ++ .../[id]/page.js | 13 ++ .../layout.js | 8 ++ .../page.js | 3 + .../basic/app/dynamic/[id]/page.js | 13 ++ .../basic/app/dynamic/[id]/unauthorized.js | 3 + .../unauthorized/basic/app/dynamic/page.js | 3 + .../app-dir/unauthorized/basic/app/layout.js | 16 +++ .../app-dir/unauthorized/basic/app/page.js | 3 + .../unauthorized/basic/app/unauthorized.js | 9 ++ .../app-dir/unauthorized/basic/next.config.js | 10 ++ .../basic/unauthorized-basic.test.ts | 41 ++++++ .../app/(group)/group-dynamic/[id]/page.js | 10 ++ .../default/app/(group)/layout.js | 3 + .../unauthorized/default/app/layout.js | 28 ++++ .../metadata-layout-unauthorized/layout.js | 9 ++ .../app/metadata-layout-unauthorized/page.js | 3 + .../default/app/navigate-unauthorized/page.js | 5 + .../app-dir/unauthorized/default/app/page.js | 22 +++ .../default/app/unauthorized-trigger.js | 12 ++ .../unauthorized/default/next.config.js | 10 ++ .../default/unauthorized-default.test.ts | 79 +++++++++++ 77 files changed, 997 insertions(+), 57 deletions(-) create mode 100644 packages/next/src/client/components/forbidden-error.tsx create mode 100644 packages/next/src/client/components/forbidden.ts create mode 100644 packages/next/src/client/components/unauthorized-error.tsx create mode 100644 packages/next/src/client/components/unauthorized.ts create mode 100644 test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/[id]/page.js create mode 100644 test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/layout.js create mode 100644 test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/page.js create mode 100644 test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js create mode 100644 test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js create mode 100644 test/e2e/app-dir/forbidden/basic/app/dynamic/page.js create mode 100644 test/e2e/app-dir/forbidden/basic/app/forbidden.js create mode 100644 test/e2e/app-dir/forbidden/basic/app/layout.js create mode 100644 test/e2e/app-dir/forbidden/basic/app/page.js create mode 100644 test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts create mode 100644 test/e2e/app-dir/forbidden/basic/next.config.js create mode 100644 test/e2e/app-dir/forbidden/default/app/(group)/group-dynamic/[id]/page.js create mode 100644 test/e2e/app-dir/forbidden/default/app/(group)/layout.js create mode 100644 test/e2e/app-dir/forbidden/default/app/forbidden-trigger.js create mode 100644 test/e2e/app-dir/forbidden/default/app/layout.js create mode 100644 test/e2e/app-dir/forbidden/default/app/metadata-layout-forbidden/layout.js create mode 100644 test/e2e/app-dir/forbidden/default/app/metadata-layout-forbidden/page.js create mode 100644 test/e2e/app-dir/forbidden/default/app/navigate-forbidden/page.js create mode 100644 test/e2e/app-dir/forbidden/default/app/page.js create mode 100644 test/e2e/app-dir/forbidden/default/forbidden-default.test.ts create mode 100644 test/e2e/app-dir/forbidden/default/next.config.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/[id]/page.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/layout.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/page.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/page.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/unauthorized.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/dynamic/page.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/layout.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/page.js create mode 100644 test/e2e/app-dir/unauthorized/basic/app/unauthorized.js create mode 100644 test/e2e/app-dir/unauthorized/basic/next.config.js create mode 100644 test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts create mode 100644 test/e2e/app-dir/unauthorized/default/app/(group)/group-dynamic/[id]/page.js create mode 100644 test/e2e/app-dir/unauthorized/default/app/(group)/layout.js create mode 100644 test/e2e/app-dir/unauthorized/default/app/layout.js create mode 100644 test/e2e/app-dir/unauthorized/default/app/metadata-layout-unauthorized/layout.js create mode 100644 test/e2e/app-dir/unauthorized/default/app/metadata-layout-unauthorized/page.js create mode 100644 test/e2e/app-dir/unauthorized/default/app/navigate-unauthorized/page.js create mode 100644 test/e2e/app-dir/unauthorized/default/app/page.js create mode 100644 test/e2e/app-dir/unauthorized/default/app/unauthorized-trigger.js create mode 100644 test/e2e/app-dir/unauthorized/default/next.config.js create mode 100644 test/e2e/app-dir/unauthorized/default/unauthorized-default.test.ts diff --git a/crates/next-core/src/app_page_loader_tree.rs b/crates/next-core/src/app_page_loader_tree.rs index befe56f1a84cae..6f42bca328f18b 100644 --- a/crates/next-core/src/app_page_loader_tree.rs +++ b/crates/next-core/src/app_page_loader_tree.rs @@ -320,6 +320,8 @@ impl AppPageLoaderTreeBuilder { template, not_found, metadata, + forbidden, + unauthorized, route: _, } = &modules; @@ -343,6 +345,10 @@ impl AppPageLoaderTreeBuilder { .await?; self.write_modules_entry(AppDirModuleType::NotFound, *not_found) .await?; + self.write_modules_entry(AppDirModuleType::Forbidden, *forbidden) + .await?; + self.write_modules_entry(AppDirModuleType::Unauthorized, *unauthorized) + .await?; self.write_modules_entry(AppDirModuleType::Page, *page) .await?; self.write_modules_entry(AppDirModuleType::DefaultPage, *default) diff --git a/crates/next-core/src/app_structure.rs b/crates/next-core/src/app_structure.rs index 813d95c435325d..7d05af4c592be9 100644 --- a/crates/next-core/src/app_structure.rs +++ b/crates/next-core/src/app_structure.rs @@ -43,6 +43,10 @@ pub struct AppDirModules { #[serde(skip_serializing_if = "Option::is_none")] pub template: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub forbidden: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub unauthorized: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub not_found: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub default: Option>, @@ -62,6 +66,8 @@ impl AppDirModules { loading: self.loading, template: self.template, not_found: self.not_found, + forbidden: self.forbidden, + unauthorized: self.unauthorized, default: None, route: None, metadata: self.metadata.clone(), @@ -306,6 +312,8 @@ async fn get_directory_tree_internal( "global-error" => modules.global_error = Some(file), "loading" => modules.loading = Some(*file), "template" => modules.template = Some(*file), + "forbidden" => modules.forbidden = Some(*file), + "unauthorized" => modules.unauthorized = Some(*file), "not-found" => modules.not_found = Some(*file), "default" => modules.default = Some(*file), "route" => modules.route = Some(file), @@ -857,10 +865,23 @@ fn directory_tree_to_loader_tree_internal( // the path). let is_root_layout = app_path.is_root() && modules.layout.is_some(); - if (is_root_directory || is_root_layout) && modules.not_found.is_none() { - modules.not_found = Some( - get_next_package(app_dir).join("dist/client/components/not-found-error.js".into()), - ); + if is_root_directory || is_root_layout { + if modules.not_found.is_none() { + modules.not_found = Some( + get_next_package(app_dir).join("dist/client/components/not-found-error.js".into()), + ); + } + if modules.forbidden.is_none() { + modules.forbidden = Some( + get_next_package(app_dir).join("dist/client/components/forbidden-error.js".into()), + ); + } + if modules.unauthorized.is_none() { + modules.unauthorized = Some( + get_next_package(app_dir) + .join("dist/client/components/unauthorized-error.js".into()), + ); + } } let mut tree = AppPageLoaderTree { diff --git a/crates/next-core/src/base_loader_tree.rs b/crates/next-core/src/base_loader_tree.rs index df25100d7625e2..a2d6324d7e1325 100644 --- a/crates/next-core/src/base_loader_tree.rs +++ b/crates/next-core/src/base_loader_tree.rs @@ -29,6 +29,8 @@ pub enum AppDirModuleType { Loading, Template, NotFound, + Forbidden, + Unauthorized, GlobalError, } @@ -42,6 +44,8 @@ impl AppDirModuleType { AppDirModuleType::Loading => "loading", AppDirModuleType::Template => "template", AppDirModuleType::NotFound => "not-found", + AppDirModuleType::Forbidden => "forbidden", + AppDirModuleType::Unauthorized => "unauthorized", AppDirModuleType::GlobalError => "global-error", } } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index cdcea99c010519..d2f9650b1b98bb 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -1260,6 +1260,9 @@ export default async function build( ) const isAppDynamicIOEnabled = Boolean(config.experimental.dynamicIO) + const isAuthInterruptsEnabled = Boolean( + config.experimental.authInterrupts + ) const isAppPPREnabled = checkIsAppPPREnabled(config.experimental.ppr) const routesManifestPath = path.join(distDir, ROUTES_MANIFEST) @@ -1989,6 +1992,7 @@ export default async function build( configFileName, runtimeEnvConfig, dynamicIO: isAppDynamicIOEnabled, + authInterrupts: isAuthInterruptsEnabled, httpAgentOptions: config.httpAgentOptions, locales: config.i18n?.locales, defaultLocale: config.i18n?.defaultLocale, @@ -2212,6 +2216,7 @@ export default async function build( edgeInfo, pageType, dynamicIO: isAppDynamicIOEnabled, + authInterrupts: isAuthInterruptsEnabled, cacheHandler: config.cacheHandler, cacheHandlers: config.experimental.cacheHandlers, isrFlushToDisk: ciEnvironment.hasNextSupport diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index f8f2febfb5edf2..09e1d12923fa00 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -1214,6 +1214,7 @@ export async function buildAppStaticPaths({ page, distDir, dynamicIO, + authInterrupts, configFileName, segments, isrFlushToDisk, @@ -1230,6 +1231,7 @@ export async function buildAppStaticPaths({ dir: string page: string dynamicIO: boolean + authInterrupts: boolean configFileName: string segments: AppSegment[] distDir: string @@ -1312,6 +1314,7 @@ export async function buildAppStaticPaths({ experimental: { after: false, dynamicIO, + authInterrupts, }, buildId, }, @@ -1487,6 +1490,7 @@ export async function isPageStatic({ edgeInfo, pageType, dynamicIO, + authInterrupts, originalAppPath, isrFlushToDisk, maxMemoryCacheSize, @@ -1501,6 +1505,7 @@ export async function isPageStatic({ page: string distDir: string dynamicIO: boolean + authInterrupts: boolean configFileName: string runtimeEnvConfig: any httpAgentOptions: NextConfigComplete['httpAgentOptions'] @@ -1642,6 +1647,7 @@ export async function isPageStatic({ dir, page, dynamicIO, + authInterrupts, configFileName, segments, distDir, diff --git a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts index 7366fac6768418..0cf487a0d1e44e 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts @@ -54,20 +54,30 @@ export type AppLoaderOptions = { } type AppLoader = webpack.LoaderDefinitionFunction +const HTTP_ACCESS_FALLBACKS = { + 'not-found': 'not-found', + forbidden: 'forbidden', + unauthorized: 'unauthorized', +} as const +const defaultHTTPAccessFallbackPaths = { + 'not-found': 'next/dist/client/components/not-found-error', + forbidden: 'next/dist/client/components/forbidden-error', + unauthorized: 'next/dist/client/components/unauthorized-error', +} as const + const FILE_TYPES = { layout: 'layout', template: 'template', error: 'error', loading: 'loading', - 'not-found': 'not-found', 'global-error': 'global-error', + ...HTTP_ACCESS_FALLBACKS, } as const const GLOBAL_ERROR_FILE_TYPE = 'global-error' const PAGE_SEGMENT = 'page$' const PARALLEL_CHILDREN_SEGMENT = 'children$' -const defaultNotFoundPath = 'next/dist/client/components/not-found-error' const defaultGlobalErrorPath = 'next/dist/client/components/error-boundary' const defaultLayoutPath = 'next/dist/client/components/default-layout' @@ -142,9 +152,6 @@ async function createTreeCodeFromPath( const isDefaultNotFound = isAppBuiltinNotFoundPage(pagePath) const appDirPrefix = isDefaultNotFound ? APP_DIR_ALIAS : splittedPath[0] - const hasRootNotFound = await resolver( - `${appDirPrefix}/${FILE_TYPES['not-found']}` - ) const pages: string[] = [] let rootLayout: string | undefined @@ -302,18 +309,35 @@ async function createTreeCodeFromPath( return false }) as [ValueOf, string][] - // Add default not found error as root not found if not present - const hasNotFoundFile = definedFilePaths.some( - ([type]) => type === 'not-found' + // Add default access fallback as root fallback if not present + const existedConventionNames = new Set( + definedFilePaths.map(([type]) => type) ) // If the first layer is a group route, we treat it as root layer const isFirstLayerGroupRoute = segments.length === 1 && subSegmentPath.filter((seg) => isGroupSegment(seg)).length === 1 - if ((isRootLayer || isFirstLayerGroupRoute) && !hasNotFoundFile) { - // If you already have a root not found, don't insert default not-found to group routes root - if (!(hasRootNotFound && isFirstLayerGroupRoute)) { - definedFilePaths.push(['not-found', defaultNotFoundPath]) + + if (isRootLayer || isFirstLayerGroupRoute) { + const accessFallbackTypes = Object.keys( + defaultHTTPAccessFallbackPaths + ) as (keyof typeof defaultHTTPAccessFallbackPaths)[] + for (const type of accessFallbackTypes) { + const hasRootFallbackFile = await resolver( + `${appDirPrefix}/${FILE_TYPES[type]}` + ) + const hasLayerFallbackFile = existedConventionNames.has(type) + + // If you already have a root access error fallback, don't insert default access error boundary to group routes root + if ( + // Is treated as root layout and without boundary + !(hasRootFallbackFile && isFirstLayerGroupRoute) && + // Does not have a fallback boundary file + !hasLayerFallbackFile + ) { + const defaultFallbackPath = defaultHTTPAccessFallbackPaths[type] + definedFilePaths.push([type, defaultFallbackPath]) + } } } @@ -358,7 +382,7 @@ async function createTreeCodeFromPath( if (isNotFoundRoute && normalizedParallelKey === 'children') { const notFoundPath = definedFilePaths.find(([type]) => type === 'not-found')?.[1] ?? - defaultNotFoundPath + defaultHTTPAccessFallbackPaths['not-found'] const varName = `notFound${nestedCollectedDeclarations.length}` nestedCollectedDeclarations.push([varName, notFoundPath]) diff --git a/packages/next/src/build/webpack/plugins/define-env-plugin.ts b/packages/next/src/build/webpack/plugins/define-env-plugin.ts index 83db74870655fd..5589a309040d4b 100644 --- a/packages/next/src/build/webpack/plugins/define-env-plugin.ts +++ b/packages/next/src/build/webpack/plugins/define-env-plugin.ts @@ -276,6 +276,8 @@ export function getDefineEnv({ // Internal only so untyped to avoid discovery (config.experimental as any).internal_disableSyncDynamicAPIWarnings ?? false, + 'process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS': + !!config.experimental.authInterrupts, ...(isNodeOrEdgeCompilation ? { // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) diff --git a/packages/next/src/client/components/dev-root-http-access-fallback-boundary.tsx b/packages/next/src/client/components/dev-root-http-access-fallback-boundary.tsx index 9043b6207592f7..d36538bb03498a 100644 --- a/packages/next/src/client/components/dev-root-http-access-fallback-boundary.tsx +++ b/packages/next/src/client/components/dev-root-http-access-fallback-boundary.tsx @@ -3,6 +3,7 @@ import React from 'react' import { HTTPAccessFallbackBoundary } from './http-access-fallback/error-boundary' +// TODO: error on using forbidden and unauthorized in root layout export function bailOnRootNotFound() { throw new Error('notFound() is not allowed to use in root layout') } diff --git a/packages/next/src/client/components/forbidden-error.tsx b/packages/next/src/client/components/forbidden-error.tsx new file mode 100644 index 00000000000000..332d491b8fc14f --- /dev/null +++ b/packages/next/src/client/components/forbidden-error.tsx @@ -0,0 +1,10 @@ +import { HTTPAccessErrorFallback } from './http-access-fallback/error-fallback' + +export default function Forbidden() { + return ( + + ) +} diff --git a/packages/next/src/client/components/forbidden.ts b/packages/next/src/client/components/forbidden.ts new file mode 100644 index 00000000000000..183b6cc1776d4a --- /dev/null +++ b/packages/next/src/client/components/forbidden.ts @@ -0,0 +1,33 @@ +import { + HTTP_ERROR_FALLBACK_ERROR_CODE, + type HTTPAccessFallbackError, +} from './http-access-fallback/http-access-fallback' + +// TODO: Add `forbidden` docs +/** + * @experimental + * This function allows you to render the [forbidden.js file](https://nextjs.org/docs/app/api-reference/file-conventions/forbidden) + * within a route segment as well as inject a tag. + * + * `forbidden()` can be used in + * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), + * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and + * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). + * + * Read more: [Next.js Docs: `forbidden`](https://nextjs.org/docs/app/api-reference/functions/forbidden) + */ +export function forbidden(): never { + if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) { + throw new Error( + `\`forbidden()\` is experimental and only allowed to be enabled when \`experimental.authInterrupts\` is enabled.` + ) + } + + // eslint-disable-next-line no-throw-literal + const error = new Error( + HTTP_ERROR_FALLBACK_ERROR_CODE + ) as HTTPAccessFallbackError + ;(error as HTTPAccessFallbackError).digest = + `${HTTP_ERROR_FALLBACK_ERROR_CODE};403` + throw error +} diff --git a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx index cf5c39079f6ec3..ef5c7cc8a74ca2 100644 --- a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx +++ b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx @@ -9,25 +9,23 @@ * e.g. 404 * 404 represents not found, and the fallback component pair contains the component and its styles. * - * TODO: support 401 and 403 HTTP errors. */ import React, { useContext } from 'react' import { useUntrackedPathname } from '../navigation-untracked' import { + HTTPAccessErrorStatus, getAccessFallbackHTTPStatus, + getAccessFallbackErrorTypeByStatus, isHTTPAccessFallbackError, } from './http-access-fallback' import { warnOnce } from '../../../shared/lib/utils/warn-once' import { MissingSlotContext } from '../../../shared/lib/app-router-context.shared-runtime' -const HTTPErrorStatus = { - NOT_FOUND: 404, - // TODO: support 401 and 403 HTTP errors. -} as const - interface HTTPAccessFallbackBoundaryProps { notFound?: React.ReactNode + forbidden?: React.ReactNode + unauthorized?: React.ReactNode children: React.ReactNode missingSlots?: Set } @@ -81,8 +79,9 @@ class HTTPAccessFallbackErrorBoundary extends React.Component< static getDerivedStateFromError(error: any) { if (isHTTPAccessFallbackError(error)) { + const httpStatus = getAccessFallbackHTTPStatus(error) return { - triggeredStatus: getAccessFallbackHTTPStatus(error), + triggeredStatus: httpStatus, } } // Re-throw if error is not for 404 @@ -112,25 +111,49 @@ class HTTPAccessFallbackErrorBoundary extends React.Component< } render() { - const { notFound } = this.props - if (this.state.triggeredStatus === HTTPErrorStatus.NOT_FOUND) { + const { notFound, forbidden, unauthorized, children } = this.props + const { triggeredStatus } = this.state + const errorComponents = { + [HTTPAccessErrorStatus.NOT_FOUND]: notFound, + [HTTPAccessErrorStatus.FORBIDDEN]: forbidden, + [HTTPAccessErrorStatus.UNAUTHORIZED]: unauthorized, + } + + if (triggeredStatus) { + const isNotFound = + triggeredStatus === HTTPAccessErrorStatus.NOT_FOUND && notFound + const isForbidden = + triggeredStatus === HTTPAccessErrorStatus.FORBIDDEN && forbidden + const isUnauthorized = + triggeredStatus === HTTPAccessErrorStatus.UNAUTHORIZED && unauthorized + + // If there's no matched boundary in this layer, keep throwing the error by rendering the children + if (!(isNotFound || isForbidden || isUnauthorized)) { + return children + } + return ( <> {process.env.NODE_ENV === 'development' && ( - + )} - {notFound} + {errorComponents[triggeredStatus]} ) } - return this.props.children + return children } } export function HTTPAccessFallbackBoundary({ notFound, + forbidden, + unauthorized, children, }: HTTPAccessFallbackBoundaryProps) { // When we're rendering the missing params shell, this will return null. This @@ -139,12 +162,15 @@ export function HTTPAccessFallbackBoundary({ // (where these error can occur), we will get the correct pathname. const pathname = useUntrackedPathname() const missingSlots = useContext(MissingSlotContext) + const hasErrorFallback = !!(notFound || forbidden || unauthorized) - if (notFound) { + if (hasErrorFallback) { return ( {children} diff --git a/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts b/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts index e2d63d525f91cf..87b8366cab1f09 100644 --- a/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts +++ b/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts @@ -1,4 +1,10 @@ -const ALLOWED_CODES = new Set([404]) +export const HTTPAccessErrorStatus = { + NOT_FOUND: 404, + FORBIDDEN: 403, + UNAUTHORIZED: 401, +} + +const ALLOWED_CODES = new Set(Object.values(HTTPAccessErrorStatus)) export const HTTP_ERROR_FALLBACK_ERROR_CODE = 'NEXT_HTTP_ERROR_FALLBACK' @@ -41,9 +47,12 @@ export function getAccessFallbackHTTPStatus( export function getAccessFallbackErrorTypeByStatus( status: number -): 'not-found' | undefined { - // TODO: support 403 and 401 +): 'not-found' | 'forbidden' | 'unauthorized' | undefined { switch (status) { + case 401: + return 'unauthorized' + case 403: + return 'forbidden' case 404: return 'not-found' default: diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index 80fe1786086bd9..a8ee010ccd27ed 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -513,6 +513,8 @@ export default function OuterLayoutRouter({ templateScripts, template, notFound, + forbidden, + unauthorized, }: { parallelRouterKey: string segmentPath: FlightSegmentPath @@ -523,6 +525,8 @@ export default function OuterLayoutRouter({ templateScripts: React.ReactNode | undefined template: React.ReactNode notFound: React.ReactNode | undefined + forbidden: React.ReactNode | undefined + unauthorized: React.ReactNode | undefined }) { const context = useContext(LayoutRouterContext) if (!context) { @@ -579,7 +583,11 @@ export default function OuterLayoutRouter({ errorScripts={errorScripts} > - + + ) +} diff --git a/packages/next/src/client/components/unauthorized.ts b/packages/next/src/client/components/unauthorized.ts new file mode 100644 index 00000000000000..3f89570961abd7 --- /dev/null +++ b/packages/next/src/client/components/unauthorized.ts @@ -0,0 +1,34 @@ +import { + HTTP_ERROR_FALLBACK_ERROR_CODE, + type HTTPAccessFallbackError, +} from './http-access-fallback/http-access-fallback' + +// TODO: Add `unauthorized` docs +/** + * @experimental + * This function allows you to render the [unauthorized.js file](https://nextjs.org/docs/app/api-reference/file-conventions/unauthorized) + * within a route segment as well as inject a tag. + * + * `unauthorized()` can be used in + * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), + * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and + * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). + * + * + * Read more: [Next.js Docs: `unauthorized`](https://nextjs.org/docs/app/api-reference/functions/unauthorized) + */ +export function unauthorized(): never { + if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) { + throw new Error( + `\`unauthorized()\` is experimental and only allowed to be used when \`experimental.authInterrupts\` is enabled.` + ) + } + + // eslint-disable-next-line no-throw-literal + const error = new Error( + HTTP_ERROR_FALLBACK_ERROR_CODE + ) as HTTPAccessFallbackError + ;(error as HTTPAccessFallbackError).digest = + `${HTTP_ERROR_FALLBACK_ERROR_CODE};401` + throw error +} diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 7d346dc174d4a5..d8d4d941a67a01 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -359,6 +359,7 @@ async function exportAppImpl( after: nextConfig.experimental.after ?? false, dynamicIO: nextConfig.experimental.dynamicIO ?? false, inlineCss: nextConfig.experimental.inlineCss ?? false, + authInterrupts: !!nextConfig.experimental.authInterrupts, }, reactMaxHeadersLength: nextConfig.reactMaxHeadersLength, } diff --git a/packages/next/src/export/routes/app-route.ts b/packages/next/src/export/routes/app-route.ts index b55b59316b15f8..e938d76622e13a 100644 --- a/packages/next/src/export/routes/app-route.ts +++ b/packages/next/src/export/routes/app-route.ts @@ -47,7 +47,9 @@ export async function exportAppRoute( }, htmlFilepath: string, fileWriter: FileWriter, - experimental: Required>, + experimental: Required< + Pick + >, buildId: string ): Promise { // Ensure that the URL is absolute. diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 9c7534b73ec844..84f4184c470538 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -67,7 +67,7 @@ type ViewportResolver = ( parent: ResolvingViewport ) => Viewport | Promise -export type MetadataErrorType = 'not-found' +export type MetadataErrorType = 'not-found' | 'forbidden' | 'unauthorized' export type MetadataItems = [ Metadata | MetadataResolver | null, diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index afcdd59e2ed593..f98dd1f43ddb24 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -760,6 +760,7 @@ async function getRSCPayload( getMetadataReady, missingSlots, preloadCallbacks, + authInterrupts: ctx.renderOpts.experimental.authInterrupts, }) // When the `vary` response header is present with `Next-URL`, that means there's a chance diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index aae3045113d99e..392f6a9f1f1c90 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -39,6 +39,7 @@ export function createComponentTree(props: { ctx: AppRenderContext missingSlots?: Set preloadCallbacks: PreloadCallbacks + authInterrupts: boolean }): Promise { return getTracer().trace( NextNodeServerSpan.createComponentTree, @@ -74,6 +75,7 @@ async function createComponentTreeInternal({ ctx, missingSlots, preloadCallbacks, + authInterrupts, }: { createSegmentPath: CreateSegmentPath loaderTree: LoaderTree @@ -87,6 +89,7 @@ async function createComponentTreeInternal({ ctx: AppRenderContext missingSlots?: Set preloadCallbacks: PreloadCallbacks + authInterrupts: boolean }): Promise { const { renderOpts: { nextConfigOutput, experimental }, @@ -114,7 +117,15 @@ async function createComponentTreeInternal({ const { page, layoutOrPagePath, segment, modules, parallelRoutes } = parseLoaderTree(tree) - const { layout, template, error, loading, 'not-found': notFound } = modules + const { + layout, + template, + error, + loading, + 'not-found': notFound, + forbidden, + unauthorized, + } = modules const injectedCSSWithCurrentLayout = new Set(injectedCSS) const injectedJSWithCurrentLayout = new Set(injectedJS) @@ -195,6 +206,40 @@ async function createComponentTreeInternal({ }) : [] + const [Forbidden, forbiddenStyles] = + authInterrupts && forbidden + ? await createComponentStylesAndScripts({ + ctx, + filePath: forbidden[1], + getComponent: forbidden[0], + injectedCSS: injectedCSSWithCurrentLayout, + injectedJS: injectedJSWithCurrentLayout, + }) + : [] + const forbiddenElement = Forbidden ? ( + <> + {forbiddenStyles} + + + ) : undefined + + const [Unauthorized, unauthorizedStyles] = + authInterrupts && unauthorized + ? await createComponentStylesAndScripts({ + ctx, + filePath: unauthorized[1], + getComponent: unauthorized[0], + injectedCSS: injectedCSSWithCurrentLayout, + injectedJS: injectedJSWithCurrentLayout, + }) + : [] + const unauthorizedElement = Unauthorized ? ( + <> + {unauthorizedStyles} + + + ) : undefined + let dynamic = layoutOrPageMod?.dynamic if (nextConfigOutput === 'export') { @@ -314,6 +359,17 @@ async function createComponentTreeInternal({ if (typeof NotFound !== 'undefined' && !isValidElementType(NotFound)) { errorMissingDefaultExport(pagePath, 'not-found') } + + if (typeof Forbidden !== 'undefined' && !isValidElementType(Forbidden)) { + errorMissingDefaultExport(pagePath, 'forbidden') + } + + if ( + typeof Unauthorized !== 'undefined' && + !isValidElementType(Unauthorized) + ) { + errorMissingDefaultExport(pagePath, 'unauthorized') + } } // Handle dynamic segment params. @@ -354,6 +410,14 @@ async function createComponentTreeInternal({ ) : undefined + const forbiddenComponent = isChildrenRouteKey + ? forbiddenElement + : undefined + + const unauthorizedComponent = isChildrenRouteKey + ? unauthorizedElement + : undefined + // if we're prefetching and that there's a Loading component, we bail out // otherwise we keep rendering for the prefetch. // We also want to bail out if there's no Loading component in the tree. @@ -426,6 +490,7 @@ async function createComponentTreeInternal({ ctx, missingSlots, preloadCallbacks, + authInterrupts: authInterrupts, }) childCacheNodeSeedData = seedData @@ -449,6 +514,8 @@ async function createComponentTreeInternal({ templateStyles={templateStyles} templateScripts={templateScripts} notFound={notFoundComponent} + forbidden={forbiddenComponent} + unauthorized={unauthorizedComponent} />, childCacheNodeSeedData, ] @@ -622,6 +689,9 @@ async function createComponentTreeInternal({ } if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { + let notfoundClientSegment: React.ReactNode + let forbiddenClientSegment: React.ReactNode + let unauthorizedClientSegment: React.ReactNode // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. // This ensures that a `HTTPAccessFallbackBoundary` is available for when that happens, // but it's not ideal, as it needlessly invokes the `NotFound` component and renders the `RootLayout` twice. @@ -636,23 +706,58 @@ async function createComponentTreeInternal({ ), } - const notfoundClientSegment = ( - + notfoundClientSegment = ( + <> + {layerAssets} + + ) - + } + if (Forbidden) { + const forbiddenParallelRouteProps = { + children: forbiddenElement, + } + forbiddenClientSegment = ( + <> + {layerAssets} + + + ) + } + if (Unauthorized) { + const unauthorizedParallelRouteProps = { + children: unauthorizedElement, + } + unauthorizedClientSegment = ( + <> + {layerAssets} + + + ) + } + if ( + notfoundClientSegment || + forbiddenClientSegment || + unauthorizedClientSegment + ) { segmentNode = ( - {layerAssets} - {notfoundClientSegment} - - } + notFound={notfoundClientSegment} + forbidden={forbiddenClientSegment} + unauthorized={unauthorizedClientSegment} > {layerAssets} {clientSegment} diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 8a0ff55eb0159f..d46ffccb0d1a53 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -183,6 +183,7 @@ export interface RenderOptsPartial { after: boolean dynamicIO: boolean inlineCss: boolean + authInterrupts: boolean } postponed?: string diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx index bf32e6bfbac29c..100ef3ceeb5daf 100644 --- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx +++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx @@ -160,6 +160,7 @@ export async function walkTreeWithFlightRouterState({ rootLayoutIncluded, getMetadataReady, preloadCallbacks, + authInterrupts: experimental.authInterrupts, } ) diff --git a/packages/next/src/server/async-storage/work-store.ts b/packages/next/src/server/async-storage/work-store.ts index 3939fbc3be9396..770ab976c8c69d 100644 --- a/packages/next/src/server/async-storage/work-store.ts +++ b/packages/next/src/server/async-storage/work-store.ts @@ -33,7 +33,7 @@ export type WorkStoreContext = { pendingWaitUntil?: Promise experimental: Pick< RenderOpts['experimental'], - 'isRoutePPREnabled' | 'after' | 'dynamicIO' + 'isRoutePPREnabled' | 'after' | 'dynamicIO' | 'authInterrupts' > /** diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index a7846af902afdb..932ef6192022de 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -594,6 +594,7 @@ export default abstract class Server< after: this.nextConfig.experimental.after ?? false, dynamicIO: this.nextConfig.experimental.dynamicIO ?? false, inlineCss: this.nextConfig.experimental.inlineCss ?? false, + authInterrupts: !!this.nextConfig.experimental.authInterrupts, }, onInstrumentationRequestError: this.instrumentationOnRequestError.bind(this), @@ -2481,6 +2482,7 @@ export default abstract class Server< experimental: { after: renderOpts.experimental.after, dynamicIO: renderOpts.experimental.dynamicIO, + authInterrupts: renderOpts.experimental.authInterrupts, }, supportsDynamicResponse, incrementalCache, diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 4467cc378f8b39..ea6f795db6bc8e 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -445,6 +445,7 @@ export const configSchema: zod.ZodType = z.lazy(() => staticGenerationMinPagesPerWorker: z.number().int().optional(), typedEnv: z.boolean().optional(), serverComponentsHmrCache: z.boolean().optional(), + authInterrupts: z.boolean().optional(), }) .optional(), exportPathMap: z diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 551a9a55fa940e..ff89b74a06866d 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -562,6 +562,12 @@ export interface ExperimentalConfig { * Supports app-router in production mode only. */ inlineCss?: boolean + + // TODO: Remove this config when the API is stable. + /** + * This config allows you to enable the experimental navigation API `forbidden` and `unauthorized`. + */ + authInterrupts?: boolean } export type ExportPathMap = { @@ -1149,6 +1155,7 @@ export const defaultConfig: NextConfig = { process.env.__NEXT_TEST_MODE && process.env.__NEXT_EXPERIMENTAL_PPR === 'true' ), + authInterrupts: false, reactOwnerStack: false, webpackBuildWorker: undefined, webpackMemoryOptimizations: false, diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 5bf7cb2dea701d..2aea937b5ce276 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -779,6 +779,7 @@ export default class DevServer extends Server { maxMemoryCacheSize: this.nextConfig.cacheMaxMemorySize, nextConfigOutput: this.nextConfig.output, buildId: this.renderOpts.buildId, + authInterrupts: !!this.nextConfig.experimental.authInterrupts, }) return pathsResult } finally { diff --git a/packages/next/src/server/dev/static-paths-worker.ts b/packages/next/src/server/dev/static-paths-worker.ts index d95a1b91ab104f..28ac0fa167ad71 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -49,6 +49,7 @@ export async function loadStaticPaths({ cacheLifeProfiles, nextConfigOutput, buildId, + authInterrupts, }: { dir: string distDir: string @@ -69,6 +70,7 @@ export async function loadStaticPaths({ } nextConfigOutput: 'standalone' | 'export' | undefined buildId: string + authInterrupts: boolean }): Promise { // update work memory runtime-config require('../../shared/lib/runtime-config.external').setConfig(config) @@ -107,6 +109,7 @@ export async function loadStaticPaths({ nextConfigOutput, isRoutePPREnabled, buildId, + authInterrupts, }) } else if (!components.getStaticPaths) { // We shouldn't get to this point since the worker should only be called for diff --git a/packages/next/src/server/lib/app-dir-module.ts b/packages/next/src/server/lib/app-dir-module.ts index 3e2438ed074a55..3e5bc9dfcf5827 100644 --- a/packages/next/src/server/lib/app-dir-module.ts +++ b/packages/next/src/server/lib/app-dir-module.ts @@ -40,7 +40,7 @@ export async function getLayoutOrPageModule(loaderTree: LoaderTree) { export async function getComponentTypeModule( loaderTree: LoaderTree, - moduleType: 'layout' | 'not-found' + moduleType: 'layout' | 'not-found' | 'forbidden' | 'unauthorized' ) { const { [moduleType]: module } = loaderTree[2] if (typeof module !== 'undefined') { diff --git a/packages/next/src/server/web/adapter.ts b/packages/next/src/server/web/adapter.ts index da6960c8c4d740..1916462c585289 100644 --- a/packages/next/src/server/web/adapter.ts +++ b/packages/next/src/server/web/adapter.ts @@ -271,6 +271,8 @@ export async function adapter( after: isAfterEnabled, isRoutePPREnabled: false, dynamicIO: false, + authInterrupts: + !!params.request.nextConfig?.experimental?.authInterrupts, }, buildId: buildId ?? '', supportsDynamicResponse: true, diff --git a/packages/next/src/server/web/edge-route-module-wrapper.ts b/packages/next/src/server/web/edge-route-module-wrapper.ts index e70add8692d45b..081f9e21e28737 100644 --- a/packages/next/src/server/web/edge-route-module-wrapper.ts +++ b/packages/next/src/server/web/edge-route-module-wrapper.ts @@ -118,6 +118,7 @@ export class EdgeRouteModuleWrapper { experimental: { after: isAfterEnabled, dynamicIO: !!process.env.__NEXT_DYNAMIC_IO, + authInterrupts: !!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS, }, buildId: '', // TODO: Populate this properly. cacheLifeProfiles: this.nextConfig.experimental.cacheLife, diff --git a/packages/next/src/server/web/types.ts b/packages/next/src/server/web/types.ts index cce93692b13ee8..1bc4eca695d2f8 100644 --- a/packages/next/src/server/web/types.ts +++ b/packages/next/src/server/web/types.ts @@ -15,7 +15,10 @@ export interface RequestData { basePath?: string i18n?: I18NConfig | null trailingSlash?: boolean - experimental?: Pick + experimental?: Pick< + ExperimentalConfig, + 'after' | 'cacheLife' | 'authInterrupts' + > } page?: { name?: string diff --git a/test/development/acceptance-app/hydration-error.test.ts b/test/development/acceptance-app/hydration-error.test.ts index 890ee7f015595e..b9052eabc5f9a8 100644 --- a/test/development/acceptance-app/hydration-error.test.ts +++ b/test/development/acceptance-app/hydration-error.test.ts @@ -434,8 +434,8 @@ describe('Error overlay for hydration errors in App router', () => { - - + + @@ -888,7 +888,7 @@ describe('Error overlay for hydration errors in App router', () => { if (isTurbopack) { expect(fullPseudoHtml).toMatchInlineSnapshot(` "... - + @@ -908,7 +908,7 @@ describe('Error overlay for hydration errors in App router', () => { } else { expect(fullPseudoHtml).toMatchInlineSnapshot(` "... - + diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/[id]/page.js b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/[id]/page.js new file mode 100644 index 00000000000000..af02a02ef0d38a --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/[id]/page.js @@ -0,0 +1,16 @@ +import { forbidden } from 'next/navigation' + +// avoid static generation to fill the dynamic params +export const dynamic = 'force-dynamic' + +export default async function Page(props) { + const params = await props.params + + const { id } = params + + if (id === '403') { + forbidden() + } + + return

{`dynamic-layout-without-forbidden [id]`}

+} diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/layout.js b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/layout.js new file mode 100644 index 00000000000000..7a2caa5a830c8b --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/layout.js @@ -0,0 +1,8 @@ +export default function Layout({ children }) { + return ( +
+

Dynamic with Layout

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/page.js b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/page.js new file mode 100644 index 00000000000000..b68eb6e9aebfab --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return
dynamic-with-layout
+} diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js b/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js new file mode 100644 index 00000000000000..fc34d5c8ba032e --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js @@ -0,0 +1,3 @@ +export default function Forbidden() { + return
{`dynamic/[id] forbidden`}
+} diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js b/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js new file mode 100644 index 00000000000000..ffa29943c9bf62 --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js @@ -0,0 +1,13 @@ +import { forbidden } from 'next/navigation' + +export default async function Page(props) { + const params = await props.params + + const { id } = params + + if (id === '403') { + forbidden() + } + + return

{`dynamic [id]`}

+} diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic/page.js b/test/e2e/app-dir/forbidden/basic/app/dynamic/page.js new file mode 100644 index 00000000000000..a4337ecf2b9008 --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/dynamic/page.js @@ -0,0 +1,3 @@ +export default function Test() { + return
dynamic
+} diff --git a/test/e2e/app-dir/forbidden/basic/app/forbidden.js b/test/e2e/app-dir/forbidden/basic/app/forbidden.js new file mode 100644 index 00000000000000..ac06bff1cb4d63 --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/forbidden.js @@ -0,0 +1,9 @@ +export default function Forbidden() { + return ( + <> +

Root Forbidden

+ +
{Date.now()}
+ + ) +} diff --git a/test/e2e/app-dir/forbidden/basic/app/layout.js b/test/e2e/app-dir/forbidden/basic/app/layout.js new file mode 100644 index 00000000000000..ba30dadf0ac5eb --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/layout.js @@ -0,0 +1,16 @@ +export default function Layout({ children }) { + return ( + + + +
+ +
+
{children}
+
+ +
+ + + ) +} diff --git a/test/e2e/app-dir/forbidden/basic/app/page.js b/test/e2e/app-dir/forbidden/basic/app/page.js new file mode 100644 index 00000000000000..e5c1e42bcc3855 --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

My page

+} diff --git a/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts b/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts new file mode 100644 index 00000000000000..a92c1ebb0a571c --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts @@ -0,0 +1,41 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('app dir - forbidden with customized boundary', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should match dynamic route forbidden boundary correctly', async () => { + // `/dynamic` display works + const browserDynamic = await next.browser('/dynamic') + expect(await browserDynamic.elementByCss('main').text()).toBe('dynamic') + + // `/dynamic/403` calling forbidden() will match the same level forbidden boundary + const browserError = await next.browser('/dynamic/403') + expect(await browserError.elementByCss('#forbidden').text()).toBe( + 'dynamic/[id] forbidden' + ) + + const browserDynamicId = await next.browser('/dynamic/123') + expect(await browserDynamicId.elementByCss('#page').text()).toBe( + 'dynamic [id]' + ) + }) + + it('should escalate forbidden to parent layout if no forbidden boundary present in current layer', async () => { + const browserDynamic = await next.browser( + '/dynamic-layout-without-forbidden' + ) + expect(await browserDynamic.elementByCss('h1').text()).toBe( + 'Dynamic with Layout' + ) + + // no forbidden boundary in /dynamic-layout-without-forbidden, escalate to parent layout to render root forbidden + const browserDynamicId = await next.browser( + '/dynamic-layout-without-forbidden/403' + ) + expect(await browserDynamicId.elementByCss('h1').text()).toBe( + 'Root Forbidden' + ) + }) +}) diff --git a/test/e2e/app-dir/forbidden/basic/next.config.js b/test/e2e/app-dir/forbidden/basic/next.config.js new file mode 100644 index 00000000000000..b59dc097c72166 --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + authInterrupts: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/forbidden/default/app/(group)/group-dynamic/[id]/page.js b/test/e2e/app-dir/forbidden/default/app/(group)/group-dynamic/[id]/page.js new file mode 100644 index 00000000000000..9cb23b1bfc9f3c --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/app/(group)/group-dynamic/[id]/page.js @@ -0,0 +1,10 @@ +import { forbidden } from 'next/navigation' + +export default async function Page(props) { + const params = await props.params + if (params.id === '403') { + forbidden() + } + + return

{`group-dynamic [id]`}

+} diff --git a/test/e2e/app-dir/forbidden/default/app/(group)/layout.js b/test/e2e/app-dir/forbidden/default/app/(group)/layout.js new file mode 100644 index 00000000000000..a0c72aae7125aa --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/app/(group)/layout.js @@ -0,0 +1,3 @@ +export default function RootLayout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/forbidden/default/app/forbidden-trigger.js b/test/e2e/app-dir/forbidden/default/app/forbidden-trigger.js new file mode 100644 index 00000000000000..8f416d432b70d0 --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/app/forbidden-trigger.js @@ -0,0 +1,12 @@ +'use client' + +import { useSearchParams, forbidden } from 'next/navigation' + +export default function ForbiddenTrigger() { + const searchParams = useSearchParams() + + if (searchParams.get('root-forbidden')) { + forbidden() + } + return null +} diff --git a/test/e2e/app-dir/forbidden/default/app/layout.js b/test/e2e/app-dir/forbidden/default/app/layout.js new file mode 100644 index 00000000000000..83a91e8712855d --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/app/layout.js @@ -0,0 +1,28 @@ +'use client' + +import { useState, Suspense } from 'react' +import { forbidden } from 'next/navigation' +import ForbiddenTrigger from './forbidden-trigger' + +export default function Root({ children }) { + const [clicked, setClicked] = useState(false) + if (clicked) { + forbidden() + } + + return ( + + + Loading...}> + + + + {children} + + + ) +} + +export const dynamic = 'force-dynamic' diff --git a/test/e2e/app-dir/forbidden/default/app/metadata-layout-forbidden/layout.js b/test/e2e/app-dir/forbidden/default/app/metadata-layout-forbidden/layout.js new file mode 100644 index 00000000000000..64041291e79dff --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/app/metadata-layout-forbidden/layout.js @@ -0,0 +1,9 @@ +import { forbidden } from 'next/navigation' + +export async function generateMetadata() { + forbidden() +} + +export default function layout({ children }) { + return children +} diff --git a/test/e2e/app-dir/forbidden/default/app/metadata-layout-forbidden/page.js b/test/e2e/app-dir/forbidden/default/app/metadata-layout-forbidden/page.js new file mode 100644 index 00000000000000..5796bdb2da717d --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/app/metadata-layout-forbidden/page.js @@ -0,0 +1,3 @@ +export default function page() { + return 'not found metadata page' +} diff --git a/test/e2e/app-dir/forbidden/default/app/navigate-forbidden/page.js b/test/e2e/app-dir/forbidden/default/app/navigate-forbidden/page.js new file mode 100644 index 00000000000000..37fa80581f3e0c --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/app/navigate-forbidden/page.js @@ -0,0 +1,5 @@ +import { forbidden } from 'next/navigation' + +export default function page() { + forbidden() +} diff --git a/test/e2e/app-dir/forbidden/default/app/page.js b/test/e2e/app-dir/forbidden/default/app/page.js new file mode 100644 index 00000000000000..5576b676484a6d --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/app/page.js @@ -0,0 +1,19 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+

hello world

+
+ + navigate to page calling forbidden() + +
+
+ + navigate to layout with metadata API calling forbidden() + +
+
+ ) +} diff --git a/test/e2e/app-dir/forbidden/default/forbidden-default.test.ts b/test/e2e/app-dir/forbidden/default/forbidden-default.test.ts new file mode 100644 index 00000000000000..37635a9f2ea747 --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/forbidden-default.test.ts @@ -0,0 +1,79 @@ +import { nextTestSetup } from 'e2e-utils' +import { + assertHasRedbox, + assertNoRedbox, + getRedboxDescription, +} from 'next-test-utils' + +describe('app dir - forbidden with default forbidden boundary', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + // TODO: error forbidden usage in root layout + it.skip('should error on client forbidden from root layout in browser', async () => { + const browser = await next.browser('/') + + await browser.elementByCss('#trigger-forbidden').click() + + if (isNextDev) { + await assertHasRedbox(browser) + expect(await getRedboxDescription(browser)).toMatch( + /forbidden\(\) is not allowed to use in root layout/ + ) + } + }) + + // TODO: error forbidden usage in root layout + it.skip('should error on server forbidden from root layout on server-side', async () => { + const browser = await next.browser('/?root-forbidden=1') + + if (isNextDev) { + await assertHasRedbox(browser) + expect(await getRedboxDescription(browser)).toBe( + 'Error: forbidden() is not allowed to use in root layout' + ) + } + }) + + it('should be able to navigate to page calling forbidden', async () => { + const browser = await next.browser('/') + + await browser.elementByCss('#navigate-forbidden').click() + await browser.waitForElementByCss('.next-error-h1') + + expect(await browser.elementByCss('h1').text()).toBe('403') + expect(await browser.elementByCss('h2').text()).toBe( + 'This page could not be accessed.' + ) + }) + + it('should be able to navigate to page with calling forbidden in metadata', async () => { + const browser = await next.browser('/') + + await browser.elementByCss('#metadata-layout-forbidden').click() + await browser.waitForElementByCss('.next-error-h1') + + expect(await browser.elementByCss('h1').text()).toBe('403') + expect(await browser.elementByCss('h2').text()).toBe( + 'This page could not be accessed.' + ) + }) + + it('should render default forbidden for group routes if forbidden is not defined', async () => { + const browser = await next.browser('/group-dynamic/123') + expect(await browser.elementByCss('#page').text()).toBe( + 'group-dynamic [id]' + ) + + await browser.loadPage(next.url + '/group-dynamic/403') + await assertNoRedbox(browser) + await browser.waitForElementByCss('.group-root-layout') + expect(await browser.elementByCss('.next-error-h1').text()).toBe('403') + }) +}) diff --git a/test/e2e/app-dir/forbidden/default/next.config.js b/test/e2e/app-dir/forbidden/default/next.config.js new file mode 100644 index 00000000000000..b59dc097c72166 --- /dev/null +++ b/test/e2e/app-dir/forbidden/default/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + authInterrupts: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/[id]/page.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/[id]/page.js new file mode 100644 index 00000000000000..3e25b65a549d08 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/[id]/page.js @@ -0,0 +1,13 @@ +import { unauthorized } from 'next/navigation' + +export default async function Page(props) { + const params = await props.params + + const { id } = params + + if (id === '401') { + unauthorized() + } + + return

{`dynamic-layout-without-unauthorized [id]`}

+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/layout.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/layout.js new file mode 100644 index 00000000000000..7a2caa5a830c8b --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/layout.js @@ -0,0 +1,8 @@ +export default function Layout({ children }) { + return ( +
+

Dynamic with Layout

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/page.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/page.js new file mode 100644 index 00000000000000..b68eb6e9aebfab --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return
dynamic-with-layout
+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/page.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/page.js new file mode 100644 index 00000000000000..8d7218f10abd89 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/page.js @@ -0,0 +1,13 @@ +import { unauthorized } from 'next/navigation' + +export default async function Page(props) { + const params = await props.params + + const { id } = params + + if (id === '401') { + unauthorized() + } + + return

{`dynamic [id]`}

+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/unauthorized.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/unauthorized.js new file mode 100644 index 00000000000000..7ffc5beaefbdec --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/unauthorized.js @@ -0,0 +1,3 @@ +export default function Unauthorized() { + return
{`dynamic/[id] unauthorized`}
+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic/page.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic/page.js new file mode 100644 index 00000000000000..a4337ecf2b9008 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic/page.js @@ -0,0 +1,3 @@ +export default function Test() { + return
dynamic
+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/layout.js b/test/e2e/app-dir/unauthorized/basic/app/layout.js new file mode 100644 index 00000000000000..ba30dadf0ac5eb --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/layout.js @@ -0,0 +1,16 @@ +export default function Layout({ children }) { + return ( + + + +
+ +
+
{children}
+
+ +
+ + + ) +} diff --git a/test/e2e/app-dir/unauthorized/basic/app/page.js b/test/e2e/app-dir/unauthorized/basic/app/page.js new file mode 100644 index 00000000000000..e5c1e42bcc3855 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

My page

+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/unauthorized.js b/test/e2e/app-dir/unauthorized/basic/app/unauthorized.js new file mode 100644 index 00000000000000..cb04c0c2df4fb9 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/unauthorized.js @@ -0,0 +1,9 @@ +export default function Forbidden() { + return ( + <> +

Root Unauthorized

+ +
{Date.now()}
+ + ) +} diff --git a/test/e2e/app-dir/unauthorized/basic/next.config.js b/test/e2e/app-dir/unauthorized/basic/next.config.js new file mode 100644 index 00000000000000..b59dc097c72166 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + authInterrupts: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts b/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts new file mode 100644 index 00000000000000..f71a60b0c03a2d --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts @@ -0,0 +1,41 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('app dir - unauthorized - basic', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should match dynamic route unauthorized boundary correctly', async () => { + // `/dynamic` display works + const browserDynamic = await next.browser('/dynamic') + expect(await browserDynamic.elementByCss('main').text()).toBe('dynamic') + + // `/dynamic/401` calling unauthorized() will match the same level unauthorized boundary + const browserError = await next.browser('/dynamic/401') + expect(await browserError.elementByCss('#unauthorized').text()).toBe( + 'dynamic/[id] unauthorized' + ) + + const browserDynamicId = await next.browser('/dynamic/123') + expect(await browserDynamicId.elementByCss('#page').text()).toBe( + 'dynamic [id]' + ) + }) + + it('should escalate unauthorized to parent layout if no unauthorized boundary present in current layer', async () => { + const browserDynamic = await next.browser( + '/dynamic-layout-without-unauthorized' + ) + expect(await browserDynamic.elementByCss('h1').text()).toBe( + 'Dynamic with Layout' + ) + + // no unauthorized boundary in /dynamic-layout-without-unauthorized, escalate to parent layout to render root unauthorized + const browserDynamicId = await next.browser( + '/dynamic-layout-without-unauthorized/401' + ) + expect(await browserDynamicId.elementByCss('h1').text()).toBe( + 'Root Unauthorized' + ) + }) +}) diff --git a/test/e2e/app-dir/unauthorized/default/app/(group)/group-dynamic/[id]/page.js b/test/e2e/app-dir/unauthorized/default/app/(group)/group-dynamic/[id]/page.js new file mode 100644 index 00000000000000..8bdaa4dc3a0b5d --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/app/(group)/group-dynamic/[id]/page.js @@ -0,0 +1,10 @@ +import { unauthorized } from 'next/navigation' + +export default async function Page(props) { + const params = await props.params + if (params.id === '401') { + unauthorized() + } + + return

{`group-dynamic [id]`}

+} diff --git a/test/e2e/app-dir/unauthorized/default/app/(group)/layout.js b/test/e2e/app-dir/unauthorized/default/app/(group)/layout.js new file mode 100644 index 00000000000000..a0c72aae7125aa --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/app/(group)/layout.js @@ -0,0 +1,3 @@ +export default function RootLayout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/unauthorized/default/app/layout.js b/test/e2e/app-dir/unauthorized/default/app/layout.js new file mode 100644 index 00000000000000..114d2ff40bdb1b --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/app/layout.js @@ -0,0 +1,28 @@ +'use client' + +import { useState, Suspense } from 'react' +import { unauthorized } from 'next/navigation' +import ForbiddenTrigger from './unauthorized-trigger' + +export default function Root({ children }) { + const [clicked, setClicked] = useState(false) + if (clicked) { + unauthorized() + } + + return ( + + + Loading...}> + + + + {children} + + + ) +} + +export const dynamic = 'force-dynamic' diff --git a/test/e2e/app-dir/unauthorized/default/app/metadata-layout-unauthorized/layout.js b/test/e2e/app-dir/unauthorized/default/app/metadata-layout-unauthorized/layout.js new file mode 100644 index 00000000000000..8497b2b1721e82 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/app/metadata-layout-unauthorized/layout.js @@ -0,0 +1,9 @@ +import { unauthorized } from 'next/navigation' + +export async function generateMetadata() { + unauthorized() +} + +export default function layout({ children }) { + return children +} diff --git a/test/e2e/app-dir/unauthorized/default/app/metadata-layout-unauthorized/page.js b/test/e2e/app-dir/unauthorized/default/app/metadata-layout-unauthorized/page.js new file mode 100644 index 00000000000000..5796bdb2da717d --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/app/metadata-layout-unauthorized/page.js @@ -0,0 +1,3 @@ +export default function page() { + return 'not found metadata page' +} diff --git a/test/e2e/app-dir/unauthorized/default/app/navigate-unauthorized/page.js b/test/e2e/app-dir/unauthorized/default/app/navigate-unauthorized/page.js new file mode 100644 index 00000000000000..4c62a508c992c5 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/app/navigate-unauthorized/page.js @@ -0,0 +1,5 @@ +import { unauthorized } from 'next/navigation' + +export default function page() { + unauthorized() +} diff --git a/test/e2e/app-dir/unauthorized/default/app/page.js b/test/e2e/app-dir/unauthorized/default/app/page.js new file mode 100644 index 00000000000000..d09cc7fd8f424c --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/app/page.js @@ -0,0 +1,22 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+

hello world

+
+ + navigate to page calling unauthorized() + +
+
+ + navigate to layout with metadata API calling unauthorized() + +
+
+ ) +} diff --git a/test/e2e/app-dir/unauthorized/default/app/unauthorized-trigger.js b/test/e2e/app-dir/unauthorized/default/app/unauthorized-trigger.js new file mode 100644 index 00000000000000..c971457929dfb8 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/app/unauthorized-trigger.js @@ -0,0 +1,12 @@ +'use client' + +import { useSearchParams, unauthorized } from 'next/navigation' + +export default function ForbiddenTrigger() { + const searchParams = useSearchParams() + + if (searchParams.get('root-unauthorized')) { + unauthorized() + } + return null +} diff --git a/test/e2e/app-dir/unauthorized/default/next.config.js b/test/e2e/app-dir/unauthorized/default/next.config.js new file mode 100644 index 00000000000000..b59dc097c72166 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + authInterrupts: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/unauthorized/default/unauthorized-default.test.ts b/test/e2e/app-dir/unauthorized/default/unauthorized-default.test.ts new file mode 100644 index 00000000000000..dcd24dd34a7e35 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/default/unauthorized-default.test.ts @@ -0,0 +1,79 @@ +import { nextTestSetup } from 'e2e-utils' +import { + assertHasRedbox, + assertNoRedbox, + getRedboxDescription, +} from 'next-test-utils' + +describe('app dir - unauthorized with default unauthorized boundary', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + // TODO: error unauthorized usage in root layout + it.skip('should error on client unauthorized from root layout in browser', async () => { + const browser = await next.browser('/') + + await browser.elementByCss('#trigger-unauthorized').click() + + if (isNextDev) { + await assertHasRedbox(browser) + expect(await getRedboxDescription(browser)).toMatch( + /unauthorized\(\) is not allowed to use in root layout/ + ) + } + }) + + // TODO: error unauthorized usage in root layout + it.skip('should error on server unauthorized from root layout on server-side', async () => { + const browser = await next.browser('/?root-unauthorized=1') + + if (isNextDev) { + await assertHasRedbox(browser) + expect(await getRedboxDescription(browser)).toBe( + 'Error: unauthorized() is not allowed to use in root layout' + ) + } + }) + + it('should be able to navigate to page calling unauthorized', async () => { + const browser = await next.browser('/') + + await browser.elementByCss('#navigate-unauthorized').click() + await browser.waitForElementByCss('.next-error-h1') + + expect(await browser.elementByCss('h1').text()).toBe('401') + expect(await browser.elementByCss('h2').text()).toBe( + `You're not authorized to access this page.` + ) + }) + + it('should be able to navigate to page with calling unauthorized in metadata', async () => { + const browser = await next.browser('/') + + await browser.elementByCss('#metadata-layout-unauthorized').click() + await browser.waitForElementByCss('.next-error-h1') + + expect(await browser.elementByCss('h1').text()).toBe('401') + expect(await browser.elementByCss('h2').text()).toBe( + `You're not authorized to access this page.` + ) + }) + + it('should render default unauthorized for group routes if unauthorized is not defined', async () => { + const browser = await next.browser('/group-dynamic/123') + expect(await browser.elementByCss('#page').text()).toBe( + 'group-dynamic [id]' + ) + + await browser.loadPage(next.url + '/group-dynamic/401') + await assertNoRedbox(browser) + await browser.waitForElementByCss('.group-root-layout') + expect(await browser.elementByCss('.next-error-h1').text()).toBe('401') + }) +})