diff --git a/.changesets/10572.md b/.changesets/10572.md new file mode 100644 index 000000000000..6fbc3b7f2b84 --- /dev/null +++ b/.changesets/10572.md @@ -0,0 +1,15 @@ +- feat: feat: Reworks RSC server entries and route manifest building to derive from routes and include if route info related to authentication (#10572) by @dthyresson + +This PR is in furtherance of authentication support in RSC. + +It refactors: + +- How server entries are built -- not from "processing the pages dir" (which is a deprecated function) but rather the routes ... and the page info for that route. Note here that a page can be used in multiple routes, so the auth info cannot really be determined here. + +- The route manifest building to include per route: + +* isPrivate - is the route private, i.e, is it wrapped in a PrivateSet +* unauthenticated - what route to navigate to if the user in not authenticated +* roles - the roles to check to see if user has the require RBAC permission to navigate to the route + +Now if some page, route request is being handled by RSC we might be able to check if it "isPrivate" and enforce auth with the roles and even where tp redirect to if not authenticated. diff --git a/__fixtures__/example-todo-main/web/src/Routes.js b/__fixtures__/example-todo-main/web/src/Routes.js index 760b6fe958b4..ca898eabcf67 100644 --- a/__fixtures__/example-todo-main/web/src/Routes.js +++ b/__fixtures__/example-todo-main/web/src/Routes.js @@ -7,7 +7,7 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Private, Router, Route } from '@redwoodjs/router' +import { PrivateSet, Router, Route } from '@redwoodjs/router' import SetLayout from 'src/layouts/SetLayout' import FooPage from 'src/pages/FooPage' @@ -22,9 +22,15 @@ const Routes = () => { - + - + + + + + + + ) diff --git a/__fixtures__/fragment-test-project/web/src/Routes.tsx b/__fixtures__/fragment-test-project/web/src/Routes.tsx index a8b80e306513..3982fc7790f5 100644 --- a/__fixtures__/fragment-test-project/web/src/Routes.tsx +++ b/__fixtures__/fragment-test-project/web/src/Routes.tsx @@ -7,7 +7,7 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Router, Route, Private, Set } from '@redwoodjs/router' +import { Router, Route, PrivateSet, Set } from '@redwoodjs/router' import BlogLayout from 'src/layouts/BlogLayout' import ScaffoldLayout from 'src/layouts/ScaffoldLayout' @@ -38,9 +38,9 @@ const Routes = () => { - + - + diff --git a/packages/internal/src/__tests__/__snapshots__/typeDefinitions.test.ts.snap b/packages/internal/src/__tests__/__snapshots__/typeDefinitions.test.ts.snap index 7dfc8178c065..5e448ff75f3c 100644 --- a/packages/internal/src/__tests__/__snapshots__/typeDefinitions.test.ts.snap +++ b/packages/internal/src/__tests__/__snapshots__/typeDefinitions.test.ts.snap @@ -22,7 +22,7 @@ exports[`generates global page imports source maps 1`] = ` exports[`generates source maps for the router routes 1`] = ` { "file": "web-routerRoutes.d.ts", - "mappings": ";;;;;;IAiBM;IACA;IACA;IAEE;IACA;IAGA", + "mappings": ";;;;;;IAiBM;IACA;IACA;IAEE;IACA;IAGA;IAGA;IAGA", "names": [], "sources": [ "../../../web/src/Routes.js", diff --git a/packages/internal/src/routes.ts b/packages/internal/src/routes.ts index 48505f8b9230..5225e7324ec1 100644 --- a/packages/internal/src/routes.ts +++ b/packages/internal/src/routes.ts @@ -73,6 +73,9 @@ export interface RWRouteManifestItem { hasParams: boolean relativeFilePath: string redirect: { to: string; permanent: boolean } | null + isPrivate: boolean + unauthenticated: string | null + roles: string | string[] | null // Probably want isNotFound here, so we can attach a separate 404 handler } @@ -80,6 +83,8 @@ export interface RouteSpec extends RWRouteManifestItem { id: string isNotFound: boolean filePath: string | undefined + isPrivate: boolean + unauthenticated: string | null relativeFilePath: string } @@ -111,6 +116,9 @@ export const getProjectRoutes = (): RouteSpec[] => { redirect: route.redirect ? { to: route.redirect, permanent: false } : null, + isPrivate: route.isPrivate, + unauthenticated: route.unauthenticated, + roles: route.roles, } }) } diff --git a/packages/project-config/src/paths.ts b/packages/project-config/src/paths.ts index 0bcdcda157a9..49287370e17e 100644 --- a/packages/project-config/src/paths.ts +++ b/packages/project-config/src/paths.ts @@ -80,7 +80,7 @@ export interface PagesDependency { /** the variable to which the import is assigned */ importName: string /** @alias importName */ - const: string + constName: string /** absolute path without extension */ importPath: string /** absolute path with extension */ @@ -346,7 +346,7 @@ export const processPagesDir = ( const importStatement = `const ${importName} = { name: '${importName}', loader: import('${importPath}') }` return { importName, - const: importName, + constName: importName, importPath, path: path.join(webPagesDir, pagePath), importStatement, diff --git a/packages/structure/src/model/RWPage.ts b/packages/structure/src/model/RWPage.ts index d82486408f6f..1edd2e0a9304 100644 --- a/packages/structure/src/model/RWPage.ts +++ b/packages/structure/src/model/RWPage.ts @@ -9,7 +9,7 @@ import type { RWProject } from './RWProject' export class RWPage extends FileNode { constructor( - public const_: string, + public constName: string, public path: string, public parent: RWProject, ) { @@ -20,7 +20,7 @@ export class RWPage extends FileNode { } @lazy() get route() { return this.parent.router.routes.find( - (r) => r.page_identifier_str === this.const_, + (r) => r.page_identifier_str === this.constName, ) } @lazy() get layoutName(): string | undefined { diff --git a/packages/structure/src/model/RWProject.ts b/packages/structure/src/model/RWProject.ts index f82b169e1be1..fae2229b25e6 100644 --- a/packages/structure/src/model/RWProject.ts +++ b/packages/structure/src/model/RWProject.ts @@ -112,7 +112,9 @@ export class RWProject extends BaseNode { } } @lazy() get pages(): RWPage[] { - return this.processPagesDir.map((p) => new RWPage(p.const, p.path, this)) + return this.processPagesDir.map( + (p) => new RWPage(p.constName, p.path, this), + ) } @lazy() get router() { return this.getRouter() diff --git a/packages/structure/src/model/RWRoute.ts b/packages/structure/src/model/RWRoute.ts index 7a7da004561a..bb701f602373 100644 --- a/packages/structure/src/model/RWRoute.ts +++ b/packages/structure/src/model/RWRoute.ts @@ -49,7 +49,90 @@ export class RWRoute extends BaseNode { ?.getOpeningElement() ?.getTagNameNode() ?.getText() - return tagText === 'Private' + return tagText === 'Private' || tagText === 'PrivateSet' + } + + @lazy() get unauthenticated() { + if (!this.isPrivate) { + return undefined + } + + const a = this.jsxNode + .getParentIfKind(tsm.SyntaxKind.JsxElement) + ?.getOpeningElement() + .getAttribute('unauthenticated') + + if (!a) { + return undefined + } + if (tsm.Node.isJsxAttribute(a)) { + const init = a.getInitializer() + if (tsm.Node.isStringLiteral(init!)) { + return init.getLiteralValue() + } + } + return undefined + } + + @lazy() + get roles() { + if (!this.isPrivate) { + return undefined + } + + const a = this.jsxNode + .getParentIfKind(tsm.SyntaxKind.JsxElement) + ?.getOpeningElement() + .getAttribute('roles') + + if (!a) { + return undefined + } + + if (tsm.Node.isJsxAttribute(a)) { + const init = a.getInitializer() + + // Handle string literals + if (tsm.Node.isStringLiteral(init)) { + let literalValue = init.getLiteralValue() + + // Check if the string looks like an array with single quotes + if (literalValue.startsWith('[') && literalValue.endsWith(']')) { + try { + // Unescape the string by replacing single quotes with double quotes + const correctedLiteralValue = literalValue.replace(/'/g, '"') + // Attempt to parse as JSON array + const parsedValue = JSON.parse(correctedLiteralValue) + if (Array.isArray(parsedValue)) { + return parsedValue + } + } catch (e) { + // If parsing fails, return undefined + return undefined + } + } + + // If not an array, return the string value + return literalValue + } + + // Handle JSX expressions with array literals + if (tsm.Node.isJsxExpression(init)) { + const expr = init.getExpression() + if (tsm.Node.isArrayLiteralExpression(expr)) { + return expr + .getElements() + .map((element) => { + if (tsm.Node.isStringLiteral(element)) { + return element.getLiteralValue() + } + return undefined + }) + .filter((val) => val !== undefined) + } + } + } + return undefined } @lazy() get hasParameters(): boolean { @@ -98,7 +181,7 @@ export class RWRoute extends BaseNode { return undefined } return this.parent.parent.pages.find( - (p) => p.const_ === this.page_identifier_str, + (p) => p.constName === this.page_identifier_str, ) } /** diff --git a/packages/structure/src/model/RWRouter.ts b/packages/structure/src/model/RWRouter.ts index 96a29a7d615c..1a757338f1dd 100644 --- a/packages/structure/src/model/RWRouter.ts +++ b/packages/structure/src/model/RWRouter.ts @@ -75,7 +75,11 @@ export class RWRouter extends FileNode { .getDescendantsOfKind(tsm.SyntaxKind.JsxElement) .filter((x) => { const tagName = x.getOpeningElement().getTagNameNode().getText() - return tagName === 'Set' || tagName === 'Private' + return ( + tagName === 'Set' || + tagName === 'Private' || + tagName === 'PrivateSet' + ) }) const prerenderSets = sets.filter((set) => diff --git a/packages/structure/src/model/__tests__/model.test.ts b/packages/structure/src/model/__tests__/model.test.ts index cf9ba1cfb0d6..9cc7a6be83ac 100644 --- a/packages/structure/src/model/__tests__/model.test.ts +++ b/packages/structure/src/model/__tests__/model.test.ts @@ -111,6 +111,28 @@ describe.skip('env vars', () => { }) }) +describe('Redwood Page detection', () => { + it('detects pages', async () => { + const projectRoot = getFixtureDir('example-todo-main') + const project = new RWProject({ projectRoot, host: new DefaultHost() }) + const routes = project.getRouter().routes + const pages = routes.map((r) => r.page).sort() + const pageConstants = pages.map((p) => p?.constName) + // Note: Pages can be duplicated if used by multiple routes, we use a Set + expect(pageConstants).toEqual([ + 'HomePage', + 'TypeScriptPage', + 'FooPage', + 'BarPage', + 'PrivatePage', + 'PrivatePage', + 'PrivatePage', + 'NotFoundPage', + undefined, + ]) + }) +}) + describe('Redwood Route detection', () => { it('detects routes with the prerender prop', async () => { const projectRoot = getFixtureDir('example-todo-main') @@ -123,7 +145,7 @@ describe('Redwood Route detection', () => { // interested in .map(({ name, path }) => ({ name, path })) - expect(prerenderRoutes.length).toBe(6) + expect(prerenderRoutes.length).toBe(8) expect(prerenderRoutes).toContainEqual({ name: 'home', path: '/' }) expect(prerenderRoutes).toContainEqual({ name: 'typescriptPage', @@ -140,6 +162,87 @@ describe('Redwood Route detection', () => { path: '/private-page', }) }) + it('detects authenticated routes', async () => { + const projectRoot = getFixtureDir('example-todo-main') + const project = new RWProject({ projectRoot, host: new DefaultHost() }) + const routes = project.getRouter().routes + + const authenticatedRoutes = routes + .filter((r) => r.isPrivate) + .map(({ name, path, unauthenticated, roles }) => ({ + name, + path, + unauthenticated, + roles, + })) + + expect(authenticatedRoutes.length).toBe(3) + }) + + it('detects name and path for an authenticated route', async () => { + const projectRoot = getFixtureDir('example-todo-main') + const project = new RWProject({ projectRoot, host: new DefaultHost() }) + const routes = project.getRouter().routes + + const authenticatedRoutes = routes + .filter((r) => r.isPrivate) + .map(({ name, path, unauthenticated, roles }) => ({ + name, + path, + unauthenticated, + roles, + })) + + expect(authenticatedRoutes[1].name).toBe('privatePageAdmin') + expect(authenticatedRoutes[1].path).toBe('/private-page-admin') + expect(authenticatedRoutes[1].unauthenticated).toBe('home') + expect(authenticatedRoutes[1].roles).toBeTypeOf('string') + expect(authenticatedRoutes[1].roles).toContain('admin') + }) + + it('detects roles for an authenticated route when roles is a string of a single role', async () => { + const projectRoot = getFixtureDir('example-todo-main') + const project = new RWProject({ projectRoot, host: new DefaultHost() }) + const routes = project.getRouter().routes + + const authenticatedRoutes = routes + .filter((r) => r.isPrivate) + .map(({ name, path, unauthenticated, roles }) => ({ + name, + path, + unauthenticated, + roles, + })) + + expect(authenticatedRoutes[1].name).toBe('privatePageAdmin') + expect(authenticatedRoutes[1].path).toBe('/private-page-admin') + expect(authenticatedRoutes[1].unauthenticated).toBe('home') + expect(authenticatedRoutes[1].roles).toBeTypeOf('string') + expect(authenticatedRoutes[1].roles).toContain('admin') + }) + + it('detects roles for an authenticated route when roles is an array of a roles', async () => { + const projectRoot = getFixtureDir('example-todo-main') + const project = new RWProject({ projectRoot, host: new DefaultHost() }) + const routes = project.getRouter().routes + + const authenticatedRoutes = routes + .filter((r) => r.isPrivate) + .map(({ name, path, unauthenticated, roles }) => ({ + name, + path, + unauthenticated, + roles, + })) + + expect(authenticatedRoutes[2].name).toBe('privatePageAdminSuper') + expect(authenticatedRoutes[2].path).toBe('/private-page-admin-super') + expect(authenticatedRoutes[2].unauthenticated).toBe('home') + expect(authenticatedRoutes[2].roles).toBeInstanceOf(Array) + expect(authenticatedRoutes[2].roles).toContain('owner') + expect(authenticatedRoutes[2].roles).toContain('superuser') + expect(authenticatedRoutes[2].roles).not.toContain('member') + }) }) function getFixtureDir( diff --git a/packages/vite/src/buildRouteManifest.ts b/packages/vite/src/buildRouteManifest.ts index 2c518595d444..f0d1efc4cdd0 100644 --- a/packages/vite/src/buildRouteManifest.ts +++ b/packages/vite/src/buildRouteManifest.ts @@ -47,6 +47,9 @@ export async function buildRouteManifest() { } : null, relativeFilePath: route.relativeFilePath, + isPrivate: route.isPrivate, + unauthenticated: route.unauthenticated, + roles: route.roles, } return acc diff --git a/packages/vite/src/lib/entries.ts b/packages/vite/src/lib/entries.ts index cfffa1e41dce..b1f1711414da 100644 --- a/packages/vite/src/lib/entries.ts +++ b/packages/vite/src/lib/entries.ts @@ -1,37 +1,28 @@ -import path from 'node:path' - -import type { PagesDependency } from '@redwoodjs/project-config' import { ensurePosixPath, getPaths, - processPagesDir, + importStatementPath, } from '@redwoodjs/project-config' +import { getProject } from '@redwoodjs/structure/dist/index' +import type { RWPage } from '@redwoodjs/structure/dist/model/RWPage' +import type { RWRoute } from '@redwoodjs/structure/dist/model/RWRoute' import { makeFilePath } from '../utils' -const getPathRelativeToSrc = (maybeAbsolutePath: string) => { - // If the path is already relative - if (!path.isAbsolute(maybeAbsolutePath)) { - return maybeAbsolutePath - } - - return `./${path.relative(getPaths().web.src, maybeAbsolutePath)}` -} - -const withRelativeImports = (page: PagesDependency) => { - return { - ...page, - relativeImport: ensurePosixPath(getPathRelativeToSrc(page.importPath)), - } -} - export function getEntries() { const entries: Record = {} + // Build the entries object based on routes and pages + // Given the page's route, we can determine whether or not + // the entry requires authentication checks + const rwProject = getProject(getPaths().base) + const routes = rwProject.getRouter().routes + // Add the various pages - const pages = processPagesDir().map(withRelativeImports) + const pages = routes.map((route: RWRoute) => route.page) as RWPage[] + for (const page of pages) { - entries[page.importName] = page.path + entries[page.constName] = ensurePosixPath(importStatementPath(page.path)) } // Add the ServerEntry entry, noting we use the "__rwjs__" prefix to avoid diff --git a/packages/vite/src/plugins/__tests__/vite-plugin-rsc-route-auto-loader.test.mts b/packages/vite/src/plugins/__tests__/vite-plugin-rsc-route-auto-loader.test.mts index 71ce27b0aefb..1003a85d3da7 100644 --- a/packages/vite/src/plugins/__tests__/vite-plugin-rsc-route-auto-loader.test.mts +++ b/packages/vite/src/plugins/__tests__/vite-plugin-rsc-route-auto-loader.test.mts @@ -385,56 +385,56 @@ describe('rscRoutesAutoLoader', () => { const pages = [ { importName: 'AboutPage', - const: 'AboutPage', + constName: 'AboutPage', importPath: '/Users/mojombo/rw-app/web/src/pages/AboutPage/AboutPage', path: '/Users/mojombo/rw-app/web/src/pages/AboutPage/AboutPage.tsx', importStatement: "const AboutPage = { name: 'AboutPage', loader: import('/Users/mojombo/rw-app/web/src/pages/AboutPage/AboutPage') }" }, { importName: 'FatalErrorPage', - const: 'FatalErrorPage', + constName: 'FatalErrorPage', importPath: '/Users/mojombo/rw-app/web/src/pages/FatalErrorPage/FatalErrorPage', path: '/Users/mojombo/rw-app/web/src/pages/FatalErrorPage/FatalErrorPage.tsx', importStatement: "const FatalErrorPage = { name: 'FatalErrorPage', loader: import('/Users/mojombo/rw-app/web/src/pages/FatalErrorPage/FatalErrorPage') }" }, { importName: 'HomePage', - const: 'HomePage', + constName: 'HomePage', importPath: '/Users/mojombo/rw-app/web/src/pages/HomePage/HomePage', path: '/Users/mojombo/rw-app/web/src/pages/HomePage/HomePage.tsx', importStatement: "const HomePage = { name: 'HomePage', loader: import('/Users/mojombo/rw-app/web/src/pages/HomePage/HomePage') }" }, { importName: 'NotFoundPage', - const: 'NotFoundPage', + constName: 'NotFoundPage', importPath: '/Users/mojombo/rw-app/web/src/pages/NotFoundPage/NotFoundPage', path: '/Users/mojombo/rw-app/web/src/pages/NotFoundPage/NotFoundPage.tsx', importStatement: "const NotFoundPage = { name: 'NotFoundPage', loader: import('/Users/mojombo/rw-app/web/src/pages/NotFoundPage/NotFoundPage') }" }, { importName: 'EmptyUserEditEmptyUserPage', - const: 'EmptyUserEditEmptyUserPage', + constName: 'EmptyUserEditEmptyUserPage', importPath: '/Users/mojombo/rw-app/web/src/pages/EmptyUser/EditEmptyUserPage/EditEmptyUserPage', path: '/Users/mojombo/rw-app/web/src/pages/EmptyUser/EditEmptyUserPage/EditEmptyUserPage.tsx', importStatement: "const EmptyUserEditEmptyUserPage = { name: 'EmptyUserEditEmptyUserPage', loader: import('/Users/mojombo/rw-app/web/src/pages/EmptyUser/EditEmptyUserPage/EditEmptyUserPage') }" }, { importName: 'EmptyUserEmptyUserPage', - const: 'EmptyUserEmptyUserPage', + constName: 'EmptyUserEmptyUserPage', importPath: '/Users/mojombo/rw-app/web/src/pages/EmptyUser/EmptyUserPage/EmptyUserPage', path: '/Users/mojombo/rw-app/web/src/pages/EmptyUser/EmptyUserPage/EmptyUserPage.tsx', importStatement: "const EmptyUserEmptyUserPage = { name: 'EmptyUserEmptyUserPage', loader: import('/Users/mojombo/rw-app/web/src/pages/EmptyUser/EmptyUserPage/EmptyUserPage') }" }, { importName: 'EmptyUserEmptyUsersPage', - const: 'EmptyUserEmptyUsersPage', + constName: 'EmptyUserEmptyUsersPage', importPath: '/Users/mojombo/rw-app/web/src/pages/EmptyUser/EmptyUsersPage/EmptyUsersPage', path: '/Users/mojombo/rw-app/web/src/pages/EmptyUser/EmptyUsersPage/EmptyUsersPage.tsx', importStatement: "const EmptyUserEmptyUsersPage = { name: 'EmptyUserEmptyUsersPage', loader: import('/Users/mojombo/rw-app/web/src/pages/EmptyUser/EmptyUsersPage/EmptyUsersPage') }" }, { importName: 'EmptyUserNewEmptyUserPage', - const: 'EmptyUserNewEmptyUserPage', + constName: 'EmptyUserNewEmptyUserPage', importPath: '/Users/mojombo/rw-app/web/src/pages/EmptyUser/NewEmptyUserPage/NewEmptyUserPage', path: '/Users/mojombo/rw-app/web/src/pages/EmptyUser/NewEmptyUserPage/NewEmptyUserPage.tsx', importStatement: "const EmptyUserNewEmptyUserPage = { name: 'EmptyUserNewEmptyUserPage', loader: import('/Users/mojombo/rw-app/web/src/pages/EmptyUser/NewEmptyUserPage/NewEmptyUserPage') }" diff --git a/packages/vite/src/plugins/vite-plugin-rsc-routes-auto-loader.ts b/packages/vite/src/plugins/vite-plugin-rsc-routes-auto-loader.ts index 1aca4ee7a5ca..4ba4fb5711dc 100644 --- a/packages/vite/src/plugins/vite-plugin-rsc-routes-auto-loader.ts +++ b/packages/vite/src/plugins/vite-plugin-rsc-routes-auto-loader.ts @@ -127,9 +127,9 @@ export function rscRoutesAutoLoader(): Plugin { ast.program.body.unshift( t.variableDeclaration('const', [ t.variableDeclarator( - t.identifier(page.const), + t.identifier(page.constName), t.callExpression(t.identifier(loadFunctionName), [ - t.stringLiteral(page.const), + t.stringLiteral(page.constName), ]), ), ]), diff --git a/packages/vite/src/rsc/rscRequestHandler.ts b/packages/vite/src/rsc/rscRequestHandler.ts index ae032579c02e..f1a4c2718e1d 100644 --- a/packages/vite/src/rsc/rscRequestHandler.ts +++ b/packages/vite/src/rsc/rscRequestHandler.ts @@ -92,8 +92,6 @@ export function createRscRequestHandler( let rsfId: string | undefined let args: unknown[] = [] - console.log('url.pathname', url.pathname) - if (url.pathname.startsWith(basePath)) { rscId = url.pathname.split('/').pop() rsfId = url.searchParams.get('action_id') || undefined