diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 83a670c8fc..e26189c158 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -292,6 +292,7 @@ jobs: - access-control - admin__e2e__1 - admin__e2e__2 + - admin-root - auth - field-error-states - fields-relationship diff --git a/docs/admin/overview.mdx b/docs/admin/overview.mdx index 137d3956e2..53ddeb9f6d 100644 --- a/docs/admin/overview.mdx +++ b/docs/admin/overview.mdx @@ -169,19 +169,32 @@ The following options are available: | Option | Default route | Description | | ------------------ | ----------------------- | ------------------------------------- | -| `admin` | `/admin` | The Admin Panel itself. | +| `admin` | `/admin` | The Admin Panel itself. | | `api` | `/api` | The [REST API](../rest-api/overview) base path. | | `graphQL` | `/graphql` | The [GraphQL API](../graphql/overview) base path. | | `graphQLPlayground`| `/graphql-playground` | The GraphQL Playground. | - - Warning: - Changing Root-level Routes _after_ your project was generated will also require you to manually update the corresponding directories in your project. For example, changing `routes.admin` will require you to rename the `(payload)/admin` directory in your project to match the new route. [More details](#project-structure). - - Tip: - You can easily add _new_ routes to the Admin Panel through the `endpoints` property of the Payload Config. See [Custom Endpoints](../rest-api/overview#custom-endpoints) for more information. + You can easily add _new_ routes to the Admin Panel through [Custom Endpoints](../rest-api/overview#custom-endpoints) and [Custom Views](./views). + + +#### Customizing Root-level Routes + +You can change the Root-level Routes as needed, such as to mount the Admin Panel at the root of your application. + +Changing Root-level Routes also requires a change to [Project Structure](#project-structure) to match the new route. For example, if you set `routes.admin` to `/`, you would need to completely remove the `admin` directory from the project structure: + +```plaintext +app/ +├─ (payload)/ +├── [[...segments]]/ +├──── ... +``` + + + Note: + If you set Root-level Routes _before_ auto-generating the Admin Panel, your [Project Structure](#project-structure) will already be set up correctly. ### Admin-level Routes diff --git a/docs/fields/checkbox.mdx b/docs/fields/checkbox.mdx index d2a4024cb3..e3915f3d0e 100644 --- a/docs/fields/checkbox.mdx +++ b/docs/fields/checkbox.mdx @@ -6,7 +6,7 @@ desc: Checkbox field types allow the developer to save a boolean value in the da keywords: checkbox, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs --- -The Checkbox Field type saves a boolean in the database. +The Checkbox Field saves a boolean in the database. { const { collections, globals, - routes: { admin }, + routes: { admin: adminRoute }, } = useConfig() const { i18n } = useTranslation() @@ -69,13 +69,13 @@ export const DefaultNavClient: React.FC = () => { let id: string if (type === EntityType.collection) { - href = `${admin}/collections/${entity.slug}` + href = formatAdminURL({ adminRoute, path: `/collections/${entity.slug}` }) entityLabel = getTranslation(entity.labels.plural, i18n) id = `nav-${entity.slug}` } if (type === EntityType.global) { - href = `${admin}/globals/${entity.slug}` + href = formatAdminURL({ adminRoute, path: `/globals/${entity.slug}` }) entityLabel = getTranslation(entity.label, i18n) id = `nav-global-${entity.slug}` } diff --git a/packages/next/src/utilities/initPage/handleAdminPage.ts b/packages/next/src/utilities/initPage/handleAdminPage.ts index 58e6b7f9d7..bbc544a54d 100644 --- a/packages/next/src/utilities/initPage/handleAdminPage.ts +++ b/packages/next/src/utilities/initPage/handleAdminPage.ts @@ -21,7 +21,8 @@ export const handleAdminPage = ({ route: string }) => { if (isAdminRoute(route, adminRoute)) { - const routeSegments = route.replace(adminRoute, '').split('/').filter(Boolean) + const baseAdminRoute = adminRoute && adminRoute !== '/' ? route.replace(adminRoute, '') : route + const routeSegments = baseAdminRoute.split('/').filter(Boolean) const [entityType, entitySlug, createOrID] = routeSegments const collectionSlug = entityType === 'collections' ? entitySlug : undefined const globalSlug = entityType === 'globals' ? entitySlug : undefined @@ -56,4 +57,6 @@ export const handleAdminPage = ({ globalConfig, } } + + return {} } diff --git a/packages/next/src/utilities/initPage/handleAuthRedirect.ts b/packages/next/src/utilities/initPage/handleAuthRedirect.ts index 3e27a9f48f..d6b66b9dc9 100644 --- a/packages/next/src/utilities/initPage/handleAuthRedirect.ts +++ b/packages/next/src/utilities/initPage/handleAuthRedirect.ts @@ -1,3 +1,4 @@ +import { formatAdminURL } from '@payloadcms/ui/shared' import { redirect } from 'next/navigation.js' import * as qs from 'qs-esm' @@ -30,7 +31,7 @@ export const handleAuthRedirect = ({ : undefined, ) - const adminLoginRoute = `${adminRoute}${loginRouteFromConfig}` + const adminLoginRoute = formatAdminURL({ adminRoute, path: loginRouteFromConfig }) const customLoginRoute = typeof redirectUnauthenticatedUser === 'string' ? redirectUnauthenticatedUser : undefined diff --git a/packages/next/src/views/Dashboard/Default/index.tsx b/packages/next/src/views/Dashboard/Default/index.tsx index b0e26c5c9c..0534cd39df 100644 --- a/packages/next/src/views/Dashboard/Default/index.tsx +++ b/packages/next/src/views/Dashboard/Default/index.tsx @@ -3,7 +3,7 @@ import type { Permissions, ServerProps, VisibleEntities } from 'payload' import { getTranslation } from '@payloadcms/translations' import { Button, Card, Gutter, SetStepNav, SetViewActions } from '@payloadcms/ui' -import { EntityType, WithServerSideProps } from '@payloadcms/ui/shared' +import { EntityType, WithServerSideProps, formatAdminURL } from '@payloadcms/ui/shared' import React, { Fragment } from 'react' import './index.scss' @@ -100,19 +100,31 @@ export const DefaultDashboard: React.FC = (props) => { if (type === EntityType.collection) { title = getTranslation(entity.labels.plural, i18n) + buttonAriaLabel = t('general:showAllLabel', { label: title }) - href = `${adminRoute}/collections/${entity.slug}` - createHREF = `${adminRoute}/collections/${entity.slug}/create` + + href = formatAdminURL({ adminRoute, path: `/collections/${entity.slug}` }) + + createHREF = formatAdminURL({ + adminRoute, + path: `/collections/${entity.slug}/create`, + }) + hasCreatePermission = permissions?.collections?.[entity.slug]?.create?.permission } if (type === EntityType.global) { title = getTranslation(entity.label, i18n) + buttonAriaLabel = t('general:editLabel', { label: getTranslation(entity.label, i18n), }) - href = `${adminRoute}/globals/${entity.slug}` + + href = formatAdminURL({ + adminRoute, + path: `/globals/${entity.slug}`, + }) } return ( diff --git a/packages/next/src/views/Document/getViewsFromConfig.tsx b/packages/next/src/views/Document/getViewsFromConfig.tsx index 9f599ff104..3e88d56c46 100644 --- a/packages/next/src/views/Document/getViewsFromConfig.tsx +++ b/packages/next/src/views/Document/getViewsFromConfig.tsx @@ -126,7 +126,12 @@ export const getViewsFromConfig = ({ } default: { - const baseRoute = [adminRoute, 'collections', collectionSlug, segment3] + const baseRoute = [ + adminRoute !== '/' && adminRoute, + 'collections', + collectionSlug, + segment3, + ] .filter(Boolean) .join('/') @@ -155,7 +160,12 @@ export const getViewsFromConfig = ({ ErrorView = UnauthorizedView } } else { - const baseRoute = [adminRoute, collectionEntity, collectionSlug, segment3] + const baseRoute = [ + adminRoute !== '/' && adminRoute, + collectionEntity, + collectionSlug, + segment3, + ] .filter(Boolean) .join('/') @@ -260,7 +270,9 @@ export const getViewsFromConfig = ({ ErrorView = UnauthorizedView } } else { - const baseRoute = [adminRoute, 'globals', globalSlug].filter(Boolean).join('/') + const baseRoute = [adminRoute !== '/' && adminRoute, 'globals', globalSlug] + .filter(Boolean) + .join('/') const currentRoute = [baseRoute, segment3, ...remainingSegments] .filter(Boolean) diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index ac7b2df28d..4b6bd38522 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -1,7 +1,7 @@ import type { AdminViewComponent, AdminViewProps, EditViewComponent } from 'payload' import { DocumentInfoProvider, EditDepthProvider, HydrateClientUser } from '@payloadcms/ui' -import { RenderCustomComponent, isEditing as getIsEditing } from '@payloadcms/ui/shared' +import { RenderCustomComponent, formatAdminURL , isEditing as getIsEditing } from '@payloadcms/ui/shared' import { notFound, redirect } from 'next/navigation.js' import React from 'react' @@ -174,7 +174,11 @@ export const Document: React.FC = async ({ }) if (doc?.id) { - const redirectURL = `${serverURL}${adminRoute}/collections/${collectionSlug}/${doc.id}` + const redirectURL = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${doc.id}`, + serverURL, + }) redirect(redirectURL) } else { notFound() diff --git a/packages/next/src/views/Edit/Default/SetDocumentStepNav/index.tsx b/packages/next/src/views/Edit/Default/SetDocumentStepNav/index.tsx index bc0dceaf99..47aa584710 100644 --- a/packages/next/src/views/Edit/Default/SetDocumentStepNav/index.tsx +++ b/packages/next/src/views/Edit/Default/SetDocumentStepNav/index.tsx @@ -11,6 +11,7 @@ import { useStepNav, useTranslation, } from '@payloadcms/ui' +import { formatAdminURL } from '@payloadcms/ui/shared' import { useEffect } from 'react' export const SetDocumentStepNav: React.FC<{ @@ -35,7 +36,7 @@ export const SetDocumentStepNav: React.FC<{ const { i18n, t } = useTranslation() const { - routes: { admin }, + routes: { admin: adminRoute }, } = useConfig() const drawerDepth = useEditDepth() @@ -47,13 +48,23 @@ export const SetDocumentStepNav: React.FC<{ if (collectionSlug) { nav.push({ label: getTranslation(pluralLabel, i18n), - url: isVisible ? `${admin}/collections/${collectionSlug}` : undefined, + url: isVisible + ? formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}`, + }) + : undefined, }) if (isEditing) { nav.push({ label: (useAsTitle && useAsTitle !== 'id' && title) || `${id}`, - url: isVisible ? `${admin}/collections/${collectionSlug}/${id}` : undefined, + url: isVisible + ? formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${id}`, + }) + : undefined, }) } else { nav.push({ @@ -63,7 +74,12 @@ export const SetDocumentStepNav: React.FC<{ } else if (globalSlug) { nav.push({ label: title, - url: isVisible ? `${admin}/globals/${globalSlug}` : undefined, + url: isVisible + ? formatAdminURL({ + adminRoute, + path: `/globals/${globalSlug}`, + }) + : undefined, }) } @@ -82,7 +98,7 @@ export const SetDocumentStepNav: React.FC<{ pluralLabel, id, useAsTitle, - admin, + adminRoute, t, i18n, title, diff --git a/packages/next/src/views/Edit/Default/index.tsx b/packages/next/src/views/Edit/Default/index.tsx index 554b3cec41..2be1a9756e 100644 --- a/packages/next/src/views/Edit/Default/index.tsx +++ b/packages/next/src/views/Edit/Default/index.tsx @@ -15,7 +15,7 @@ import { useEditDepth, useUploadEdits, } from '@payloadcms/ui' -import { getFormState } from '@payloadcms/ui/shared' +import { formatAdminURL, getFormState } from '@payloadcms/ui/shared' import { useRouter, useSearchParams } from 'next/navigation.js' import React, { Fragment, useCallback } from 'react' @@ -127,7 +127,10 @@ export const DefaultEditView: React.FC = () => { if (!isEditing && depth < 2) { // Redirect to the same locale if it's been set - const redirectRoute = `${adminRoute}/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}` + const redirectRoute = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`, + }) router.push(redirectRoute) } else { resetUploadEdits() diff --git a/packages/next/src/views/ForgotPassword/index.tsx b/packages/next/src/views/ForgotPassword/index.tsx index 07fcff2d5f..547bd39961 100644 --- a/packages/next/src/views/ForgotPassword/index.tsx +++ b/packages/next/src/views/ForgotPassword/index.tsx @@ -1,6 +1,7 @@ import type { AdminViewProps } from 'payload' import { Button, Translation } from '@payloadcms/ui' +import { formatAdminURL } from '@payloadcms/ui/shared' import LinkImport from 'next/link.js' import React, { Fragment } from 'react' @@ -22,9 +23,9 @@ export const ForgotPasswordView: React.FC = ({ initPageResult }) const { admin: { - routes: { account: accountRoute }, + routes: { account: accountRoute, login: loginRoute }, }, - routes: { admin }, + routes: { admin: adminRoute }, } = config if (user) { @@ -34,14 +35,23 @@ export const ForgotPasswordView: React.FC = ({ initPageResult })

{children}, + '0': ({ children }) => ( + + {children} + + ), }} i18nKey="authentication:loggedInChangePassword" t={i18n.t} />


