diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 3a4bb2cae9f..f62920437cd 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -31,7 +31,7 @@ jobs: - run: pnpm i - name: Run unit tests ${{ matrix.name }} - run: pnpm --filter ${{ matrix.name }} test + run: pnpm --filter seven exec init-loaders && pnpm --filter ${{ matrix.name }} test client: name: '@plone/client' diff --git a/apps/seven/app/config/server.server.test.ts b/apps/seven/app/config/server.server.test.ts index 47166eebd72..21ed199a2ef 100644 --- a/apps/seven/app/config/server.server.test.ts +++ b/apps/seven/app/config/server.server.test.ts @@ -27,7 +27,7 @@ describe('config/server', () => { name: 'ploneClient', type: 'client', }) - .method().config.apiPath, - ).toEqual('http://localhost:8080/Plone'); + .method(), + ).toHaveProperty('initialize'); }); }); diff --git a/apps/seven/app/config/server.server.ts b/apps/seven/app/config/server.server.ts index 7c0dbaeb071..b2b836631bf 100644 --- a/apps/seven/app/config/server.server.ts +++ b/apps/seven/app/config/server.server.ts @@ -16,14 +16,10 @@ export default function install() { config.settings.apiPath = process.env.PLONE_API_PATH || 'http://localhost:8080/Plone'; - const cli = PloneClient.initialize({ - apiPath: config.settings.apiPath, - }); - config.registerUtility({ name: 'ploneClient', type: 'client', - method: () => cli, + method: () => PloneClient, }); config.registerUtility({ diff --git a/apps/seven/app/config/types.ts b/apps/seven/app/config/types.ts index e30078c8cfe..d08a46f945a 100644 --- a/apps/seven/app/config/types.ts +++ b/apps/seven/app/config/types.ts @@ -3,8 +3,11 @@ import type PloneClient from '@plone/client'; import type { Value } from '@plone/plate/components/editor'; import type { Params } from 'react-router'; +export type PloneClientUtility = typeof PloneClient; + declare module '@plone/types' { interface UtilityTypeMap { + client: () => PloneClientUtility; rootContentSubRequest: (args: LoaderUtilityArgs) => Promise; rootLoaderData: ( args: LoaderUtilityArgs, diff --git a/apps/seven/app/middleware.server.test.ts b/apps/seven/app/middleware.server.test.ts index 5937de6d23c..9a7d6306922 100644 --- a/apps/seven/app/middleware.server.test.ts +++ b/apps/seven/app/middleware.server.test.ts @@ -1,18 +1,52 @@ import { expect, describe, it, vi, afterEach } from 'vitest'; import config from '@plone/registry'; import { RouterContextProvider } from 'react-router'; +import { jwtDecode } from 'jwt-decode'; +import { getAuthFromRequest } from '@plone/react-router'; import { fetchPloneContent, getAPIResourceWithAuth, installServerMiddleware, + PloneClientMiddleware, otherResources, ploneClientContext, ploneContentContext, ploneSiteContext, + ploneUserContext, } from './middleware.server'; +vi.mock('jwt-decode'); +vi.mock('@plone/react-router', () => ({ getAuthFromRequest: vi.fn() })); + describe('middleware', () => { + const initializePloneClientContext = async ( + request: Request, + context: RouterContextProvider, + ) => { + await PloneClientMiddleware( + { + request, + context, + params: {}, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + vi.fn(), + ); + }; + + const registerPloneClientFactory = (ploneClient: Record) => { + config.registerUtility({ + name: 'ploneClient', + type: 'client', + method: () => ({ + initialize: vi.fn().mockReturnValue(ploneClient), + }), + }); + }; + afterEach(() => { + vi.resetAllMocks(); vi.restoreAllMocks(); }); @@ -23,7 +57,13 @@ describe('middleware', () => { const nextMock = vi.fn(); await installServerMiddleware( - { request, params: {}, context, unstable_pattern: '/' }, + { + request, + context, + params: {}, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, nextMock, ); @@ -40,8 +80,74 @@ describe('middleware', () => { name: 'ploneClient', type: 'client', }) - .method().config.apiPath, - ).toEqual('http://localhost:8080/Plone'); + .method(), + ).toHaveProperty('initialize'); + }); + }); + + describe('PloneClientMiddleware', () => { + it('initializes the PloneClient and sets it in context', async () => { + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + vi.mocked(getAuthFromRequest).mockResolvedValue(undefined); + config.settings.apiPath = 'http://localhost:8080/Plone'; + config.registerUtility({ + name: 'ploneClient', + type: 'client', + method: () => ({ + initialize: vi.fn().mockReturnValue({ + config: { apiPath: 'http://localhost:8080/Plone' }, + }), + }), + }); + + await PloneClientMiddleware( + { + request, + context, + params: {}, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(context.get(ploneClientContext)).toBeDefined(); + }); + + it('initializes PloneClient with token when available', async () => { + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + vi.mocked(getAuthFromRequest).mockResolvedValue('valid.jwt.token'); + config.settings.apiPath = 'http://localhost:8080/Plone'; + const initializeMock = vi.fn().mockReturnValue({}); + config.registerUtility({ + name: 'ploneClient', + type: 'client', + method: () => ({ + initialize: initializeMock, + }), + }); + + await PloneClientMiddleware( + { + request, + context, + params: {}, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(initializeMock).toHaveBeenCalledWith({ + apiPath: 'http://localhost:8080/Plone', + token: 'valid.jwt.token', + }); }); }); @@ -53,7 +159,13 @@ describe('middleware', () => { const nextMock = vi.fn(); await otherResources( - { request, params, context, unstable_pattern: '/' }, + { + request, + params, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, nextMock, ); }); @@ -66,7 +178,13 @@ describe('middleware', () => { try { await otherResources( - { request, params, context, unstable_pattern: '/style.css' }, + { + request, + params, + context, + unstable_pattern: '/style.css', + unstable_url: new URL(request.url), + }, nextMock, ); } catch (err: any) { @@ -82,7 +200,13 @@ describe('middleware', () => { try { await otherResources( - { request, params, context, unstable_pattern: '/style.css.map' }, + { + request, + params, + context, + unstable_pattern: '/style.css.map', + unstable_url: new URL(request.url), + }, nextMock, ); } catch (err: any) { @@ -137,6 +261,7 @@ describe('middleware', () => { params, context, unstable_pattern: '/?expand=breadcrumbs', + unstable_url: new URL(request.url), }, nextMock, ); @@ -153,13 +278,45 @@ describe('middleware', () => { try { await otherResources( - { request, params, context, unstable_pattern: '/assets/image.png' }, + { + request, + params, + context, + unstable_pattern: '/assets/image.png', + unstable_url: new URL(request.url), + }, nextMock, ); } catch (err: any) { expect(err.init.status).toEqual(404); } }); + + it('blocks requests to .well-known paths', async () => { + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const params = { + '*': '.well-known/appspecific/com.chrome.devtools.json', + }; + const nextMock = vi.fn(); + + try { + await otherResources( + { + request, + params, + context, + unstable_pattern: + '/.well-known/appspecific/com.chrome.devtools.json', + unstable_url: new URL(request.url), + }, + nextMock, + ); + } catch (err: any) { + expect(err).toBeInstanceOf(Response); + expect(err.status).toEqual(200); + } + }); }); describe('getAPIResourceWithAuth', () => { @@ -170,7 +327,13 @@ describe('middleware', () => { const nextMock = vi.fn(); await getAPIResourceWithAuth( - { request, params, context, unstable_pattern: '/' }, + { + request, + params, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, nextMock, ); }); @@ -193,6 +356,7 @@ describe('middleware', () => { params, context, unstable_pattern: '/image.png/@@images/image', + unstable_url: new URL(request.url), }, nextMock, ); @@ -227,6 +391,7 @@ describe('middleware', () => { params, context, unstable_pattern: '/file.txt/@@download/file', + unstable_url: new URL(request.url), }, nextMock, ); @@ -256,7 +421,13 @@ describe('middleware', () => { try { await getAPIResourceWithAuth( - { request, params, context, unstable_pattern: '/@@site-logo/image' }, + { + request, + params, + context, + unstable_pattern: '/@@site-logo/image', + unstable_url: new URL(request.url), + }, nextMock, ); } catch { @@ -285,7 +456,13 @@ describe('middleware', () => { try { await getAPIResourceWithAuth( - { request, params, context, unstable_pattern: '/@portrait/username' }, + { + request, + params, + context, + unstable_pattern: '/@portrait/username', + unstable_url: new URL(request.url), + }, nextMock, ); } catch { @@ -304,7 +481,7 @@ describe('middleware', () => { describe('fetchPloneContent', () => { afterEach(() => { - delete config.utilities['client']; + delete config.utilities['ploneClient']; }); it('fetches content and site and sets them in context', async () => { @@ -315,21 +492,24 @@ describe('middleware', () => { const getContentMock = vi.fn().mockResolvedValue(mockContent); const getSiteMock = vi.fn().mockResolvedValue(mockSite); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { token: undefined }, - getContent: getContentMock, - getSite: getSiteMock, - }), + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, }); const request = new Request('http://example.com'); const context = new RouterContextProvider(); const nextMock = vi.fn(); + await initializePloneClientContext(request, context); + await fetchPloneContent( - { request, params: {}, context, unstable_pattern: '/' }, + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, nextMock, ); @@ -350,25 +530,23 @@ describe('middleware', () => { const getContentMock = vi.fn().mockResolvedValue({ data: {} }); const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { token: undefined }, - getContent: getContentMock, - getSite: getSiteMock, - }), + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, }); const request = new Request('http://example.com/test-content'); const context = new RouterContextProvider(); const nextMock = vi.fn(); + await initializePloneClientContext(request, context); + await fetchPloneContent( { request, params: { '*': 'test-content' }, context, unstable_pattern: '/test-content', + unstable_url: new URL(request.url), }, nextMock, ); @@ -385,22 +563,25 @@ describe('middleware', () => { .mockRejectedValue({ data: undefined, status: 500 }); const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { token: undefined }, - getContent: getContentMock, - getSite: getSiteMock, - }), + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, }); const request = new Request('http://example.com'); const context = new RouterContextProvider(); const nextMock = vi.fn(); + await initializePloneClientContext(request, context); + try { await fetchPloneContent( - { request, params: {}, context, unstable_pattern: '/' }, + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, nextMock, ); } catch (err: any) { @@ -414,27 +595,175 @@ describe('middleware', () => { .fn() .mockRejectedValue({ data: undefined, status: 500 }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { token: undefined }, - getContent: getContentMock, - getSite: getSiteMock, - }), + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, }); const request = new Request('http://example.com'); const context = new RouterContextProvider(); const nextMock = vi.fn(); + await initializePloneClientContext(request, context); + try { await fetchPloneContent( - { request, params: {}, context, unstable_pattern: '/' }, + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, nextMock, ); } catch (err: any) { expect(err.init.status).toEqual(500); } }); + + it('sets ploneUserContext to null when no token is provided', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn(); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getUserMock).not.toHaveBeenCalled(); + expect(context.get(ploneUserContext)).toBeNull(); + }); + + it('fetches user and sets ploneUserContext when token is valid', async () => { + const mockUser = { data: { id: 'testuser', fullname: 'Test User' } }; + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn().mockResolvedValue(mockUser); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + vi.mocked(getAuthFromRequest).mockResolvedValue('valid.jwt.token'); + vi.mocked(jwtDecode).mockReturnValue({ + sub: 'testuser', + exp: 9999999999, + fullname: 'Test User', + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getUserMock).toHaveBeenCalledWith({ userId: 'testuser' }); + expect(context.get(ploneUserContext)).toEqual(mockUser.data); + expect(getContentMock).toHaveBeenCalledWith({ + path: '/', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions', 'types'], + }); + }); + + it('does not fetch user when token has no sub field', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn(); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + vi.mocked(getAuthFromRequest).mockResolvedValue('token.without.sub'); + vi.mocked(jwtDecode).mockReturnValue({ exp: 9999999999 }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getUserMock).not.toHaveBeenCalled(); + expect(context.get(ploneUserContext)).toBeNull(); + expect(getContentMock).toHaveBeenCalledWith({ + path: '/', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'], + }); + }); + + it('handles JWT decode errors gracefully and proceeds without user', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn(); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + vi.mocked(getAuthFromRequest).mockResolvedValue('malformed.token'); + vi.mocked(jwtDecode).mockImplementation(() => { + throw new Error('Invalid token'); + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getUserMock).not.toHaveBeenCalled(); + expect(context.get(ploneUserContext)).toBeNull(); + }); }); }); diff --git a/apps/seven/app/middleware.server.ts b/apps/seven/app/middleware.server.ts index efef30661f8..7c1e89f3e7d 100644 --- a/apps/seven/app/middleware.server.ts +++ b/apps/seven/app/middleware.server.ts @@ -1,3 +1,4 @@ +import { jwtDecode } from 'jwt-decode'; import { data, createContext } from 'react-router'; import { flattenToAppURL } from '@plone/helpers'; import { getAuthFromRequest } from '@plone/react-router'; @@ -12,6 +13,9 @@ export const ploneContentContext = createContext>['data']>(); export const ploneSiteContext = createContext>['data']>(); +export const ploneUserContext = createContext< + Awaited>['data'] | null +>(null); export const installServerMiddleware: Route.MiddlewareFunction = async ( { request, context }, @@ -20,6 +24,27 @@ export const installServerMiddleware: Route.MiddlewareFunction = async ( installServer(); }; +export const PloneClientMiddleware: Route.MiddlewareFunction = async ( + { request, context }, + next, +) => { + const token = await getAuthFromRequest(request); + + const PloneClient = config + .getUtility({ + name: 'ploneClient', + type: 'client', + }) + .method(); + + const cli = PloneClient.initialize({ + apiPath: config.settings.apiPath, + token, + }); + + context.set(ploneClientContext, cli); +}; + export const otherResources: Route.MiddlewareFunction = async ( { request, params, context }, next, @@ -74,28 +99,36 @@ export const fetchPloneContent: Route.MiddlewareFunction = async ( const token = await getAuthFromRequest(request); const expand = ['navroot', 'breadcrumbs', 'navigation', 'actions']; - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; + let userId = ''; + if (token) { + try { + const decodedToken = jwtDecode<{ + sub: string; + exp: number; + fullname: string | null; + }>(token); + userId = decodedToken.sub || ''; + } catch {} + } + + if (userId) expand.push('types'); + try { - const [content, site] = await Promise.all([ + const [content, site, user] = await Promise.all([ cli.getContent({ path, expand }), cli.getSite(), + userId ? cli.getUser({ userId }) : Promise.resolve(null), ]); migrateContent(content.data); - context.set(ploneClientContext, cli); context.set(ploneContentContext, flattenToAppURL(content.data)); context.set(ploneSiteContext, flattenToAppURL(site.data)); + context.set(ploneUserContext, user?.data ?? null); } catch (error: any) { throw data('Content Not Found', { status: typeof error.status === 'number' ? error.status : 500, diff --git a/apps/seven/app/root.tsx b/apps/seven/app/root.tsx index 605ab5d8e6b..74aee548004 100644 --- a/apps/seven/app/root.tsx +++ b/apps/seven/app/root.tsx @@ -8,6 +8,7 @@ import { fetchPloneContent, getAPIResourceWithAuth, installServerMiddleware, + PloneClientMiddleware, otherResources, ploneClientContext, ploneContentContext, @@ -16,6 +17,7 @@ import { export const middleware = [ installServerMiddleware, + PloneClientMiddleware, otherResources, getAPIResourceWithAuth, fetchPloneContent, diff --git a/apps/seven/news/+contextloaders.internal b/apps/seven/news/+contextloaders.internal new file mode 100644 index 00000000000..f1c4402ee21 --- /dev/null +++ b/apps/seven/news/+contextloaders.internal @@ -0,0 +1 @@ +Updated app test and eslint config. @pnicolli diff --git a/apps/seven/news/+getuser.feature b/apps/seven/news/+getuser.feature new file mode 100644 index 00000000000..204ee964267 --- /dev/null +++ b/apps/seven/news/+getuser.feature @@ -0,0 +1 @@ +Added user data in the context for authenticated users @pnicolli \ No newline at end of file diff --git a/apps/seven/package.json b/apps/seven/package.json index 08ae69e4cc0..578703f6a33 100644 --- a/apps/seven/package.json +++ b/apps/seven/package.json @@ -37,6 +37,7 @@ "i18next-fs-backend": "^2.6.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.27", + "jwt-decode": "^4.0.0", "react": "catalog:", "react-dom": "catalog:", "react-i18next": "catalog:", diff --git a/apps/seven/vitest.config.ts b/apps/seven/vitest.config.ts index 06c617850f3..6eb6f6c4bb3 100644 --- a/apps/seven/vitest.config.ts +++ b/apps/seven/vitest.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ 'packages/**', 'build/**', '*.config.ts', - 'registry.loader.js', + '.plone/**', 'app/entry.server.tsx', 'app/entry.client.tsx', 'app/i18next.server.ts', diff --git a/eslint.config.mjs b/eslint.config.mjs index dc07002c680..11876b08762 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -84,6 +84,9 @@ export default tseslint.config( project: ['packages/*/tsconfig.json', 'apps/seven/tsconfig.json'], alwaysTryTypes: true, }, + alias: { + map: [['seven', './apps/seven']], + }, node: true, }, }, @@ -181,8 +184,7 @@ export default tseslint.config( 'packages/registry/docs', '**/.react-router/*', '**/+types/*', - '**/registry.loader.js', - '**/registry.loader.server.js', + '**/.plone/*', ], }, ); diff --git a/packages/cmsui/+contextloaders.internal b/packages/cmsui/+contextloaders.internal new file mode 100644 index 00000000000..ff7f6b54d8f --- /dev/null +++ b/packages/cmsui/+contextloaders.internal @@ -0,0 +1 @@ +Refactored to use context in all loaders and actions. @pnicolli diff --git a/packages/cmsui/news/+getuser.feature b/packages/cmsui/news/+getuser.feature new file mode 100644 index 00000000000..a8c12c8edcd --- /dev/null +++ b/packages/cmsui/news/+getuser.feature @@ -0,0 +1 @@ +Set the cookie expiration date equal to the token expiration date after login @pnicolli \ No newline at end of file diff --git a/packages/cmsui/package.json b/packages/cmsui/package.json index ce3ae2e7fb8..c01c35b32e4 100644 --- a/packages/cmsui/package.json +++ b/packages/cmsui/package.json @@ -63,6 +63,7 @@ "clsx": "^2.1.1", "jotai": "^2.12.3", "jotai-optics": "^0.4.0", + "jwt-decode": "^4.0.0", "optics-ts": "^2.4.1", "react-aria": "catalog:", "react-aria-components": "catalog:", diff --git a/packages/cmsui/routes/api/createContent.test.tsx b/packages/cmsui/routes/api/createContent.test.tsx index 01c7e5dd6e1..c4aa91e0061 100644 --- a/packages/cmsui/routes/api/createContent.test.tsx +++ b/packages/cmsui/routes/api/createContent.test.tsx @@ -1,12 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import config from '@plone/registry'; import { action } from './createContent'; +import { RouterContextProvider } from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; describe('createContent API route action', () => { afterEach(() => { vi.restoreAllMocks(); config.settings = {}; - delete config.utilities['ploneClient']; }); it('calls createContent with wildcard path and returns response data', async () => { @@ -18,16 +19,10 @@ describe('createContent API route action', () => { }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { - token: undefined, - }, - createContent: createContentMock, - }), - }); + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + createContent: createContentMock, + } as any); const request = new Request('http://example.com/@createContent/folder', { method: 'POST', @@ -51,8 +46,9 @@ describe('createContent API route action', () => { const response = await action({ request, params: { '*': 'folder' }, - context: {}, - } as any); + context, + unstable_pattern: '/@createContent/folder', + }); expect(createContentMock).toHaveBeenCalledWith({ path: '/folder', diff --git a/packages/cmsui/routes/api/createContent.tsx b/packages/cmsui/routes/api/createContent.tsx index e3f019c1e29..3ee51e76f12 100644 --- a/packages/cmsui/routes/api/createContent.tsx +++ b/packages/cmsui/routes/api/createContent.tsx @@ -1,25 +1,22 @@ -import { data, type ActionFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type ActionFunctionArgs, +} from 'react-router'; import { flattenToAppURL } from '@plone/helpers'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; +import { ploneClientContext } from 'seven/app/middleware.server'; type CreateContentRequest = { path?: string; data?: Record; }; -export async function action({ params, request }: ActionFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; +export async function action({ + params, + request, + context, +}: ActionFunctionArgs) { + const cli = context.get(ploneClientContext); const body = (await request.json()) as CreateContentRequest; const pathFromParams = `/${params['*'] || ''}`; diff --git a/packages/cmsui/routes/auth/login.tsx b/packages/cmsui/routes/auth/login.tsx index 2c91780731c..46701b50ee2 100644 --- a/packages/cmsui/routes/auth/login.tsx +++ b/packages/cmsui/routes/auth/login.tsx @@ -3,8 +3,10 @@ import { useActionData, redirect, type ActionFunctionArgs, + RouterContextProvider, } from 'react-router'; - +import { jwtDecode } from 'jwt-decode'; +import { ploneClientContext } from 'seven/app/middleware.server'; import { redirectIfLoggedInLoader, setAuthOnResponse, @@ -13,9 +15,6 @@ import { Button, TextField } from '@plone/components/quanta'; import ploneSvg from '../../static/plone-white.svg'; import ArrowRightSVG from '@plone/components/icons/arrow-right.svg?react'; -import type PloneClient from '@plone/client'; -import config from '@plone/registry'; - export const loader = redirectIfLoggedInLoader; export const meta = () => { @@ -31,22 +30,26 @@ type LoginErrorResponse = { }; }; -export async function action({ request }: ActionFunctionArgs) { +export async function action({ + request, + context, +}: ActionFunctionArgs) { const formData = await request.formData(); const username = String(formData.get('username') || ''); const password = String(formData.get('password') || ''); - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; + const cli = context.get(ploneClientContext); try { const { data } = await cli.login({ username, password }); + const decodedToken = jwtDecode<{ + sub: string; + exp: number; + fullname: string | null; + }>(data.token); + const expires = new Date(decodedToken.exp * 1000); const response = redirect('/'); - return await setAuthOnResponse(response, data.token); + return await setAuthOnResponse(response, data.token, { expires }); } catch (error: any) { return { status: Number(error?.status) || 500, diff --git a/packages/cmsui/routes/auth/logout.tsx b/packages/cmsui/routes/auth/logout.tsx index 996a97d3297..5a372c657cd 100644 --- a/packages/cmsui/routes/auth/logout.tsx +++ b/packages/cmsui/routes/auth/logout.tsx @@ -1,28 +1,25 @@ -import { type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { RouterContextProvider, type LoaderFunctionArgs } from 'react-router'; import { getAuthFromRequest, redirectWithClearedCookie, } from '@plone/react-router'; -import config from '@plone/registry'; +// import { ploneClientContext } from 'seven/app/middleware.server'; -export async function loader({ request }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); +export async function loader({ + request, + // context, +}: LoaderFunctionArgs) { + await getAuthFromRequest(request); + // const token = await getAuthFromRequest(request); - if (token) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; + // if (token) { + // const cli = context.get(ploneClientContext); - // this does not exist yet in @plone/client - // but it's also not needed by default - // see https://6.docs.plone.org/plone.restapi/docs/source/usage/authentication.html - // await cli.logout(); - } + // this does not exist yet in @plone/client + // but it's also not needed by default + // see https://6.docs.plone.org/plone.restapi/docs/source/usage/authentication.html + // await cli.logout(); + // } return redirectWithClearedCookie(); } diff --git a/packages/cmsui/routes/breadcrumbs.tsx b/packages/cmsui/routes/breadcrumbs.tsx index 67d2d75174b..cf972595d32 100644 --- a/packages/cmsui/routes/breadcrumbs.tsx +++ b/packages/cmsui/routes/breadcrumbs.tsx @@ -1,20 +1,16 @@ -import { data, type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; import { flattenToAppURL } from '@plone/helpers'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; +export async function loader({ + params, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; diff --git a/packages/cmsui/routes/controlpanel.tsx b/packages/cmsui/routes/controlpanel.tsx index cac78dd49bb..4e49e58ac72 100644 --- a/packages/cmsui/routes/controlpanel.tsx +++ b/packages/cmsui/routes/controlpanel.tsx @@ -1,5 +1,6 @@ import { redirect, + RouterContextProvider, useFetcher, useLoaderData, useNavigate, @@ -8,8 +9,8 @@ import { } from 'react-router'; import { useTranslation } from 'react-i18next'; import { atom } from 'jotai'; +import { ploneClientContext } from 'seven/app/middleware.server'; import type { DeepKeys } from '@tanstack/react-form'; -import type PloneClient from '@plone/client'; import { requireAuthCookie } from '@plone/react-router'; import { InitAtoms } from '@plone/helpers'; import type { @@ -31,35 +32,29 @@ import Back from '@plone/components/icons/arrow-left.svg?react'; import Checkbox from '@plone/components/icons/checkbox.svg?react'; import config from '@plone/registry'; -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await requireAuthCookie(request); +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); const panel_id = params.id || 'navigation'; - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; + const cli = context.get(ploneClientContext); const { data: controlpanel } = await cli.getControlpanel({ path: panel_id }); return { controlpanel }; } -export async function action({ params, request }: ActionFunctionArgs) { - const token = await requireAuthCookie(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; +export async function action({ + params, + request, + // context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); - cli.config.token = token; + // const cli = context.get(ploneClientContext); // const path = `/${params['*'] || ''}`; diff --git a/packages/cmsui/routes/controlpanels.tsx b/packages/cmsui/routes/controlpanels.tsx index 5efd050a6e7..b0be3450189 100644 --- a/packages/cmsui/routes/controlpanels.tsx +++ b/packages/cmsui/routes/controlpanels.tsx @@ -1,33 +1,34 @@ import { + RouterContextProvider, useLoaderData, useNavigate, type LoaderFunctionArgs, } from 'react-router'; import { useTranslation } from 'react-i18next'; -import type PloneClient from '@plone/client'; +import { ploneClientContext } from 'seven/app/middleware.server'; import { requireAuthCookie } from '@plone/react-router'; import { Button, Container } from '@plone/components/quanta'; import { Plug } from '@plone/layout/components/Pluggable'; import ControlPanelsList from '../components/ControlPanel/ControlPanelsList'; import VersionOverview from '../components/VersionOverview/VersionOverview'; import Back from '@plone/components/icons/arrow-left.svg?react'; -import config from '@plone/registry'; -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await requireAuthCookie(request); +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; + const cli = context.get(ploneClientContext); - cli.config.token = token; - - const { data: controlpanels } = await cli.getControlpanels(); - const { data: systemInformation } = await cli.getSystem(); - return { controlpanels, systemInformation }; + const [controlpanelsRes, sysInfoRes] = await Promise.all([ + cli.getControlpanels(), + cli.getSystem(), + ]); + return { + controlpanels: controlpanelsRes.data, + systemInformation: sysInfoRes.data, + }; } export default function ControlPanels() { diff --git a/packages/cmsui/routes/edit.tsx b/packages/cmsui/routes/edit.tsx index 1f3eca07e3e..6cd9e78dd1f 100644 --- a/packages/cmsui/routes/edit.tsx +++ b/packages/cmsui/routes/edit.tsx @@ -40,7 +40,6 @@ import Sidebar, { sidebarAtom } from '../components/Sidebar/Sidebar'; // import { ConsoleLog } from '../helpers/debug'; export async function loader({ - params, request, context, }: LoaderFunctionArgs) { diff --git a/packages/cmsui/routes/objectBrowserWidget.tsx b/packages/cmsui/routes/objectBrowserWidget.tsx index eb2694d432e..3fed6dbf653 100644 --- a/packages/cmsui/routes/objectBrowserWidget.tsx +++ b/packages/cmsui/routes/objectBrowserWidget.tsx @@ -1,21 +1,17 @@ -import { data, type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; import { flattenToAppURL } from '@plone/helpers'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; -// NOTE: cannot import and reuse loaders, somewhere it tries to load js process which is undefined -export async function loader({ params, request, context }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; @@ -51,7 +47,10 @@ export async function loader({ params, request, context }: LoaderFunctionArgs) { // const strippedRequest = new Request(request.url.replace(/\?.*$/, ''), { // headers: request.headers, // }); - // Call the breadcrumbs endpoint + + // TODO replace with reading from the context expander + // const content = context.get(ploneContentContext), + // content.data['@components'].breadcrumbs.... const { data: breadcrumbs } = await cli.getBreadcrumbs({ path, }); diff --git a/packages/cmsui/routes/search.test.tsx b/packages/cmsui/routes/search.test.tsx index 43579aff327..ac3a12efb4b 100644 --- a/packages/cmsui/routes/search.test.tsx +++ b/packages/cmsui/routes/search.test.tsx @@ -1,12 +1,13 @@ import { expect, describe, it, vi, afterEach } from 'vitest'; import config from '@plone/registry'; import { loader } from './search'; +import { RouterContextProvider } from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; describe('loader', () => { afterEach(() => { vi.restoreAllMocks(); config.settings = {}; - delete config.utilities['ploneClient']; }); it('should call the search method with the correct parameters', async () => { @@ -18,21 +19,21 @@ describe('loader', () => { }, }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - search: searchMock, - config: { - token: undefined, - }, - }), - }); + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + search: searchMock, + } as any); + const request = new Request( 'http://example.com/@search?SearchableText=test&path.depth=1', ); - await loader({ request, params: {}, context: {} } as any); + await loader({ + request, + params: {}, + context, + unstable_pattern: '/@search?SearchableText=test&path.depth=1', + }); expect(searchMock).toHaveBeenCalledWith({ query: { @@ -54,19 +55,14 @@ describe('loader', () => { }, }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - search: searchMock, - config: { - token: undefined, - }, - }), - }); + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + search: searchMock, + } as any); + const request = new Request('http://example.com/@search'); - await loader({ request, params: {}, context: {} } as any); + await loader({ request, params: {}, context, unstable_pattern: '@search' }); expect(searchMock).toHaveBeenCalledWith({ query: { diff --git a/packages/cmsui/routes/search.tsx b/packages/cmsui/routes/search.tsx index b6879780372..33911dd1d82 100644 --- a/packages/cmsui/routes/search.tsx +++ b/packages/cmsui/routes/search.tsx @@ -1,20 +1,17 @@ -import { data, type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; import { flattenToAppURL } from '@plone/helpers'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; +import { ploneClientContext } from 'seven/app/middleware.server'; -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; const query = Object.fromEntries(new URL(request.url).searchParams.entries()); diff --git a/packages/cmsui/vitest.config.ts b/packages/cmsui/vitest.config.ts index 38cde0a5e15..caeb5d3f0a3 100644 --- a/packages/cmsui/vitest.config.ts +++ b/packages/cmsui/vitest.config.ts @@ -1,14 +1,16 @@ import { coverageConfigDefaults, defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; // https://vitejs.dev/config/ export default defineConfig({ + plugins: [tsconfigPaths()], test: { globals: true, environment: 'jsdom', setupFiles: './setupTesting.ts', // you might want to disable it, if you don't have tests that rely on CSS // since parsing CSS is slow - css: true, + // css: true, exclude: ['**/node_modules/**', '**/lib/**', '**/acceptance/**'], coverage: { exclude: [ @@ -16,7 +18,7 @@ export default defineConfig({ 'packages/**', 'build/**', '*.config.ts', - 'registry.loader.js', + '.plone/**', 'app/entry.server.tsx', 'app/entry.client.tsx', 'app/i18next.server.ts', diff --git a/packages/layout/helpers/index.ts b/packages/layout/helpers/index.ts index 6bec2ba4f17..3e1703eda20 100644 --- a/packages/layout/helpers/index.ts +++ b/packages/layout/helpers/index.ts @@ -29,8 +29,8 @@ export function NotContentTypeCondition(contentType: string[]) { }; } -export function shouldShowToolbar(content: Content) { - const actions = content['@components']?.actions; +export function shouldShowToolbar(content?: Content | null) { + const actions = content?.['@components']?.actions; const isVisible = (actions?.object?.some((a) => a.id === 'edit') ?? false) || (actions?.object_buttons?.some((a) => a.id === 'edit') ?? false); diff --git a/packages/layout/news/8030.bugfix b/packages/layout/news/8030.bugfix new file mode 100644 index 00000000000..6ba88c799b6 --- /dev/null +++ b/packages/layout/news/8030.bugfix @@ -0,0 +1 @@ +Hardened `shouldShowToolbar` helper. @sneridagh diff --git a/packages/publicui/news/+contextloaders.internal b/packages/publicui/news/+contextloaders.internal new file mode 100644 index 00000000000..ff7f6b54d8f --- /dev/null +++ b/packages/publicui/news/+contextloaders.internal @@ -0,0 +1 @@ +Refactored to use context in all loaders and actions. @pnicolli diff --git a/packages/publicui/routes/content.tsx b/packages/publicui/routes/content.tsx index a79a9e59f7e..54ee97755a7 100644 --- a/packages/publicui/routes/content.tsx +++ b/packages/publicui/routes/content.tsx @@ -1,15 +1,22 @@ -import { useLocation, useRouteLoaderData } from 'react-router'; +import { + RouterContextProvider, + useLoaderData, + useLocation, + type LoaderFunctionArgs, +} from 'react-router'; import SlotRenderer from '@plone/layout/slots/SlotRenderer'; -import type { RootLoader } from 'seven/app/root'; +import { ploneContentContext } from 'seven/app/middleware.server'; + +export async function loader({ + context, +}: LoaderFunctionArgs) { + const content = context.get(ploneContentContext); + return { content }; +} export default function Content() { - const contentData = useRouteLoaderData('root'); + const { content } = useLoaderData(); const location = useLocation(); - if (!contentData) { - return null; - } - const { content } = contentData; - return ; } diff --git a/packages/publicui/routes/index.tsx b/packages/publicui/routes/index.tsx index 51c611537e4..177db68f16a 100644 --- a/packages/publicui/routes/index.tsx +++ b/packages/publicui/routes/index.tsx @@ -7,19 +7,23 @@ import { Outlet, Scripts, ScrollRestoration, + useLoaderData, useLocation, useMatches, useNavigate, - useRouteLoaderData, type UIMatch, type LinksFunction, type MetaFunction, + type LoaderFunctionArgs, + RouterContextProvider, } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Link, RouterProvider as RACRouterProvider, } from 'react-aria-components'; +import i18next from 'seven/app/i18next.server'; +import { ploneContentContext } from 'seven/app/middleware.server'; import type { RootLoader } from 'seven/app/root'; import Pencil from '@plone/components/icons/pencil.svg?react'; import SlotRenderer from '@plone/layout/slots/SlotRenderer'; @@ -72,13 +76,22 @@ export const links: LinksFunction = () => [ }, ]; -export async function loader() { - return { cssLayers: config.settings.cssLayers }; +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + const locale = await i18next.getLocale(request); + const content = context.get(ploneContentContext); + return { + content, + cssLayers: config.settings.cssLayers, + locale, + }; } export default function Index() { const location = useLocation(); - const rootData = useRouteLoaderData('root'); + const { content, locale } = useLoaderData(); const { i18n } = useTranslation(); const navigate = useNavigate(); const matches = useMatches() as UIMatch[]; @@ -86,10 +99,6 @@ export default function Index() { .filter((match) => match.handle?.bodyClass) .map((match) => match.handle?.bodyClass); - if (!rootData) { - return null; - } - const { content, locale } = rootData; const showToolbar = shouldShowToolbar(content); return ( diff --git a/packages/publicui/routes/search.tsx b/packages/publicui/routes/search.tsx index c75e907b635..d8c3746d113 100644 --- a/packages/publicui/routes/search.tsx +++ b/packages/publicui/routes/search.tsx @@ -2,25 +2,23 @@ import { useTranslation } from 'react-i18next'; import { data, Form, + RouterContextProvider, useLoaderData, type LoaderFunctionArgs, } from 'react-router'; -import type PloneClient from '@plone/client'; - -import config from '@plone/registry'; +import { ploneClientContext } from 'seven/app/middleware.server'; import { Container, Input } from '@plone/components/quanta'; export const handle = { bodyClass: 'search-route', }; -export async function loader({ request, params }: LoaderFunctionArgs) { - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; +export async function loader({ + request, + params, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; const url = new URL(request.url); diff --git a/packages/publicui/routes/sitemap.tsx b/packages/publicui/routes/sitemap.tsx index 95e61e850c6..c37d30eb4e0 100644 --- a/packages/publicui/routes/sitemap.tsx +++ b/packages/publicui/routes/sitemap.tsx @@ -1,24 +1,24 @@ import { useTranslation } from 'react-i18next'; -import { data, type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; import Sitemap from '@plone/layout/components/Sitemap/Sitemap'; +import { Container } from '@plone/components/quanta'; import type { NavigationResponse } from '@plone/types'; import { flattenToAppURL } from '@plone/helpers'; -import config from '@plone/registry'; -import { Container } from '@plone/components/quanta'; - export const handle = { bodyClass: 'sitemap-route', }; -export async function loader({ params }: LoaderFunctionArgs) { - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; +export async function loader({ + params, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); // TODO path for getNavigation const path = `/${params['*'] || ''}`; diff --git a/packages/react-router/news/+getuser.feature b/packages/react-router/news/+getuser.feature new file mode 100644 index 00000000000..a312a99e5b3 --- /dev/null +++ b/packages/react-router/news/+getuser.feature @@ -0,0 +1 @@ +Added options when setting cookies on response @pnicolli \ No newline at end of file diff --git a/packages/react-router/src/index.ts b/packages/react-router/src/index.ts index 7e07c9eccc3..fd85b475596 100644 --- a/packages/react-router/src/index.ts +++ b/packages/react-router/src/index.ts @@ -81,7 +81,7 @@ if (secret === 'default' && process.env.NODE_ENV === 'production') { export const cookie = createCookie('auth_seven', { secrets: [secret], // 30 days - maxAge: 30 * 24 * 60 * 60, + // maxAge: 30 * 24 * 60 * 60, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', @@ -99,8 +99,12 @@ export async function getAuthFromRequest( return token ?? undefined; } -export async function setAuthOnResponse(response: Response, token: string) { - const header = await cookie.serialize(token); +export async function setAuthOnResponse( + response: Response, + token: string, + options?: Parameters[1], +) { + const header = await cookie.serialize(token, options); response.headers.append('Set-Cookie', header); return response; } diff --git a/packages/tooling/package.json b/packages/tooling/package.json index fc132ee9e24..34a661e6f21 100644 --- a/packages/tooling/package.json +++ b/packages/tooling/package.json @@ -27,6 +27,7 @@ "@types/react": "catalog:", "eslint": "^9.20.1", "eslint-config-prettier": "^10.0.1", + "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-better-tailwindcss": "^3.7.9", "eslint-plugin-import": "^2.32.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb26eb4de8e..f4b1ab9c151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ importers: isbot: specifier: ^5.1.27 version: 5.1.31 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 react: specifier: ^19.2.0 version: 19.2.0 @@ -484,6 +487,9 @@ importers: jotai-optics: specifier: ^0.4.0 version: 0.4.0(jotai@2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.6)(react@19.2.0))(optics-ts@2.4.1) + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 optics-ts: specifier: ^2.4.1 version: 2.4.1 @@ -1554,6 +1560,9 @@ importers: eslint-config-prettier: specifier: ^10.0.1 version: 10.1.8(eslint@9.38.0(jiti@2.6.1)) + eslint-import-resolver-alias: + specifier: ^1.1.2 + version: 1.1.2(eslint-plugin-import@2.32.0) eslint-import-resolver-typescript: specifier: ^4.4.4 version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.38.0(jiti@2.6.1)) @@ -6594,6 +6603,12 @@ packages: unrs-resolver: optional: true + eslint-import-resolver-alias@1.1.2: + resolution: {integrity: sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==} + engines: {node: '>= 4'} + peerDependencies: + eslint-plugin-import: '>=1.4.0' + eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} @@ -7691,6 +7706,10 @@ packages: engines: {node: '>=18.17'} hasBin: true + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -16670,6 +16689,10 @@ snapshots: optionalDependencies: unrs-resolver: 1.11.1 + eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0): + dependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4)(eslint@9.38.0(jiti@2.6.1)) + eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 @@ -17965,6 +17988,8 @@ snapshots: slick: 1.12.2 web-resource-inliner: 7.0.0 + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1