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

feat: Introduce unauthorized() #2

Open
wants to merge 16 commits into
base: elef/introduce-error-builder
Choose a base branch
from
Open
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
69 changes: 57 additions & 12 deletions packages/next/src/build/webpack/loaders/next-app-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,30 @@ export type AppLoaderOptions = {
}
type AppLoader = webpack.LoaderDefinitionFunction<AppLoaderOptions>

const UI_FILE_TYPES = {
'not-found': 'not-found',
forbidden: 'forbidden',
unauthorized: 'unauthorized',
} as const

const FILE_TYPES = {
layout: 'layout',
template: 'template',
error: 'error',
loading: 'loading',
'not-found': 'not-found',
...UI_FILE_TYPES,
} 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 defaultUIErrorPaths: { [k in keyof typeof UI_FILE_TYPES]: string } = {
'not-found': 'next/dist/client/components/not-found-error',
forbidden: 'next/dist/client/components/forbidden-error',
unauthorized: 'next/dist/client/components/unauthorized-error',
}

const defaultGlobalErrorPath = 'next/dist/client/components/error-boundary'
const defaultLayoutPath = 'next/dist/client/components/default-layout'

Expand Down Expand Up @@ -200,9 +211,17 @@ 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 uiErrorFileTypes = Object.keys(
UI_FILE_TYPES
) as (keyof typeof UI_FILE_TYPES)[]

const uiErrorPaths = await Promise.all(
uiErrorFileTypes.map((fileType) =>
resolver(`${appDirPrefix}/${FILE_TYPES[fileType]}`)
)
)

const pages: string[] = []

let rootLayout: string | undefined
Expand Down Expand Up @@ -360,18 +379,44 @@ async function createTreeCodeFromPath(
([, filePath]) => filePath !== undefined
)