- @@ -51,7 +61,14 @@ export const ForgotPasswordView: React.FC = ({ initPageResult }) return ( - {i18n.t('authentication:backToLogin')} + + {i18n.t('authentication:backToLogin')} + ) } diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index e931b79475..7a4b6e464e 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -6,7 +6,7 @@ import { ListQueryProvider, TableColumnsProvider, } from '@payloadcms/ui' -import { RenderCustomComponent } from '@payloadcms/ui/shared' +import { RenderCustomComponent, formatAdminURL } from '@payloadcms/ui/shared' import { notFound } from 'next/navigation.js' import { createClientCollectionConfig, mergeListSearchAndWhere } from 'payload' import { isNumber, isReactComponentOrFunction } from 'payload/shared' @@ -66,7 +66,7 @@ export const ListView: React.FC = async ({ } catch (error) {} // eslint-disable-line no-empty const { - routes: { admin }, + routes: { admin: adminRoute }, } = config if (collectionConfig) { @@ -132,7 +132,10 @@ export const ListView: React.FC = async ({ })} collectionSlug={collectionSlug} hasCreatePermission={permissions?.collections?.[collectionSlug]?.create?.permission} - newDocumentURL={`${admin}/collections/${collectionSlug}/create`} + newDocumentURL={formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/create`, + })} > = async ({ limit, listPreferences, locale: fullLocale, - newDocumentURL: `${admin}/collections/${collectionSlug}/create`, + newDocumentURL: formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/create`, + }), params, payload, permissions, diff --git a/packages/next/src/views/Login/LoginForm/index.tsx b/packages/next/src/views/Login/LoginForm/index.tsx index 05aef5f60e..fec31aa18a 100644 --- a/packages/next/src/views/Login/LoginForm/index.tsx +++ b/packages/next/src/views/Login/LoginForm/index.tsx @@ -9,6 +9,7 @@ const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport. import type { FormState, PayloadRequest } from 'payload' import { Form, FormSubmit, PasswordField, useConfig, useTranslation } from '@payloadcms/ui' +import { formatAdminURL } from '@payloadcms/ui/shared' import { password } from 'payload/shared' import type { LoginFieldProps } from '../LoginField/index.js' @@ -29,7 +30,7 @@ export const LoginForm: React.FC<{ routes: { forgot: forgotRoute }, user: userSlug, }, - routes: { admin, api }, + routes: { admin: adminRoute, api: apiRoute }, } = config const collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug) @@ -71,12 +72,12 @@ export const LoginForm: React.FC<{ return (
@@ -104,7 +105,14 @@ export const LoginForm: React.FC<{ } />
- {t('authentication:forgotPasswordQuestion')} + + {t('authentication:forgotPasswordQuestion')} + {t('authentication:login')}
) diff --git a/packages/next/src/views/Logout/LogoutClient.tsx b/packages/next/src/views/Logout/LogoutClient.tsx index 34a2044a47..c3317f8c40 100644 --- a/packages/next/src/views/Logout/LogoutClient.tsx +++ b/packages/next/src/views/Logout/LogoutClient.tsx @@ -1,5 +1,6 @@ 'use client' import { Button, useAuth, useTranslation } from '@payloadcms/ui' +import { formatAdminURL } from '@payloadcms/ui/shared' import LinkImport from 'next/link.js' import React, { Fragment, useEffect } from 'react' @@ -32,9 +33,12 @@ export const LogoutClient: React.FC<{ Link={Link} buttonStyle="secondary" el="link" - url={`${adminRoute}/login${ - redirect && redirect.length > 0 ? `?redirect=${encodeURIComponent(redirect)}` : '' - }`} + url={formatAdminURL({ + adminRoute, + path: `/login${ + redirect && redirect.length > 0 ? `?redirect=${encodeURIComponent(redirect)}` : '' + }`, + })} > {t('authentication:logBackIn')} diff --git a/packages/next/src/views/Logout/index.tsx b/packages/next/src/views/Logout/index.tsx index 315bb8771e..8188ea0cf2 100644 --- a/packages/next/src/views/Logout/index.tsx +++ b/packages/next/src/views/Logout/index.tsx @@ -18,7 +18,7 @@ export const LogoutView: React.FC< req: { payload: { config: { - routes: { admin }, + routes: { admin: adminRoute }, }, }, }, @@ -27,7 +27,7 @@ export const LogoutView: React.FC< return (
diff --git a/packages/next/src/views/NotFound/index.client.tsx b/packages/next/src/views/NotFound/index.client.tsx index c7b09ad048..b800df549f 100644 --- a/packages/next/src/views/NotFound/index.client.tsx +++ b/packages/next/src/views/NotFound/index.client.tsx @@ -18,7 +18,7 @@ export const NotFoundClient: React.FC<{ const { t } = useTranslation() const { - routes: { admin }, + routes: { admin: adminRoute }, } = useConfig() useEffect(() => { @@ -38,7 +38,7 @@ export const NotFoundClient: React.FC<{

{t('general:nothingFound')}

{t('general:sorryNotFound')}

-
diff --git a/packages/next/src/views/NotFound/index.tsx b/packages/next/src/views/NotFound/index.tsx index 5abe6e54fa..0b4b4c2a4e 100644 --- a/packages/next/src/views/NotFound/index.tsx +++ b/packages/next/src/views/NotFound/index.tsx @@ -3,6 +3,7 @@ import type { Metadata } from 'next' import type { AdminViewComponent, SanitizedConfig } from 'payload' import { HydrateClientUser } from '@payloadcms/ui' +import { formatAdminURL } from '@payloadcms/ui/shared' import React, { Fragment } from 'react' import { DefaultTemplate } from '../../templates/Default/index.js' @@ -52,7 +53,7 @@ export const NotFoundPage = async ({ const initPageResult = await initPage({ config, redirectUnauthenticatedUser: true, - route: `${adminRoute}/not-found`, + route: formatAdminURL({ adminRoute, path: '/not-found' }), searchParams, }) diff --git a/packages/next/src/views/ResetPassword/index.client.tsx b/packages/next/src/views/ResetPassword/index.client.tsx index fbb930e0fc..6b700e76d6 100644 --- a/packages/next/src/views/ResetPassword/index.client.tsx +++ b/packages/next/src/views/ResetPassword/index.client.tsx @@ -12,6 +12,7 @@ import { useFormFields, useTranslation, } from '@payloadcms/ui' +import { formatAdminURL } from '@payloadcms/ui/shared' import { useRouter } from 'next/navigation.js' import React from 'react' import { toast } from 'sonner' @@ -36,8 +37,11 @@ const initialState: FormState = { export const ResetPasswordClient: React.FC = ({ token }) => { const i18n = useTranslation() const { - admin: { user: userSlug }, - routes: { admin, api }, + admin: { + routes: { login: loginRoute }, + user: userSlug, + }, + routes: { admin: adminRoute, api: apiRoute }, serverURL, } = useConfig() @@ -49,18 +53,23 @@ export const ResetPasswordClient: React.FC = ({ token }) => { async (data) => { if (data.token) { await fetchFullUser() - history.push(`${admin}`) + history.push(adminRoute) } else { - history.push(`${admin}/login`) + history.push( + formatAdminURL({ + adminRoute, + path: loginRoute, + }), + ) toast.success(i18n.t('general:updatedSuccessfully')) } }, - [fetchFullUser, history, admin, i18n], + [fetchFullUser, history, adminRoute, i18n], ) return (
= ({ initPageResult, params admin: { routes: { account: accountRoute }, }, - routes: { admin }, + routes: { admin: adminRoute }, } = config if (user) { @@ -42,14 +43,23 @@ export const ResetPassword: React.FC = ({ initPageResult, params

{children}, + '0': ({ children }) => ( + + {children} + + ), }} i18nKey="authentication:loggedInChangePassword" t={i18n.t} />


-
diff --git a/packages/next/src/views/Root/getViewFromConfig.tsx b/packages/next/src/views/Root/getViewFromConfig.tsx index 25c7c26781..978b1fd274 100644 --- a/packages/next/src/views/Root/getViewFromConfig.tsx +++ b/packages/next/src/views/Root/getViewFromConfig.tsx @@ -1,5 +1,7 @@ import type { AdminViewComponent, SanitizedConfig } from 'payload' +import { formatAdminURL } from '@payloadcms/ui/shared' + import type { initPage } from '../../utilities/initPage/index.js' import { Account } from '../Account/index.js' @@ -93,7 +95,7 @@ export const getViewFromConfig = ({ return isPathMatchingRoute({ currentRoute, exact: true, - path: `${adminRoute}${route}`, + path: formatAdminURL({ adminRoute, path: route }), }) }) diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index 8e890ab45b..c1261a5d38 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations' import type { Metadata } from 'next' import type { SanitizedConfig } from 'payload' -import { WithServerSideProps } from '@payloadcms/ui/shared' +import { WithServerSideProps, formatAdminURL } from '@payloadcms/ui/shared' import { notFound, redirect } from 'next/navigation.js' import React, { Fragment } from 'react' @@ -37,13 +37,16 @@ export const RootPage = async ({ const { admin: { - routes: { createFirstUser: createFirstUserRoute }, + routes: { createFirstUser: _createFirstUserRoute }, user: userSlug, }, routes: { admin: adminRoute }, } = config - const currentRoute = `${adminRoute}${Array.isArray(params.segments) ? `/${params.segments.join('/')}` : ''}` + const currentRoute = formatAdminURL({ + adminRoute, + path: `${Array.isArray(params.segments) ? `/${params.segments.join('/')}` : ''}`, + }) const segments = Array.isArray(params.segments) ? params.segments : [] @@ -71,20 +74,20 @@ export const RootPage = async ({ }) ?.then((doc) => !!doc) - const routeWithAdmin = `${adminRoute}${createFirstUserRoute}` + const createFirstUserRoute = formatAdminURL({ adminRoute, path: _createFirstUserRoute }) const collectionConfig = config.collections.find(({ slug }) => slug === userSlug) const disableLocalStrategy = collectionConfig?.auth?.disableLocalStrategy - if (disableLocalStrategy && currentRoute === routeWithAdmin) { + if (disableLocalStrategy && currentRoute === createFirstUserRoute) { redirect(adminRoute) } - if (!dbHasUser && currentRoute !== routeWithAdmin && !disableLocalStrategy) { - redirect(routeWithAdmin) + if (!dbHasUser && currentRoute !== createFirstUserRoute && !disableLocalStrategy) { + redirect(createFirstUserRoute) } - if (dbHasUser && currentRoute === routeWithAdmin) { + if (dbHasUser && currentRoute === createFirstUserRoute) { redirect(adminRoute) } } diff --git a/packages/next/src/views/Verify/index.tsx b/packages/next/src/views/Verify/index.tsx index 7ec9b2d98a..069a793ceb 100644 --- a/packages/next/src/views/Verify/index.tsx +++ b/packages/next/src/views/Verify/index.tsx @@ -1,5 +1,6 @@ import type { AdminViewProps } from 'payload' +import { formatAdminURL } from '@payloadcms/ui/shared' import { redirect } from 'next/navigation.js' import React from 'react' @@ -39,10 +40,10 @@ export const Verify: React.FC = async ({ token, }) - return redirect(`${adminRoute}/login`) + return redirect(formatAdminURL({ adminRoute, path: '/login' })) } catch (e) { // already verified - if (e?.status === 202) redirect(`${adminRoute}/login`) + if (e?.status === 202) redirect(formatAdminURL({ adminRoute, path: '/login' })) textToRender = req.t('authentication:unableToVerify') } diff --git a/packages/next/src/views/Version/Default/SetStepNav.tsx b/packages/next/src/views/Version/Default/SetStepNav.tsx index 16b4cdb164..3efb70ff6b 100644 --- a/packages/next/src/views/Version/Default/SetStepNav.tsx +++ b/packages/next/src/views/Version/Default/SetStepNav.tsx @@ -5,7 +5,7 @@ import type React from 'react' import { getTranslation } from '@payloadcms/translations' import { useConfig, useLocale, useStepNav, useTranslation } from '@payloadcms/ui' -import { formatDate } from '@payloadcms/ui/shared' +import { formatAdminURL, formatDate } from '@payloadcms/ui/shared' import { useEffect } from 'react' export const SetStepNav: React.FC<{ @@ -62,15 +62,18 @@ export const SetStepNav: React.FC<{ nav = [ { label: getTranslation(pluralLabel, i18n), - url: `${adminRoute}/collections/${collectionSlug}`, + url: formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}` }), }, { label: docLabel, - url: `${adminRoute}/collections/${collectionSlug}/${id}`, + url: formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}/${id}` }), }, { label: 'Versions', - url: `${adminRoute}/collections/${collectionSlug}/${id}/versions`, + url: formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${id}/versions`, + }), }, { label: doc?.createdAt @@ -84,11 +87,17 @@ export const SetStepNav: React.FC<{ nav = [ { label: globalConfig.label, - url: `${adminRoute}/globals/${globalConfig.slug}`, + url: formatAdminURL({ + adminRoute, + path: `/globals/${globalConfig.slug}`, + }), }, { label: 'Versions', - url: `${adminRoute}/globals/${globalConfig.slug}/versions`, + url: formatAdminURL({ + adminRoute, + path: `/globals/${globalConfig.slug}/versions`, + }), }, { label: doc?.createdAt diff --git a/packages/next/src/views/Version/Restore/index.tsx b/packages/next/src/views/Version/Restore/index.tsx index c30f0367ef..fc50f00e2f 100644 --- a/packages/next/src/views/Version/Restore/index.tsx +++ b/packages/next/src/views/Version/Restore/index.tsx @@ -1,7 +1,7 @@ 'use client' import { getTranslation } from '@payloadcms/translations' import { Button, Modal, Pill, useConfig, useModal, useTranslation } from '@payloadcms/ui' -import { requests } from '@payloadcms/ui/shared' +import { formatAdminURL, requests } from '@payloadcms/ui/shared' import { useRouter } from 'next/navigation.js' import React, { Fragment, useCallback, useState } from 'react' import { toast } from 'sonner' @@ -24,9 +24,10 @@ const Restore: React.FC = ({ versionID, }) => { const { - routes: { admin, api }, + routes: { admin: adminRoute, api: apiRoute }, serverURL, } = useConfig() + const { toggleModal } = useModal() const [processing, setProcessing] = useState(false) const router = useRouter() @@ -37,17 +38,23 @@ const Restore: React.FC = ({ versionDate, }) - let fetchURL = `${serverURL}${api}` + let fetchURL = `${serverURL}${apiRoute}` let redirectURL: string if (collectionSlug) { fetchURL = `${fetchURL}/${collectionSlug}/versions/${versionID}` - redirectURL = `${admin}/collections/${collectionSlug}/${originalDocID}` + redirectURL = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${originalDocID}`, + }) } if (globalSlug) { fetchURL = `${fetchURL}/globals/${globalSlug}/versions/${versionID}` - redirectURL = `${admin}/globals/${globalSlug}` + redirectURL = formatAdminURL({ + adminRoute, + path: `/globals/${globalSlug}`, + }) } const handleRestore = useCallback(async () => { diff --git a/packages/next/src/views/Versions/cells/CreatedAt/index.tsx b/packages/next/src/views/Versions/cells/CreatedAt/index.tsx index 22b6849fca..dd85e847cd 100644 --- a/packages/next/src/views/Versions/cells/CreatedAt/index.tsx +++ b/packages/next/src/views/Versions/cells/CreatedAt/index.tsx @@ -1,6 +1,6 @@ 'use client' import { useConfig, useTableCell, useTranslation } from '@payloadcms/ui' -import { formatDate } from '@payloadcms/ui/shared' +import { formatAdminURL, formatDate } from '@payloadcms/ui/shared' import LinkImport from 'next/link.js' import React from 'react' @@ -19,7 +19,7 @@ export const CreatedAtCell: React.FC = ({ }) => { const { admin: { dateFormat }, - routes: { admin }, + routes: { admin: adminRoute }, } = useConfig() const { i18n } = useTranslation() @@ -30,9 +30,17 @@ export const CreatedAtCell: React.FC = ({ let to: string - if (collectionSlug) to = `${admin}/collections/${collectionSlug}/${docID}/versions/${versionID}` + if (collectionSlug) + to = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${docID}/versions/${versionID}`, + }) - if (globalSlug) to = `${admin}/globals/${globalSlug}/versions/${versionID}` + if (globalSlug) + to = formatAdminURL({ + adminRoute, + path: `/globals/${globalSlug}/versions/${versionID}`, + }) return ( diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 3ac995bbfa..b4a4306516 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -699,7 +699,10 @@ export type Config = { plugins?: Plugin[] /** Control the routing structure that Payload binds itself to. */ routes?: { - /** @default "/admin" */ + /** The route for the admin panel. + * @example "/my-admin" + * @default "/admin" + */ admin?: string /** @default "/api" */ api?: string diff --git a/packages/plugin-search/src/Search/ui/index.client.tsx b/packages/plugin-search/src/Search/ui/index.client.tsx index 1e0b956c1c..30bf419fa8 100644 --- a/packages/plugin-search/src/Search/ui/index.client.tsx +++ b/packages/plugin-search/src/Search/ui/index.client.tsx @@ -3,6 +3,7 @@ import type { FormState } from 'payload' import { useConfig, useWatchForm } from '@payloadcms/ui' +import { formatAdminURL } from '@payloadcms/ui/shared' import React from 'react' // TODO: fix this import to work in dev mode within the monorepo in a way that is backwards compatible with 1.x // import CopyToClipboard from 'payload/dist/admin/components/elements/CopyToClipboard' @@ -35,7 +36,10 @@ export const LinkToDocClient: React.FC = () => { serverURL, } = config - const href = `${serverURL}${adminRoute}/collections/${relationTo}/${docId}` + const href = `${serverURL}${formatAdminURL({ + adminRoute, + path: '/collections/${relationTo}/${docId}', + })}` return (
diff --git a/packages/ui/src/elements/AppHeader/index.tsx b/packages/ui/src/elements/AppHeader/index.tsx index 873ce4b6f1..f1307e26d4 100644 --- a/packages/ui/src/elements/AppHeader/index.tsx +++ b/packages/ui/src/elements/AppHeader/index.tsx @@ -6,6 +6,7 @@ import { Account } from '../../graphics/Account/index.js' import { useActions } from '../../providers/Actions/index.js' import { useConfig } from '../../providers/Config/index.js' import { useTranslation } from '../../providers/Translation/index.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { Hamburger } from '../Hamburger/index.js' import { LocalizerLabel } from '../Localizer/LocalizerLabel/index.js' import { Localizer } from '../Localizer/index.js' @@ -89,7 +90,7 @@ export const AppHeader: React.FC = () => { diff --git a/packages/ui/src/elements/DeleteDocument/index.tsx b/packages/ui/src/elements/DeleteDocument/index.tsx index bae9b49019..e7baf9eef8 100644 --- a/packages/ui/src/elements/DeleteDocument/index.tsx +++ b/packages/ui/src/elements/DeleteDocument/index.tsx @@ -12,6 +12,7 @@ import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { Button } from '../Button/index.js' import { PopupList } from '../Popup/index.js' import { Translation } from '../Translation/index.js' @@ -32,7 +33,7 @@ export const DeleteDocument: React.FC = (props) => { const { id, buttonId, collectionSlug, singularLabel, title: titleFromProps } = props const { - routes: { admin, api }, + routes: { admin: adminRoute, api }, serverURL, } = useConfig() @@ -75,7 +76,12 @@ export const DeleteDocument: React.FC = (props) => { json.message, ) - return router.push(`${admin}/collections/${collectionSlug}`) + return router.push( + formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}`, + }), + ) } toggleModal(modalSlug) if (json.errors) { @@ -104,7 +110,7 @@ export const DeleteDocument: React.FC = (props) => { i18n, title, router, - admin, + adminRoute, addDefaultError, ]) diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 28c3a549a3..f261be4338 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -7,6 +7,7 @@ import React, { Fragment, useEffect } from 'react' import { useComponentMap } from '../../providers/ComponentMap/index.js' import { useConfig } from '../../providers/Config/index.js' import { useTranslation } from '../../providers/Translation/index.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { formatDate } from '../../utilities/formatDate.js' import { Autosave } from '../Autosave/index.js' import { DeleteDocument } from '../DeleteDocument/index.js' @@ -199,7 +200,10 @@ export const DocumentControls: React.FC<{ {hasCreatePermission && ( {i18n.t('general:createNew')} diff --git a/packages/ui/src/elements/DuplicateDocument/index.tsx b/packages/ui/src/elements/DuplicateDocument/index.tsx index d270708149..2c9a096941 100644 --- a/packages/ui/src/elements/DuplicateDocument/index.tsx +++ b/packages/ui/src/elements/DuplicateDocument/index.tsx @@ -13,6 +13,7 @@ import { useConfig } from '../../providers/Config/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { Button } from '../Button/index.js' import { PopupList } from '../Popup/index.js' import './index.scss' @@ -31,13 +32,16 @@ export const DuplicateDocument: React.FC = ({ id, slug, singularLabel }) const { toggleModal } = useModal() const locale = useLocale() const { setModified } = useForm() + const { - routes: { api }, + routes: { api: apiRoute }, serverURL, } = useConfig() + const { - routes: { admin }, + routes: { admin: adminRoute }, } = useConfig() + const [hasClicked, setHasClicked] = useState(false) const { i18n, t } = useTranslation() @@ -53,7 +57,7 @@ export const DuplicateDocument: React.FC = ({ id, slug, singularLabel }) } await requests .post( - `${serverURL}${api}/${slug}/${id}/duplicate${locale?.code ? `?locale=${locale.code}` : ''}`, + `${serverURL}${apiRoute}/${slug}/${id}/duplicate${locale?.code ? `?locale=${locale.code}` : ''}`, { body: JSON.stringify({}), headers: { @@ -72,7 +76,10 @@ export const DuplicateDocument: React.FC = ({ id, slug, singularLabel }) ) setModified(false) router.push( - `${admin}/collections/${slug}/${doc.id}${locale?.code ? `?locale=${locale.code}` : ''}`, + formatAdminURL({ + adminRoute, + path: `/collections/${slug}/${doc.id}${locale?.code ? `?locale=${locale.code}` : ''}`, + }), ) } else { toast.error( @@ -87,7 +94,7 @@ export const DuplicateDocument: React.FC = ({ id, slug, singularLabel }) locale, modified, serverURL, - api, + apiRoute, slug, id, i18n, @@ -97,7 +104,7 @@ export const DuplicateDocument: React.FC = ({ id, slug, singularLabel }) singularLabel, setModified, router, - admin, + adminRoute, ], ) diff --git a/packages/ui/src/elements/Logout/index.tsx b/packages/ui/src/elements/Logout/index.tsx index a7b0826a1c..4ca87ceb07 100644 --- a/packages/ui/src/elements/Logout/index.tsx +++ b/packages/ui/src/elements/Logout/index.tsx @@ -5,6 +5,7 @@ import { LogOutIcon } from '../../icons/LogOut/index.js' import { useComponentMap } from '../../providers/ComponentMap/index.js' import { useConfig } from '../../providers/Config/index.js' import { useTranslation } from '../../providers/Translation/index.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' const baseClass = 'nav' @@ -19,18 +20,19 @@ const DefaultLogout: React.FC<{ admin: { routes: { logout: logoutRoute }, }, - routes: { admin }, + routes: { admin: adminRoute }, } = config - const basePath = process.env.NEXT_BASE_PATH ?? '' - const LinkElement = Link || 'a' return ( diff --git a/packages/ui/src/elements/StayLoggedIn/index.tsx b/packages/ui/src/elements/StayLoggedIn/index.tsx index 1829efed3f..f9128ba923 100644 --- a/packages/ui/src/elements/StayLoggedIn/index.tsx +++ b/packages/ui/src/elements/StayLoggedIn/index.tsx @@ -8,6 +8,7 @@ import { Button } from '../../elements/Button/index.js' import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' import { useTranslation } from '../../providers/Translation/index.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' import './index.scss' const baseClass = 'stay-logged-in' @@ -24,8 +25,9 @@ export const StayLoggedInModal: React.FC = () => { admin: { routes: { logout: logoutRoute }, }, - routes: { admin }, + routes: { admin: adminRoute }, } = config + const { toggleModal } = useModal() const { t } = useTranslation() @@ -41,7 +43,12 @@ export const StayLoggedInModal: React.FC = () => { buttonStyle="secondary" onClick={() => { toggleModal(stayLoggedInModalSlug) - router.push(`${admin}${logoutRoute}`) + router.push( + formatAdminURL({ + adminRoute, + path: logoutRoute, + }), + ) }} > {t('authentication:logOut')} diff --git a/packages/ui/src/elements/Table/DefaultCell/index.tsx b/packages/ui/src/elements/Table/DefaultCell/index.tsx index f81b76663f..af694f9555 100644 --- a/packages/ui/src/elements/Table/DefaultCell/index.tsx +++ b/packages/ui/src/elements/Table/DefaultCell/index.tsx @@ -8,6 +8,7 @@ import { getTranslation } from '@payloadcms/translations' import { useConfig } from '../../../providers/Config/index.js' import { useTranslation } from '../../../providers/Translation/index.js' +import { formatAdminURL } from '../../../utilities/formatAdminURL.js' import { useTableCell } from '../TableCellProvider/index.js' import { CodeCell } from './fields/Code/index.js' import { cellComponents } from './fields/index.js' @@ -56,7 +57,10 @@ export const DefaultCell: React.FC = (props) => { if (isLink) { WrapElement = Link wrapElementProps.href = customCellContext?.collectionSlug - ? `${adminRoute}/collections/${customCellContext?.collectionSlug}/${rowData.id}` + ? formatAdminURL({ + adminRoute, + path: `/collections/${customCellContext?.collectionSlug}/${rowData.id}`, + }) : '' } diff --git a/packages/ui/src/exports/shared/index.ts b/packages/ui/src/exports/shared/index.ts index 10d910a798..cf824f9fde 100644 --- a/packages/ui/src/exports/shared/index.ts +++ b/packages/ui/src/exports/shared/index.ts @@ -6,6 +6,7 @@ export { PayloadIcon } from '../../graphics/Icon/index.js' export { PayloadLogo } from '../../graphics/Logo/index.js' export { requests } from '../../utilities/api.js' export { findLocaleFromCode } from '../../utilities/findLocaleFromCode.js' +export { formatAdminURL } from '../../utilities/formatAdminURL.js' export { formatDate } from '../../utilities/formatDate.js' export { formatDocTitle } from '../../utilities/formatDocTitle.js' export { getFormState } from '../../utilities/getFormState.js' diff --git a/packages/ui/src/graphics/Account/index.tsx b/packages/ui/src/graphics/Account/index.tsx index 9e71e6b397..141b010fa9 100644 --- a/packages/ui/src/graphics/Account/index.tsx +++ b/packages/ui/src/graphics/Account/index.tsx @@ -4,6 +4,7 @@ import React from 'react' import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { DefaultAccountIcon } from './Default/index.js' import { GravatarAccountIcon } from './Gravatar/index.js' @@ -18,9 +19,7 @@ export const Account = () => { const { user } = useAuth() const pathname = usePathname() - - const isOnAccountPage = pathname === `${adminRoute}${accountRoute}` - + const isOnAccountPage = pathname === formatAdminURL({ adminRoute, path: accountRoute }) if (!user?.email || Avatar === 'default') return if (Avatar === 'gravatar') return if (Avatar) return diff --git a/packages/ui/src/providers/Auth/index.tsx b/packages/ui/src/providers/Auth/index.tsx index f60c8081ba..68df3aa1f9 100644 --- a/packages/ui/src/providers/Auth/index.tsx +++ b/packages/ui/src/providers/Auth/index.tsx @@ -11,6 +11,7 @@ import { stayLoggedInModalSlug } from '../../elements/StayLoggedIn/index.js' import { useDebounce } from '../../hooks/useDebounce.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { useConfig } from '../Config/index.js' export type AuthContext = { @@ -48,7 +49,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children routes: { inactivity: logoutInactivityRoute }, user: userSlug, }, - routes: { admin, api }, + routes: { admin: adminRoute, api: apiRoute }, serverURL, } = config @@ -62,14 +63,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const id = user?.id const redirectToInactivityRoute = useCallback(() => { - if (window.location.pathname.startsWith(admin)) { + if (window.location.pathname.startsWith(adminRoute)) { const redirectParam = `?redirect=${encodeURIComponent(window.location.pathname)}` - router.replace(`${admin}${logoutInactivityRoute}${redirectParam}`) + router.replace( + formatAdminURL({ + adminRoute, + path: `${logoutInactivityRoute}${redirectParam}`, + }), + ) } else { - router.replace(`${admin}${logoutInactivityRoute}`) + router.replace( + formatAdminURL({ + adminRoute, + path: logoutInactivityRoute, + }), + ) } closeAllModals() - }, [router, admin, logoutInactivityRoute, closeAllModals]) + }, [router, adminRoute, logoutInactivityRoute, closeAllModals]) const revokeTokenAndExpire = useCallback(() => { setTokenInMemory(undefined) @@ -97,11 +108,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children if (forceRefresh || (tokenExpiration && remainingTime < 120)) { setTimeout(async () => { try { - const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`, { - headers: { - 'Accept-Language': i18n.language, + const request = await requests.post( + `${serverURL}${apiRoute}/${userSlug}/refresh-token`, + { + headers: { + 'Accept-Language': i18n.language, + }, }, - }) + ) if (request.status === 200) { const json = await request.json() @@ -121,7 +135,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children [ tokenExpiration, serverURL, - api, + apiRoute, userSlug, i18n, redirectToInactivityRoute, @@ -132,7 +146,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const refreshCookieAsync = useCallback( async (skipSetUser?: boolean): Promise => { try { - const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`, { + const request = await requests.post(`${serverURL}${apiRoute}/${userSlug}/refresh-token`, { headers: { 'Accept-Language': i18n.language, }, @@ -155,18 +169,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children return null } }, - [serverURL, api, userSlug, i18n, redirectToInactivityRoute, setTokenAndExpiration], + [serverURL, apiRoute, userSlug, i18n, redirectToInactivityRoute, setTokenAndExpiration], ) const logOut = useCallback(async () => { setUser(null) revokeTokenAndExpire() try { - await requests.post(`${serverURL}${api}/${userSlug}/logout`) + await requests.post(`${serverURL}${apiRoute}/${userSlug}/logout`) } catch (e) { toast.error(`Logging out failed: ${e.message}`) } - }, [serverURL, api, userSlug, revokeTokenAndExpire]) + }, [serverURL, apiRoute, userSlug, revokeTokenAndExpire]) const refreshPermissions = useCallback(async () => { const params = { @@ -174,7 +188,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } try { - const request = await requests.get(`${serverURL}${api}/access?${qs.stringify(params)}`, { + const request = await requests.get(`${serverURL}${apiRoute}/access?${qs.stringify(params)}`, { headers: { 'Accept-Language': i18n.language, }, @@ -189,11 +203,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } catch (e) { toast.error(`Refreshing permissions failed: ${e.message}`) } - }, [serverURL, api, i18n, code]) + }, [serverURL, apiRoute, i18n, code]) const fetchFullUser = React.useCallback(async () => { try { - const request = await requests.get(`${serverURL}${api}/${userSlug}/me`, { + const request = await requests.get(`${serverURL}${apiRoute}/${userSlug}/me`, { headers: { 'Accept-Language': i18n.language, }, @@ -216,7 +230,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } catch (e) { toast.error(`Fetching user failed: ${e.message}`) } - }, [serverURL, api, userSlug, i18n.language, setTokenAndExpiration, revokeTokenAndExpire]) + }, [serverURL, apiRoute, userSlug, i18n.language, setTokenAndExpiration, revokeTokenAndExpire]) // On mount, get user and set useEffect(() => { diff --git a/packages/ui/src/utilities/formatAdminURL.ts b/packages/ui/src/utilities/formatAdminURL.ts new file mode 100644 index 0000000000..0fe16dff59 --- /dev/null +++ b/packages/ui/src/utilities/formatAdminURL.ts @@ -0,0 +1,22 @@ +import type { Config } from 'payload' + +/** Will read the `routes.admin` config and appropriately handle `"/"` admin paths */ +export const formatAdminURL = (args: { + adminRoute: Config['routes']['admin'] + path: string + serverURL?: Config['serverURL'] +}): string => { + const { adminRoute, path, serverURL } = args + + if (adminRoute) { + if (adminRoute === '/') { + if (!path) { + return `${serverURL || ''}${adminRoute}` + } + } else { + return `${serverURL || ''}${adminRoute}${path}` + } + } + + return `${serverURL || ''}${path}` +} diff --git a/test/admin-root/.gitignore b/test/admin-root/.gitignore new file mode 100644 index 0000000000..cce01755f4 --- /dev/null +++ b/test/admin-root/.gitignore @@ -0,0 +1,2 @@ +/media +/media-gif diff --git a/test/admin-root/app/(payload)/[[...segments]]/not-found.tsx b/test/admin-root/app/(payload)/[[...segments]]/not-found.tsx new file mode 100644 index 0000000000..e7723f49a0 --- /dev/null +++ b/test/admin-root/app/(payload)/[[...segments]]/not-found.tsx @@ -0,0 +1,22 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +import type { Metadata } from 'next' + +import config from '@payload-config' +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views' + +type Args = { + params: { + segments: string[] + } + searchParams: { + [key: string]: string | string[] + } +} + +export const generateMetadata = ({ params, searchParams }: Args): Promise => + generatePageMetadata({ config, params, searchParams }) + +const NotFound = ({ params, searchParams }: Args) => NotFoundPage({ config, params, searchParams }) + +export default NotFound diff --git a/test/admin-root/app/(payload)/[[...segments]]/page.tsx b/test/admin-root/app/(payload)/[[...segments]]/page.tsx new file mode 100644 index 0000000000..61be15c883 --- /dev/null +++ b/test/admin-root/app/(payload)/[[...segments]]/page.tsx @@ -0,0 +1,22 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +import type { Metadata } from 'next' + +import config from '@payload-config' +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import { RootPage, generatePageMetadata } from '@payloadcms/next/views' + +type Args = { + params: { + segments: string[] + } + searchParams: { + [key: string]: string | string[] + } +} + +export const generateMetadata = ({ params, searchParams }: Args): Promise => + generatePageMetadata({ config, params, searchParams }) + +const Page = ({ params, searchParams }: Args) => RootPage({ config, params, searchParams }) + +export default Page diff --git a/test/admin-root/app/(payload)/api/[...slug]/route.ts b/test/admin-root/app/(payload)/api/[...slug]/route.ts new file mode 100644 index 0000000000..52caec96ad --- /dev/null +++ b/test/admin-root/app/(payload)/api/[...slug]/route.ts @@ -0,0 +1,10 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY it because it could be re-written at any time. */ +import config from '@payload-config' +import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' + +export const GET = REST_GET(config) +export const POST = REST_POST(config) +export const DELETE = REST_DELETE(config) +export const PATCH = REST_PATCH(config) +export const OPTIONS = REST_OPTIONS(config) diff --git a/test/admin-root/app/(payload)/api/graphql-playground/route.ts b/test/admin-root/app/(payload)/api/graphql-playground/route.ts new file mode 100644 index 0000000000..7b7f279983 --- /dev/null +++ b/test/admin-root/app/(payload)/api/graphql-playground/route.ts @@ -0,0 +1,6 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY it because it could be re-written at any time. */ +import config from '@payload-config' +import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' + +export const GET = GRAPHQL_PLAYGROUND_GET(config) diff --git a/test/admin-root/app/(payload)/api/graphql/route.ts b/test/admin-root/app/(payload)/api/graphql/route.ts new file mode 100644 index 0000000000..c2723e439f --- /dev/null +++ b/test/admin-root/app/(payload)/api/graphql/route.ts @@ -0,0 +1,6 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY it because it could be re-written at any time. */ +import config from '@payload-config' +import { GRAPHQL_POST } from '@payloadcms/next/routes' + +export const POST = GRAPHQL_POST(config) diff --git a/test/admin-root/app/(payload)/custom.scss b/test/admin-root/app/(payload)/custom.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/admin-root/app/(payload)/layout.tsx b/test/admin-root/app/(payload)/layout.tsx new file mode 100644 index 0000000000..57c61a3a67 --- /dev/null +++ b/test/admin-root/app/(payload)/layout.tsx @@ -0,0 +1,16 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +import configPromise from '@payload-config' +import { RootLayout } from '@payloadcms/next/layouts' +// import '@payloadcms/ui/styles.css' // Uncomment this line if `@payloadcms/ui` in `tsconfig.json` points to `/ui/dist` instead of `/ui/src` +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import React from 'react' + +import './custom.scss' + +type Args = { + children: React.ReactNode +} + +const Layout = ({ children }: Args) => {children} + +export default Layout diff --git a/test/admin-root/app/my-route/route.ts b/test/admin-root/app/my-route/route.ts new file mode 100644 index 0000000000..6a705ad240 --- /dev/null +++ b/test/admin-root/app/my-route/route.ts @@ -0,0 +1,5 @@ +export const GET = () => { + return Response.json({ + hello: 'elliot', + }) +} diff --git a/test/admin-root/collections/Posts/index.ts b/test/admin-root/collections/Posts/index.ts new file mode 100644 index 0000000000..3ac8e7758f --- /dev/null +++ b/test/admin-root/collections/Posts/index.ts @@ -0,0 +1,19 @@ +import type { CollectionConfig } from 'payload' + +export const postsSlug = 'posts' + +export const PostsCollection: CollectionConfig = { + slug: postsSlug, + admin: { + useAsTitle: 'text', + }, + fields: [ + { + name: 'text', + type: 'text', + }, + ], + versions: { + drafts: true, + }, +} diff --git a/test/admin-root/config.ts b/test/admin-root/config.ts new file mode 100644 index 0000000000..e35f37dc38 --- /dev/null +++ b/test/admin-root/config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import path from 'path' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' +import { PostsCollection, postsSlug } from './collections/Posts/index.js' +import { MenuGlobal } from './globals/Menu/index.js' +import { adminRoute } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + collections: [PostsCollection], + cors: ['http://localhost:3000', 'http://localhost:3001'], + globals: [MenuGlobal], + routes: { + admin: adminRoute, + }, + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + await payload.create({ + collection: postsSlug, + data: { + text: 'example post', + }, + }) + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/admin-root/e2e.spec.ts b/test/admin-root/e2e.spec.ts new file mode 100644 index 0000000000..5bdd676f9b --- /dev/null +++ b/test/admin-root/e2e.spec.ts @@ -0,0 +1,61 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import * as path from 'path' +import { adminRoute } from 'shared.js' +import { fileURLToPath } from 'url' + +import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js' +import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' +import { TEST_TIMEOUT_LONG } from '../playwright.config.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +test.describe('Admin Panel (Root)', () => { + let page: Page + let url: AdminUrlUtil + + test.beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + + const { serverURL } = await initPayloadE2ENoConfig({ dirname }) + url = new AdminUrlUtil(serverURL, 'posts', { + admin: adminRoute, + }) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ + customRoutes: { + admin: adminRoute, + }, + page, + serverURL, + }) + }) + + test('renders admin panel at root', async () => { + await page.goto(url.admin) + const pageURL = page.url() + expect(pageURL).toBe(url.admin) + expect(pageURL).not.toContain('/admin') + }) + + test('collection — navigates to list view', async () => { + await page.goto(url.list) + const pageURL = page.url() + expect(pageURL).toContain(url.list) + expect(pageURL).not.toContain('/admin') + }) + + test('global — navigates to edit view', async () => { + await page.goto(url.global('menu')) + const pageURL = page.url() + expect(pageURL).toBe(url.global('menu')) + expect(pageURL).not.toContain('/admin') + }) +}) diff --git a/test/admin-root/eslint.config.js b/test/admin-root/eslint.config.js new file mode 100644 index 0000000000..b27e03cee9 --- /dev/null +++ b/test/admin-root/eslint.config.js @@ -0,0 +1,21 @@ +import { rootParserOptions } from '../../eslint.config.js' +import { testEslintConfig } from '../eslint.config.js' + +/** @typedef {import('eslint').Linter.FlatConfig} */ +let FlatConfig + +/** @type {FlatConfig[]} */ +export const index = [ + ...testEslintConfig, + { + languageOptions: { + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigDirName: import.meta.dirname, + ...rootParserOptions, + }, + }, + }, +] + +export default index diff --git a/test/admin-root/globals/Menu/index.ts b/test/admin-root/globals/Menu/index.ts new file mode 100644 index 0000000000..f9f603d786 --- /dev/null +++ b/test/admin-root/globals/Menu/index.ts @@ -0,0 +1,13 @@ +import type { GlobalConfig } from 'payload' + +export const menuSlug = 'menu' + +export const MenuGlobal: GlobalConfig = { + slug: menuSlug, + fields: [ + { + name: 'globalText', + type: 'text', + }, + ], +} diff --git a/test/admin-root/int.spec.ts b/test/admin-root/int.spec.ts new file mode 100644 index 0000000000..fa6d6c9a36 --- /dev/null +++ b/test/admin-root/int.spec.ts @@ -0,0 +1,72 @@ +import type { Payload } from 'payload' + +import type { NextRESTClient } from '../helpers/NextRESTClient.js' + +import { devUser } from '../credentials.js' +import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { postsSlug } from './collections/Posts/index.js' +import configPromise from './config.js' + +let payload: Payload +let token: string +let restClient: NextRESTClient + +const { email, password } = devUser + +describe('Admin (Root) Tests', () => { + // --__--__--__--__--__--__--__--__--__ + // Boilerplate test setup/teardown + // --__--__--__--__--__--__--__--__--__ + beforeAll(async () => { + const initialized = await initPayloadInt(configPromise) + ;({ payload, restClient } = initialized) + + const data = await restClient + .POST('/users/login', { + body: JSON.stringify({ + email, + password, + }), + }) + .then((res) => res.json()) + + token = data.token + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + // --__--__--__--__--__--__--__--__--__ + // You can run tests against the local API or the REST API + // use the tests below as a guide + // --__--__--__--__--__--__--__--__--__ + + it('local API example', async () => { + const newPost = await payload.create({ + collection: postsSlug, + data: { + text: 'LOCAL API EXAMPLE', + }, + }) + + expect(newPost.text).toEqual('LOCAL API EXAMPLE') + }) + + it('rest API example', async () => { + const data = await restClient + .POST(`/${postsSlug}`, { + body: JSON.stringify({ + text: 'REST API EXAMPLE', + }), + headers: { + Authorization: `JWT ${token}`, + }, + }) + .then((res) => res.json()) + + expect(data.doc.text).toEqual('REST API EXAMPLE') + }) +}) diff --git a/test/admin-root/next-env.d.ts b/test/admin-root/next-env.d.ts new file mode 100644 index 0000000000..4f11a03dc6 --- /dev/null +++ b/test/admin-root/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/test/admin-root/next.config.mjs b/test/admin-root/next.config.mjs new file mode 100644 index 0000000000..63880cf124 --- /dev/null +++ b/test/admin-root/next.config.mjs @@ -0,0 +1,30 @@ +import bundleAnalyzer from '@next/bundle-analyzer' + +import withPayload from '../../packages/next/src/withPayload.js' + +const withBundleAnalyzer = bundleAnalyzer({ + enabled: process.env.ANALYZE === 'true', +}) + +export default withBundleAnalyzer( + withPayload({ + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + images: { + domains: ['localhost'], + }, + webpack: (webpackConfig) => { + webpackConfig.resolve.extensionAlias = { + '.cjs': ['.cts', '.cjs'], + '.js': ['.ts', '.tsx', '.js', '.jsx'], + '.mjs': ['.mts', '.mjs'], + } + + return webpackConfig + }, + }), +) diff --git a/test/admin-root/payload-types.ts b/test/admin-root/payload-types.ts new file mode 100644 index 0000000000..290dab133a --- /dev/null +++ b/test/admin-root/payload-types.ts @@ -0,0 +1,130 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +export interface Config { + auth: { + users: UserAuthOperations; + }; + collections: { + posts: Post; + users: User; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + db: { + defaultIDType: string; + }; + globals: { + menu: Menu; + }; + locale: null; + user: User & { + collection: 'users'; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: string; + text?: string | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "menu". + */ +export interface Menu { + id: string; + globalText?: string | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/admin-root/schema.graphql b/test/admin-root/schema.graphql new file mode 100644 index 0000000000..8dcff9dc22 --- /dev/null +++ b/test/admin-root/schema.graphql @@ -0,0 +1,1742 @@ +type Query { + Post(id: String!, draft: Boolean): Post + Posts(draft: Boolean, where: Post_where, limit: Int, page: Int, sort: String): Posts + countPosts(draft: Boolean, where: Post_where): countPosts + docAccessPost(id: String!): postsDocAccess + versionPost(id: String): PostVersion + versionsPosts(where: versionsPost_where, limit: Int, page: Int, sort: String): versionsPosts + User(id: String!, draft: Boolean): User + Users(draft: Boolean, where: User_where, limit: Int, page: Int, sort: String): Users + countUsers(draft: Boolean, where: User_where): countUsers + docAccessUser(id: String!): usersDocAccess + meUser: usersMe + initializedUser: Boolean + PayloadPreference(id: String!, draft: Boolean): PayloadPreference + PayloadPreferences(draft: Boolean, where: PayloadPreference_where, limit: Int, page: Int, sort: String): PayloadPreferences + countPayloadPreferences(draft: Boolean, where: PayloadPreference_where): countPayloadPreferences + docAccessPayloadPreference(id: String!): payload_preferencesDocAccess + Menu(draft: Boolean): Menu + docAccessMenu: menuDocAccess + Access: Access +} + +type Post { + id: String + text: String + updatedAt: DateTime + createdAt: DateTime + _status: Post__status +} + +""" +A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. +""" +scalar DateTime + +enum Post__status { + draft + published +} + +type Posts { + docs: [Post] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input Post_where { + text: Post_text_operator + updatedAt: Post_updatedAt_operator + createdAt: Post_createdAt_operator + _status: Post__status_operator + id: Post_id_operator + AND: [Post_where_and] + OR: [Post_where_or] +} + +input Post_text_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input Post_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input Post_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input Post__status_operator { + equals: Post__status_Input + not_equals: Post__status_Input + in: [Post__status_Input] + not_in: [Post__status_Input] + all: [Post__status_Input] + exists: Boolean +} + +enum Post__status_Input { + draft + published +} + +input Post_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input Post_where_and { + text: Post_text_operator + updatedAt: Post_updatedAt_operator + createdAt: Post_createdAt_operator + _status: Post__status_operator + id: Post_id_operator + AND: [Post_where_and] + OR: [Post_where_or] +} + +input Post_where_or { + text: Post_text_operator + updatedAt: Post_updatedAt_operator + createdAt: Post_createdAt_operator + _status: Post__status_operator + id: Post_id_operator + AND: [Post_where_and] + OR: [Post_where_or] +} + +type countPosts { + totalDocs: Int +} + +type postsDocAccess { + fields: PostsDocAccessFields + create: PostsCreateDocAccess + read: PostsReadDocAccess + update: PostsUpdateDocAccess + delete: PostsDeleteDocAccess + readVersions: PostsReadVersionsDocAccess +} + +type PostsDocAccessFields { + text: PostsDocAccessFields_text + updatedAt: PostsDocAccessFields_updatedAt + createdAt: PostsDocAccessFields_createdAt + _status: PostsDocAccessFields__status +} + +type PostsDocAccessFields_text { + create: PostsDocAccessFields_text_Create + read: PostsDocAccessFields_text_Read + update: PostsDocAccessFields_text_Update + delete: PostsDocAccessFields_text_Delete +} + +type PostsDocAccessFields_text_Create { + permission: Boolean! +} + +type PostsDocAccessFields_text_Read { + permission: Boolean! +} + +type PostsDocAccessFields_text_Update { + permission: Boolean! +} + +type PostsDocAccessFields_text_Delete { + permission: Boolean! +} + +type PostsDocAccessFields_updatedAt { + create: PostsDocAccessFields_updatedAt_Create + read: PostsDocAccessFields_updatedAt_Read + update: PostsDocAccessFields_updatedAt_Update + delete: PostsDocAccessFields_updatedAt_Delete +} + +type PostsDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type PostsDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type PostsDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type PostsDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type PostsDocAccessFields_createdAt { + create: PostsDocAccessFields_createdAt_Create + read: PostsDocAccessFields_createdAt_Read + update: PostsDocAccessFields_createdAt_Update + delete: PostsDocAccessFields_createdAt_Delete +} + +type PostsDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type PostsDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type PostsDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type PostsDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type PostsDocAccessFields__status { + create: PostsDocAccessFields__status_Create + read: PostsDocAccessFields__status_Read + update: PostsDocAccessFields__status_Update + delete: PostsDocAccessFields__status_Delete +} + +type PostsDocAccessFields__status_Create { + permission: Boolean! +} + +type PostsDocAccessFields__status_Read { + permission: Boolean! +} + +type PostsDocAccessFields__status_Update { + permission: Boolean! +} + +type PostsDocAccessFields__status_Delete { + permission: Boolean! +} + +type PostsCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type PostsReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type PostsUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PostsDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type PostsReadVersionsDocAccess { + permission: Boolean! + where: JSONObject +} + +type PostVersion { + parent(draft: Boolean): Post + version: PostVersion_Version + createdAt: DateTime + updatedAt: DateTime + latest: Boolean + id: String +} + +type PostVersion_Version { + text: String + updatedAt: DateTime + createdAt: DateTime + _status: PostVersion_Version__status +} + +enum PostVersion_Version__status { + draft + published +} + +type versionsPosts { + docs: [PostVersion] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input versionsPost_where { + parent: versionsPost_parent_operator + version__text: versionsPost_version__text_operator + version__updatedAt: versionsPost_version__updatedAt_operator + version__createdAt: versionsPost_version__createdAt_operator + version___status: versionsPost_version___status_operator + createdAt: versionsPost_createdAt_operator + updatedAt: versionsPost_updatedAt_operator + latest: versionsPost_latest_operator + id: versionsPost_id_operator + AND: [versionsPost_where_and] + OR: [versionsPost_where_or] +} + +input versionsPost_parent_operator { + equals: JSON + not_equals: JSON + in: [JSON] + not_in: [JSON] + all: [JSON] + exists: Boolean +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +input versionsPost_version__text_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input versionsPost_version__updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input versionsPost_version__createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input versionsPost_version___status_operator { + equals: versionsPost_version___status_Input + not_equals: versionsPost_version___status_Input + in: [versionsPost_version___status_Input] + not_in: [versionsPost_version___status_Input] + all: [versionsPost_version___status_Input] + exists: Boolean +} + +enum versionsPost_version___status_Input { + draft + published +} + +input versionsPost_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input versionsPost_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input versionsPost_latest_operator { + equals: Boolean + not_equals: Boolean + exists: Boolean +} + +input versionsPost_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input versionsPost_where_and { + parent: versionsPost_parent_operator + version__text: versionsPost_version__text_operator + version__updatedAt: versionsPost_version__updatedAt_operator + version__createdAt: versionsPost_version__createdAt_operator + version___status: versionsPost_version___status_operator + createdAt: versionsPost_createdAt_operator + updatedAt: versionsPost_updatedAt_operator + latest: versionsPost_latest_operator + id: versionsPost_id_operator + AND: [versionsPost_where_and] + OR: [versionsPost_where_or] +} + +input versionsPost_where_or { + parent: versionsPost_parent_operator + version__text: versionsPost_version__text_operator + version__updatedAt: versionsPost_version__updatedAt_operator + version__createdAt: versionsPost_version__createdAt_operator + version___status: versionsPost_version___status_operator + createdAt: versionsPost_createdAt_operator + updatedAt: versionsPost_updatedAt_operator + latest: versionsPost_latest_operator + id: versionsPost_id_operator + AND: [versionsPost_where_and] + OR: [versionsPost_where_or] +} + +type User { + id: String + updatedAt: DateTime + createdAt: DateTime + email: EmailAddress! + resetPasswordToken: String + resetPasswordExpiration: DateTime + salt: String + hash: String + loginAttempts: Float + lockUntil: DateTime + password: String! +} + +""" +A field whose value conforms to the standard internet email address format as specified in HTML Spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address. +""" +scalar EmailAddress @specifiedBy(url: "https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address") + +type Users { + docs: [User] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input User_where { + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +input User_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input User_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input User_email_operator { + equals: EmailAddress + not_equals: EmailAddress + like: EmailAddress + contains: EmailAddress + in: [EmailAddress] + not_in: [EmailAddress] + all: [EmailAddress] +} + +input User_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input User_where_and { + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +input User_where_or { + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +type countUsers { + totalDocs: Int +} + +type usersDocAccess { + fields: UsersDocAccessFields + create: UsersCreateDocAccess + read: UsersReadDocAccess + update: UsersUpdateDocAccess + delete: UsersDeleteDocAccess + unlock: UsersUnlockDocAccess +} + +type UsersDocAccessFields { + updatedAt: UsersDocAccessFields_updatedAt + createdAt: UsersDocAccessFields_createdAt + email: UsersDocAccessFields_email + password: UsersDocAccessFields_password +} + +type UsersDocAccessFields_updatedAt { + create: UsersDocAccessFields_updatedAt_Create + read: UsersDocAccessFields_updatedAt_Read + update: UsersDocAccessFields_updatedAt_Update + delete: UsersDocAccessFields_updatedAt_Delete +} + +type UsersDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt { + create: UsersDocAccessFields_createdAt_Create + read: UsersDocAccessFields_createdAt_Read + update: UsersDocAccessFields_createdAt_Update + delete: UsersDocAccessFields_createdAt_Delete +} + +type UsersDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_email { + create: UsersDocAccessFields_email_Create + read: UsersDocAccessFields_email_Read + update: UsersDocAccessFields_email_Update + delete: UsersDocAccessFields_email_Delete +} + +type UsersDocAccessFields_email_Create { + permission: Boolean! +} + +type UsersDocAccessFields_email_Read { + permission: Boolean! +} + +type UsersDocAccessFields_email_Update { + permission: Boolean! +} + +type UsersDocAccessFields_email_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_password { + create: UsersDocAccessFields_password_Create + read: UsersDocAccessFields_password_Read + update: UsersDocAccessFields_password_Update + delete: UsersDocAccessFields_password_Delete +} + +type UsersDocAccessFields_password_Create { + permission: Boolean! +} + +type UsersDocAccessFields_password_Read { + permission: Boolean! +} + +type UsersDocAccessFields_password_Update { + permission: Boolean! +} + +type UsersDocAccessFields_password_Delete { + permission: Boolean! +} + +type UsersCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUnlockDocAccess { + permission: Boolean! + where: JSONObject +} + +type usersMe { + collection: String + exp: Int + strategy: String + token: String + user: User +} + +type PayloadPreference { + id: String + user: PayloadPreference_User_Relationship! + key: String + value: JSON + updatedAt: DateTime + createdAt: DateTime +} + +type PayloadPreference_User_Relationship { + relationTo: PayloadPreference_User_RelationTo + value: PayloadPreference_User +} + +enum PayloadPreference_User_RelationTo { + users +} + +union PayloadPreference_User = User + +type PayloadPreferences { + docs: [PayloadPreference] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input PayloadPreference_where { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +input PayloadPreference_user_Relation { + relationTo: PayloadPreference_user_Relation_RelationTo + value: JSON +} + +enum PayloadPreference_user_Relation_RelationTo { + users +} + +input PayloadPreference_key_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadPreference_value_operator { + equals: JSON + not_equals: JSON + like: JSON + contains: JSON + within: JSON + intersects: JSON + exists: Boolean +} + +input PayloadPreference_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadPreference_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadPreference_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadPreference_where_and { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +input PayloadPreference_where_or { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +type countPayloadPreferences { + totalDocs: Int +} + +type payload_preferencesDocAccess { + fields: PayloadPreferencesDocAccessFields + create: PayloadPreferencesCreateDocAccess + read: PayloadPreferencesReadDocAccess + update: PayloadPreferencesUpdateDocAccess + delete: PayloadPreferencesDeleteDocAccess +} + +type PayloadPreferencesDocAccessFields { + user: PayloadPreferencesDocAccessFields_user + key: PayloadPreferencesDocAccessFields_key + value: PayloadPreferencesDocAccessFields_value + updatedAt: PayloadPreferencesDocAccessFields_updatedAt + createdAt: PayloadPreferencesDocAccessFields_createdAt +} + +type PayloadPreferencesDocAccessFields_user { + create: PayloadPreferencesDocAccessFields_user_Create + read: PayloadPreferencesDocAccessFields_user_Read + update: PayloadPreferencesDocAccessFields_user_Update + delete: PayloadPreferencesDocAccessFields_user_Delete +} + +type PayloadPreferencesDocAccessFields_user_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key { + create: PayloadPreferencesDocAccessFields_key_Create + read: PayloadPreferencesDocAccessFields_key_Read + update: PayloadPreferencesDocAccessFields_key_Update + delete: PayloadPreferencesDocAccessFields_key_Delete +} + +type PayloadPreferencesDocAccessFields_key_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value { + create: PayloadPreferencesDocAccessFields_value_Create + read: PayloadPreferencesDocAccessFields_value_Read + update: PayloadPreferencesDocAccessFields_value_Update + delete: PayloadPreferencesDocAccessFields_value_Delete +} + +type PayloadPreferencesDocAccessFields_value_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt { + create: PayloadPreferencesDocAccessFields_updatedAt_Create + read: PayloadPreferencesDocAccessFields_updatedAt_Read + update: PayloadPreferencesDocAccessFields_updatedAt_Update + delete: PayloadPreferencesDocAccessFields_updatedAt_Delete +} + +type PayloadPreferencesDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt { + create: PayloadPreferencesDocAccessFields_createdAt_Create + read: PayloadPreferencesDocAccessFields_createdAt_Read + update: PayloadPreferencesDocAccessFields_createdAt_Update + delete: PayloadPreferencesDocAccessFields_createdAt_Delete +} + +type PayloadPreferencesDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type Menu { + globalText: String + updatedAt: DateTime + createdAt: DateTime +} + +type menuDocAccess { + fields: MenuDocAccessFields + read: MenuReadDocAccess + update: MenuUpdateDocAccess +} + +type MenuDocAccessFields { + globalText: MenuDocAccessFields_globalText + updatedAt: MenuDocAccessFields_updatedAt + createdAt: MenuDocAccessFields_createdAt +} + +type MenuDocAccessFields_globalText { + create: MenuDocAccessFields_globalText_Create + read: MenuDocAccessFields_globalText_Read + update: MenuDocAccessFields_globalText_Update + delete: MenuDocAccessFields_globalText_Delete +} + +type MenuDocAccessFields_globalText_Create { + permission: Boolean! +} + +type MenuDocAccessFields_globalText_Read { + permission: Boolean! +} + +type MenuDocAccessFields_globalText_Update { + permission: Boolean! +} + +type MenuDocAccessFields_globalText_Delete { + permission: Boolean! +} + +type MenuDocAccessFields_updatedAt { + create: MenuDocAccessFields_updatedAt_Create + read: MenuDocAccessFields_updatedAt_Read + update: MenuDocAccessFields_updatedAt_Update + delete: MenuDocAccessFields_updatedAt_Delete +} + +type MenuDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type MenuDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type MenuDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type MenuDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type MenuDocAccessFields_createdAt { + create: MenuDocAccessFields_createdAt_Create + read: MenuDocAccessFields_createdAt_Read + update: MenuDocAccessFields_createdAt_Update + delete: MenuDocAccessFields_createdAt_Delete +} + +type MenuDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type MenuDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type MenuDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type MenuDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type MenuReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type MenuUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type Access { + canAccessAdmin: Boolean! + posts: postsAccess + users: usersAccess + payload_preferences: payload_preferencesAccess + menu: menuAccess +} + +type postsAccess { + fields: PostsFields + create: PostsCreateAccess + read: PostsReadAccess + update: PostsUpdateAccess + delete: PostsDeleteAccess + readVersions: PostsReadVersionsAccess +} + +type PostsFields { + text: PostsFields_text + updatedAt: PostsFields_updatedAt + createdAt: PostsFields_createdAt + _status: PostsFields__status +} + +type PostsFields_text { + create: PostsFields_text_Create + read: PostsFields_text_Read + update: PostsFields_text_Update + delete: PostsFields_text_Delete +} + +type PostsFields_text_Create { + permission: Boolean! +} + +type PostsFields_text_Read { + permission: Boolean! +} + +type PostsFields_text_Update { + permission: Boolean! +} + +type PostsFields_text_Delete { + permission: Boolean! +} + +type PostsFields_updatedAt { + create: PostsFields_updatedAt_Create + read: PostsFields_updatedAt_Read + update: PostsFields_updatedAt_Update + delete: PostsFields_updatedAt_Delete +} + +type PostsFields_updatedAt_Create { + permission: Boolean! +} + +type PostsFields_updatedAt_Read { + permission: Boolean! +} + +type PostsFields_updatedAt_Update { + permission: Boolean! +} + +type PostsFields_updatedAt_Delete { + permission: Boolean! +} + +type PostsFields_createdAt { + create: PostsFields_createdAt_Create + read: PostsFields_createdAt_Read + update: PostsFields_createdAt_Update + delete: PostsFields_createdAt_Delete +} + +type PostsFields_createdAt_Create { + permission: Boolean! +} + +type PostsFields_createdAt_Read { + permission: Boolean! +} + +type PostsFields_createdAt_Update { + permission: Boolean! +} + +type PostsFields_createdAt_Delete { + permission: Boolean! +} + +type PostsFields__status { + create: PostsFields__status_Create + read: PostsFields__status_Read + update: PostsFields__status_Update + delete: PostsFields__status_Delete +} + +type PostsFields__status_Create { + permission: Boolean! +} + +type PostsFields__status_Read { + permission: Boolean! +} + +type PostsFields__status_Update { + permission: Boolean! +} + +type PostsFields__status_Delete { + permission: Boolean! +} + +type PostsCreateAccess { + permission: Boolean! + where: JSONObject +} + +type PostsReadAccess { + permission: Boolean! + where: JSONObject +} + +type PostsUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type PostsDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type PostsReadVersionsAccess { + permission: Boolean! + where: JSONObject +} + +type usersAccess { + fields: UsersFields + create: UsersCreateAccess + read: UsersReadAccess + update: UsersUpdateAccess + delete: UsersDeleteAccess + unlock: UsersUnlockAccess +} + +type UsersFields { + updatedAt: UsersFields_updatedAt + createdAt: UsersFields_createdAt + email: UsersFields_email + password: UsersFields_password +} + +type UsersFields_updatedAt { + create: UsersFields_updatedAt_Create + read: UsersFields_updatedAt_Read + update: UsersFields_updatedAt_Update + delete: UsersFields_updatedAt_Delete +} + +type UsersFields_updatedAt_Create { + permission: Boolean! +} + +type UsersFields_updatedAt_Read { + permission: Boolean! +} + +type UsersFields_updatedAt_Update { + permission: Boolean! +} + +type UsersFields_updatedAt_Delete { + permission: Boolean! +} + +type UsersFields_createdAt { + create: UsersFields_createdAt_Create + read: UsersFields_createdAt_Read + update: UsersFields_createdAt_Update + delete: UsersFields_createdAt_Delete +} + +type UsersFields_createdAt_Create { + permission: Boolean! +} + +type UsersFields_createdAt_Read { + permission: Boolean! +} + +type UsersFields_createdAt_Update { + permission: Boolean! +} + +type UsersFields_createdAt_Delete { + permission: Boolean! +} + +type UsersFields_email { + create: UsersFields_email_Create + read: UsersFields_email_Read + update: UsersFields_email_Update + delete: UsersFields_email_Delete +} + +type UsersFields_email_Create { + permission: Boolean! +} + +type UsersFields_email_Read { + permission: Boolean! +} + +type UsersFields_email_Update { + permission: Boolean! +} + +type UsersFields_email_Delete { + permission: Boolean! +} + +type UsersFields_password { + create: UsersFields_password_Create + read: UsersFields_password_Read + update: UsersFields_password_Update + delete: UsersFields_password_Delete +} + +type UsersFields_password_Create { + permission: Boolean! +} + +type UsersFields_password_Read { + permission: Boolean! +} + +type UsersFields_password_Update { + permission: Boolean! +} + +type UsersFields_password_Delete { + permission: Boolean! +} + +type UsersCreateAccess { + permission: Boolean! + where: JSONObject +} + +type UsersReadAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type UsersDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUnlockAccess { + permission: Boolean! + where: JSONObject +} + +type payload_preferencesAccess { + fields: PayloadPreferencesFields + create: PayloadPreferencesCreateAccess + read: PayloadPreferencesReadAccess + update: PayloadPreferencesUpdateAccess + delete: PayloadPreferencesDeleteAccess +} + +type PayloadPreferencesFields { + user: PayloadPreferencesFields_user + key: PayloadPreferencesFields_key + value: PayloadPreferencesFields_value + updatedAt: PayloadPreferencesFields_updatedAt + createdAt: PayloadPreferencesFields_createdAt +} + +type PayloadPreferencesFields_user { + create: PayloadPreferencesFields_user_Create + read: PayloadPreferencesFields_user_Read + update: PayloadPreferencesFields_user_Update + delete: PayloadPreferencesFields_user_Delete +} + +type PayloadPreferencesFields_user_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_key { + create: PayloadPreferencesFields_key_Create + read: PayloadPreferencesFields_key_Read + update: PayloadPreferencesFields_key_Update + delete: PayloadPreferencesFields_key_Delete +} + +type PayloadPreferencesFields_key_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_value { + create: PayloadPreferencesFields_value_Create + read: PayloadPreferencesFields_value_Read + update: PayloadPreferencesFields_value_Update + delete: PayloadPreferencesFields_value_Delete +} + +type PayloadPreferencesFields_value_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt { + create: PayloadPreferencesFields_updatedAt_Create + read: PayloadPreferencesFields_updatedAt_Read + update: PayloadPreferencesFields_updatedAt_Update + delete: PayloadPreferencesFields_updatedAt_Delete +} + +type PayloadPreferencesFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt { + create: PayloadPreferencesFields_createdAt_Create + read: PayloadPreferencesFields_createdAt_Read + update: PayloadPreferencesFields_createdAt_Update + delete: PayloadPreferencesFields_createdAt_Delete +} + +type PayloadPreferencesFields_createdAt_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesCreateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesReadAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type menuAccess { + fields: MenuFields + read: MenuReadAccess + update: MenuUpdateAccess +} + +type MenuFields { + globalText: MenuFields_globalText + updatedAt: MenuFields_updatedAt + createdAt: MenuFields_createdAt +} + +type MenuFields_globalText { + create: MenuFields_globalText_Create + read: MenuFields_globalText_Read + update: MenuFields_globalText_Update + delete: MenuFields_globalText_Delete +} + +type MenuFields_globalText_Create { + permission: Boolean! +} + +type MenuFields_globalText_Read { + permission: Boolean! +} + +type MenuFields_globalText_Update { + permission: Boolean! +} + +type MenuFields_globalText_Delete { + permission: Boolean! +} + +type MenuFields_updatedAt { + create: MenuFields_updatedAt_Create + read: MenuFields_updatedAt_Read + update: MenuFields_updatedAt_Update + delete: MenuFields_updatedAt_Delete +} + +type MenuFields_updatedAt_Create { + permission: Boolean! +} + +type MenuFields_updatedAt_Read { + permission: Boolean! +} + +type MenuFields_updatedAt_Update { + permission: Boolean! +} + +type MenuFields_updatedAt_Delete { + permission: Boolean! +} + +type MenuFields_createdAt { + create: MenuFields_createdAt_Create + read: MenuFields_createdAt_Read + update: MenuFields_createdAt_Update + delete: MenuFields_createdAt_Delete +} + +type MenuFields_createdAt_Create { + permission: Boolean! +} + +type MenuFields_createdAt_Read { + permission: Boolean! +} + +type MenuFields_createdAt_Update { + permission: Boolean! +} + +type MenuFields_createdAt_Delete { + permission: Boolean! +} + +type MenuReadAccess { + permission: Boolean! + where: JSONObject +} + +type MenuUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type Mutation { + createPost(data: mutationPostInput!, draft: Boolean): Post + updatePost(id: String!, autosave: Boolean, data: mutationPostUpdateInput!, draft: Boolean): Post + deletePost(id: String!): Post + duplicatePost(id: String!): Post + restoreVersionPost(id: String): Post + createUser(data: mutationUserInput!, draft: Boolean): User + updateUser(id: String!, autosave: Boolean, data: mutationUserUpdateInput!, draft: Boolean): User + deleteUser(id: String!): User + refreshTokenUser: usersRefreshedUser + logoutUser: String + unlockUser(email: String!): Boolean! + loginUser(email: String!, password: String): usersLoginResult + forgotPasswordUser(disableEmail: Boolean, expiration: Int, email: String!): Boolean! + resetPasswordUser(password: String, token: String): usersResetPassword + verifyEmailUser(token: String): Boolean + createPayloadPreference(data: mutationPayloadPreferenceInput!, draft: Boolean): PayloadPreference + updatePayloadPreference(id: String!, autosave: Boolean, data: mutationPayloadPreferenceUpdateInput!, draft: Boolean): PayloadPreference + deletePayloadPreference(id: String!): PayloadPreference + duplicatePayloadPreference(id: String!): PayloadPreference + updateMenu(data: mutationMenuInput!, draft: Boolean): Menu +} + +input mutationPostInput { + text: String + updatedAt: String + createdAt: String + _status: Post__status_MutationInput +} + +enum Post__status_MutationInput { + draft + published +} + +input mutationPostUpdateInput { + text: String + updatedAt: String + createdAt: String + _status: PostUpdate__status_MutationInput +} + +enum PostUpdate__status_MutationInput { + draft + published +} + +input mutationUserInput { + updatedAt: String + createdAt: String + email: String! + resetPasswordToken: String + resetPasswordExpiration: String + salt: String + hash: String + loginAttempts: Float + lockUntil: String + password: String! +} + +input mutationUserUpdateInput { + updatedAt: String + createdAt: String + email: String + resetPasswordToken: String + resetPasswordExpiration: String + salt: String + hash: String + loginAttempts: Float + lockUntil: String + password: String +} + +type usersRefreshedUser { + exp: Int + refreshedToken: String + strategy: String + user: usersJWT +} + +type usersJWT { + email: EmailAddress! + collection: String! +} + +type usersLoginResult { + exp: Int + token: String + user: User +} + +type usersResetPassword { + token: String + user: User +} + +input mutationPayloadPreferenceInput { + user: PayloadPreference_UserRelationshipInput + key: String + value: JSON + updatedAt: String + createdAt: String +} + +input PayloadPreference_UserRelationshipInput { + relationTo: PayloadPreference_UserRelationshipInputRelationTo + value: JSON +} + +enum PayloadPreference_UserRelationshipInputRelationTo { + users +} + +input mutationPayloadPreferenceUpdateInput { + user: PayloadPreferenceUpdate_UserRelationshipInput + key: String + value: JSON + updatedAt: String + createdAt: String +} + +input PayloadPreferenceUpdate_UserRelationshipInput { + relationTo: PayloadPreferenceUpdate_UserRelationshipInputRelationTo + value: JSON +} + +enum PayloadPreferenceUpdate_UserRelationshipInputRelationTo { + users +} + +input mutationMenuInput { + globalText: String + updatedAt: String + createdAt: String +} \ No newline at end of file diff --git a/test/admin-root/shared.js b/test/admin-root/shared.js new file mode 100644 index 0000000000..42301e6c81 --- /dev/null +++ b/test/admin-root/shared.js @@ -0,0 +1 @@ +export const adminRoute = '/' diff --git a/test/admin-root/tsconfig.eslint.json b/test/admin-root/tsconfig.eslint.json new file mode 100644 index 0000000000..b34cc7afbb --- /dev/null +++ b/test/admin-root/tsconfig.eslint.json @@ -0,0 +1,13 @@ +{ + // extend your base config to share compilerOptions, etc + //"extends": "./tsconfig.json", + "compilerOptions": { + // ensure that nobody can accidentally use this config for a build + "noEmit": true + }, + "include": [ + // whatever paths you intend to lint + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/test/admin-root/tsconfig.json b/test/admin-root/tsconfig.json new file mode 100644 index 0000000000..88a05102f3 --- /dev/null +++ b/test/admin-root/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@payload-config": ["./config.ts"], + "@payloadcms/ui/assets": ["../../packages/ui/src/assets/index.ts"], + "@payloadcms/ui/elements/*": ["../../packages/ui/src/elements/*/index.tsx"], + "@payloadcms/ui/fields/*": ["../../packages/ui/src/fields/*/index.tsx"], + "@payloadcms/ui/forms/*": ["../../packages/ui/src/forms/*/index.tsx"], + "@payloadcms/ui/graphics/*": ["../../packages/ui/src/graphics/*/index.tsx"], + "@payloadcms/ui/hooks/*": ["../../packages/ui/src/hooks/*.ts"], + "@payloadcms/ui/icons/*": ["../../packages/ui/src/icons/*/index.tsx"], + "@payloadcms/ui/providers/*": ["../../packages/ui/src/providers/*/index.tsx"], + "@payloadcms/ui/templates/*": ["../../packages/ui/src/templates/*/index.tsx"], + "@payloadcms/ui/utilities/*": ["../../packages/ui/src/utilities/*.ts"], + "@payloadcms/ui/scss": ["../../packages/ui/src/scss.scss"], + "@payloadcms/ui/scss/app.scss": ["../../packages/ui/src/scss/app.scss"], + "payload/types": ["../../packages/payload/src/exports/types.ts"], + "@payloadcms/next/*": ["../../packages/next/src/*"], + "@payloadcms/next": ["../../packages/next/src/exports/*"] + } + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/test/admin/shared.ts b/test/admin/shared.ts index b08f858cc2..13350be030 100644 --- a/test/admin/shared.ts +++ b/test/admin/shared.ts @@ -1,3 +1,5 @@ +import type { Config } from 'payload' + export const slugSingularLabel = 'Post' export const slugPluralLabel = 'Posts' @@ -35,7 +37,7 @@ export const customCollectionParamViewPathBase = '/custom-param' export const customCollectionParamViewPath = `${customCollectionParamViewPathBase}/:slug` -export const customAdminRoutes = { +export const customAdminRoutes: Config['admin']['routes'] = { inactivity: '/custom-inactivity', logout: '/custom-logout', } diff --git a/test/buildConfigWithDefaults.ts b/test/buildConfigWithDefaults.ts index de7e0cd846..f8ab8037d9 100644 --- a/test/buildConfigWithDefaults.ts +++ b/test/buildConfigWithDefaults.ts @@ -160,9 +160,7 @@ export async function buildConfigWithDefaults( secret: 'TEST_SECRET', sharp, telemetry: false, - ...testConfig, - i18n: { supportedLanguages: { de, @@ -182,6 +180,7 @@ export async function buildConfigWithDefaults( if (!config.admin) { config.admin = {} } + if (config.admin.autoLogin === undefined) { config.admin.autoLogin = process.env.PAYLOAD_PUBLIC_DISABLE_AUTO_LOGIN === 'true' diff --git a/test/dev.js b/test/dev.js index 458a090670..a98c9daa35 100644 --- a/test/dev.js +++ b/test/dev.js @@ -33,15 +33,15 @@ process.env.PAYLOAD_DROP_DATABASE = 'true' const { beforeTest } = await createTestHooks(testSuiteArg) await beforeTest() -const rootDir = getNextJSRootDir(testSuiteArg) +const { rootDir, adminRoute } = getNextJSRootDir(testSuiteArg) // Open the admin if the -o flag is passed if (args.o) { - await open('http://localhost:3000/admin') + await open(`http://localhost:3000${adminRoute}`) } // @ts-expect-error await nextDev({ port: process.env.PORT || 3000, dirname: rootDir }, 'default', rootDir) // fetch the admin url to force a render -fetch(`http://localhost:${process.env.PORT || 3000}/admin`) +fetch(`http://localhost:${process.env.PORT || 3000}${adminRoute}`) diff --git a/test/helpers/adminUrlUtil.ts b/test/helpers/adminUrlUtil.ts index 09e5239d5d..043623fcaf 100644 --- a/test/helpers/adminUrlUtil.ts +++ b/test/helpers/adminUrlUtil.ts @@ -1,3 +1,9 @@ +// IMPORTANT: ensure that imports do not contain React components, etc. as this breaks Playwright tests +// Instead of pointing to the bundled code, which will include React components, use direct import paths +import { formatAdminURL } from '../../packages/ui/src/utilities/formatAdminURL.js' // eslint-disable-line payload/no-relative-monorepo-imports + +import type { Config } from 'payload' + export class AdminUrlUtil { account: string @@ -5,17 +11,53 @@ export class AdminUrlUtil { create: string + entitySlug: string + list: string - constructor(serverURL: string, slug: string) { - this.account = `${serverURL}/admin/account` - this.admin = `${serverURL}/admin` - this.list = `${this.admin}/collections/${slug}` - this.create = `${this.list}/create` + routes: Config['routes'] + + serverURL: string + constructor(serverURL: string, slug: string, routes?: Config['routes']) { + this.routes = { + admin: routes?.admin || '/admin', + } + + this.serverURL = serverURL + + this.entitySlug = slug + + this.admin = formatAdminURL({ + adminRoute: this.routes.admin, + path: '', + serverURL: this.serverURL, + }) + + this.account = formatAdminURL({ + adminRoute: this.routes.admin, + path: '/account', + serverURL: this.serverURL, + }) + + this.list = formatAdminURL({ + adminRoute: this.routes.admin, + path: `/collections/${this.entitySlug}`, + serverURL: this.serverURL, + }) + + this.create = formatAdminURL({ + adminRoute: this.routes.admin, + path: `/collections/${this.entitySlug}/create`, + serverURL: this.serverURL, + }) } collection(slug: string): string { - return `${this.admin}/collections/${slug}` + return formatAdminURL({ + adminRoute: this.routes.admin, + path: `/collections/${slug}`, + serverURL: this.serverURL, + }) } edit(id: number | string): string { @@ -23,6 +65,10 @@ export class AdminUrlUtil { } global(slug: string): string { - return `${this.admin}/globals/${slug}` + return formatAdminURL({ + adminRoute: this.routes.admin, + path: `/globals/${slug}`, + serverURL: this.serverURL, + }) } } diff --git a/test/helpers/getNextJSRootDir.js b/test/helpers/getNextJSRootDir.js index 996e80fc15..8e3e8f287d 100644 --- a/test/helpers/getNextJSRootDir.js +++ b/test/helpers/getNextJSRootDir.js @@ -2,6 +2,8 @@ import fs from 'fs' import { dirname, resolve } from 'path' import { fileURLToPath } from 'url' +import { adminRoute as rootAdminRoute } from '../admin-root/shared.js' + const _filename = fileURLToPath(import.meta.url) const _dirname = dirname(_filename) @@ -17,9 +19,23 @@ export const getNextJSRootDir = (testSuite) => { // Swallow err - no config found } - if (hasNextConfig) return testSuiteDir + let adminRoute = '/admin' + + if (testSuite === 'admin-root') { + adminRoute = rootAdminRoute + } + + if (hasNextConfig) { + return { + rootDir: testSuiteDir, + adminRoute, + } + } // If no next config found in test suite, // return monorepo root dir - return resolve(_dirname, '../../') + return { + rootDir: resolve(_dirname, '../../'), + adminRoute, + } } diff --git a/test/helpers/initPayloadE2ENoConfig.ts b/test/helpers/initPayloadE2ENoConfig.ts index 5015ad32aa..33ecda32ff 100644 --- a/test/helpers/initPayloadE2ENoConfig.ts +++ b/test/helpers/initPayloadE2ENoConfig.ts @@ -39,7 +39,7 @@ export async function initPayloadE2ENoConfig>({ await startMemoryDB() - const dir = getNextJSRootDir(testSuiteName) + const { rootDir } = getNextJSRootDir(testSuiteName) if (prebuild) { await new Promise((res, rej) => { @@ -50,7 +50,7 @@ export async function initPayloadE2ENoConfig>({ env: { PATH: process.env.PATH, NODE_ENV: 'production', - NEXTJS_DIR: dir, + NEXTJS_DIR: rootDir, }, }) @@ -68,7 +68,7 @@ export async function initPayloadE2ENoConfig>({ dev: !prebuild, hostname: 'localhost', port, - dir, + dir: rootDir, }) const handle = app.getRequestHandler() diff --git a/test/live-preview/app/(payload)/custom.scss b/test/live-preview/app/(payload)/custom.scss index ddfc972f49..f557cd4277 100644 --- a/test/live-preview/app/(payload)/custom.scss +++ b/test/live-preview/app/(payload)/custom.scss @@ -1,6 +1,5 @@ #custom-css { font-family: monospace; - background-image: url('/placeholder.png'); } #custom-css::after { diff --git a/test/scripts/reset-tsconfig.js b/test/scripts/reset-tsconfig.js new file mode 100644 index 0000000000..e69de29bb2