// Add default not found error as root not found if not present
const hasNotFoundFile = definedFilePaths.some(
([type]) => type === 'not-found'
function createFileTypeCounters(
paths: typeof filePaths,
types: (keyof typeof FILE_TYPES)[]
) {
const dictionary = new Map<string, number>()
for (const [type] of paths) {
const item = dictionary.get(type)
if (item) {
dictionary.set(type, item + 1)
} else {
dictionary.set(type, 1)
}
}

return types.map((t) => (dictionary.get(t) || 0) >= 1)
}

// Check if ui error files exist for this segment path
const fileTypeCounters = createFileTypeCounters(
definedFilePaths,
uiErrorFileTypes
)

// 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])

for (let i = 0; i < uiErrorFileTypes.length; i++) {
const fileType = uiErrorFileTypes[i]
const hasFileType = fileTypeCounters[i]
const hasRootFileType = uiErrorPaths[i]

if ((isRootLayer || isFirstLayerGroupRoute) && !hasFileType) {
// If you already have a root file, don't insert default file to group routes root
if (!(hasRootFileType && isFirstLayerGroupRoute)) {
definedFilePaths.push([fileType, defaultUIErrorPaths[fileType]])
}
}
}

Expand Down Expand Up @@ -416,7 +461,7 @@ async function createTreeCodeFromPath(
if (isNotFoundRoute && normalizedParallelKey === 'children') {
const notFoundPath =
definedFilePaths.find(([type]) => type === 'not-found')?.[1] ??
defaultNotFoundPath
defaultUIErrorPaths['not-found']
nestedCollectedAsyncImports.push(notFoundPath)
subtreeCode = `{
children: [${JSON.stringify(UNDERSCORE_NOT_FOUND_ROUTE)}, {
Expand Down
8 changes: 4 additions & 4 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -663,14 +663,14 @@ function Router({

if (process.env.NODE_ENV !== 'production') {
if (typeof window !== 'undefined') {
const DevRootNotFoundBoundary: typeof import('./dev-root-not-found-boundary').DevRootNotFoundBoundary =
require('./dev-root-not-found-boundary').DevRootNotFoundBoundary
const DevRootUIErrorsBoundary: typeof import('./dev-root-not-found-boundary').DevRootUIErrorsBoundary =
require('./dev-root-not-found-boundary').DevRootUIErrorsBoundary
content = (
<DevRootNotFoundBoundary>
<DevRootUIErrorsBoundary>
<MissingSlotContext.Provider value={missingSlots}>
{content}
</MissingSlotContext.Provider>
</DevRootNotFoundBoundary>
</DevRootUIErrorsBoundary>
)
}
const HotReloader: typeof import('./react-dev-overlay/app/hot-reloader-client').default =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
'use client'

import React from 'react'
import { NotFoundBoundary } from './not-found-boundary'
import {
ForbiddenBoundary,
NotFoundBoundary,
UnauthorizedBoundary,
} from './ui-errors-boundaries'
import type { UIErrorHelper } from '../../shared/lib/ui-error-types'

export function bailOnNotFound() {
throw new Error('notFound() is not allowed to use in root layout')
export function bailOnUIError(uiError: UIErrorHelper) {
throw new Error(`${uiError}() is not allowed to use in root layout`)
}

function NotAllowedRootNotFoundError() {
bailOnNotFound()
bailOnUIError('notFound')
return null
}

export function DevRootNotFoundBoundary({
function NotAllowedRootForbiddenError() {
bailOnUIError('forbidden')
return null
}

function NotAllowedRootUnauthorizedError() {
bailOnUIError('unauthorized')
return null
}

export function DevRootUIErrorsBoundary({
children,
}: {
children: React.ReactNode
}) {
return (
<NotFoundBoundary notFound={<NotAllowedRootNotFoundError />}>
{children}
</NotFoundBoundary>
<UnauthorizedBoundary uiComponent={<NotAllowedRootUnauthorizedError />}>
<ForbiddenBoundary uiComponent={<NotAllowedRootForbiddenError />}>
<NotFoundBoundary uiComponent={<NotAllowedRootNotFoundError />}>
{children}
</NotFoundBoundary>
</ForbiddenBoundary>
</UnauthorizedBoundary>
)
}
11 changes: 11 additions & 0 deletions packages/next/src/client/components/forbidden-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { UIErrorTemplate } from './ui-error-template'

export default function Forbidden() {
return (
<UIErrorTemplate
pageTitle="403: This page is forbidden."
title="403"
subtitle="This page is forbidden."
/>
)
}
33 changes: 33 additions & 0 deletions packages/next/src/client/components/forbidden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createUIError } from './ui-error-builder'

const { thrower, matcher } = createUIError('NEXT_FORBIDDEN')

// TODO(@panteliselef): Update docs
/**
* This function allows you to render the [forbidden.js file]
* 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).
*
* - In a Server Component, this will insert a `<meta name="robots" content="noindex" />` meta tag and set the status code to 403.
* - In a Route Handler or Server Action, it will serve a 403 to the caller.
*
* // TODO(@panteliselef): Update docs
* Read more: [Next.js Docs: `forbidden`](https://nextjs.org/docs/app/api-reference/functions/not-found)
*/
const forbidden = thrower

// TODO(@panteliselef): Update docs
/**
* Checks an error to determine if it's an error generated by the `forbidden()`
* helper.
*
* @param error the error that may reference a forbidden error
* @returns true if the error is a forbidden error
*/
const isForbiddenError = matcher

export { forbidden, isForbiddenError }
9 changes: 8 additions & 1 deletion packages/next/src/client/components/is-next-router-error.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { isNotFoundError } from './not-found'
import { isRedirectError } from './redirect'
import { isForbiddenError } from './forbidden'
import { isUnauthorizedError } from './unauthorized'

export function isNextRouterError(error: any): boolean {
return (
error && error.digest && (isRedirectError(error) || isNotFoundError(error))
error &&
error.digest &&
(isRedirectError(error) ||
isNotFoundError(error) ||
isForbiddenError(error) ||
isUnauthorizedError(error))
)
}
59 changes: 41 additions & 18 deletions packages/next/src/client/components/layout-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ import { ErrorBoundary } from './error-boundary'
import { matchSegment } from './match-segments'
import { handleSmoothScroll } from '../../shared/lib/router/utils/handle-smooth-scroll'
import { RedirectBoundary } from './redirect-boundary'
import { NotFoundBoundary } from './not-found-boundary'
import { getSegmentValue } from './router-reducer/reducers/get-segment-value'
import { createRouterCacheKey } from './router-reducer/create-router-cache-key'
import { hasInterceptionRouteInCurrentTree } from './router-reducer/reducers/has-interception-route-in-current-tree'
import {
ForbiddenBoundary,
NotFoundBoundary,
UnauthorizedBoundary,
} from './ui-errors-boundaries'

/**
* Add refetch marker to router state at the point of the current layout segment.
Expand Down Expand Up @@ -525,6 +529,10 @@ export default function OuterLayoutRouter({
template,
notFound,
notFoundStyles,
forbidden,
forbiddenStyles,
unauthorized,
unauthorizedStyles,
styles,
}: {
parallelRouterKey: string
Expand All @@ -537,6 +545,10 @@ export default function OuterLayoutRouter({
template: React.ReactNode
notFound: React.ReactNode | undefined
notFoundStyles: React.ReactNode | undefined
forbidden: React.ReactNode | undefined
forbiddenStyles: React.ReactNode | undefined
unauthorized: React.ReactNode | undefined
unauthorizedStyles: React.ReactNode | undefined
styles?: React.ReactNode
}) {
const context = useContext(LayoutRouterContext)
Expand Down Expand Up @@ -600,24 +612,35 @@ export default function OuterLayoutRouter({
loadingStyles={loading?.[1]}
loadingScripts={loading?.[2]}
>
<NotFoundBoundary
notFound={notFound}
notFoundStyles={notFoundStyles}
<UnauthorizedBoundary
uiComponent={unauthorized}
uiComponentStyles={unauthorizedStyles}
>
<RedirectBoundary>
<InnerLayoutRouter
parallelRouterKey={parallelRouterKey}
url={url}
tree={tree}
childNodes={childNodesForParallelRouter!}
segmentPath={segmentPath}
cacheKey={cacheKey}
isActive={
currentChildSegmentValue === preservedSegmentValue
}
/>
</RedirectBoundary>
</NotFoundBoundary>
<ForbiddenBoundary
uiComponent={forbidden}
uiComponentStyles={forbiddenStyles}
>
<NotFoundBoundary
uiComponent={notFound}
uiComponentStyles={notFoundStyles}
>
<RedirectBoundary>
<InnerLayoutRouter
parallelRouterKey={parallelRouterKey}
url={url}
tree={tree}
childNodes={childNodesForParallelRouter!}
segmentPath={segmentPath}
cacheKey={cacheKey}
isActive={
currentChildSegmentValue ===
preservedSegmentValue
}
/>
</RedirectBoundary>
</NotFoundBoundary>
</ForbiddenBoundary>
</UnauthorizedBoundary>
</LoadingBoundary>
</ErrorBoundary>
</ScrollAndFocusHandler>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ class ReadonlyURLSearchParams extends URLSearchParams {

export { redirect, permanentRedirect, RedirectType } from './redirect'
export { notFound } from './not-found'
export { forbidden } from './forbidden'
export { unauthorized } from './unauthorized'
export { ReadonlyURLSearchParams }
2 changes: 2 additions & 0 deletions packages/next/src/client/components/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ export {
// Shared components APIs
export {
notFound,
forbidden,
unauthorized,
redirect,
permanentRedirect,
RedirectType,
Expand Down
Loading
Loading