feat: admin UI logout extensibility (#1274)

* added Logout documentation

* updated type and schema

* updated logout component, route and inactivityRoute references

* added custom Logout component into test admin instance

* fixed windows path management

* added dotenv usage

* added check on testSuiteDir and provided more meaningful error message

* fixed object destructure

* updated from logout.route to logoutRoute

* extracted getSanitizedLogoutRoutes method

* added unit tests

* updated references

* updated doc

* reviewed casing and added defaults

* updated usage

* restored workers previous value

* fixed config validation

* updated docs and schema

* updated reference to logoutRoute and inactivityRoute

* updated test ref

Co-authored-by: Alberto Maghini (MSC Technology Italia) <alberto.maghini@msc.com>
Co-authored-by: Alberto Maghini <alberto@newesis.com>
This commit is contained in:
Alberto Maghini
2022-11-14 20:55:31 +01:00
committed by GitHub
parent 4d8cc97475
commit a345ef0d31
18 changed files with 233 additions and 119 deletions

View File

@@ -11,8 +11,10 @@ While designing the Payload Admin panel, we determined it should be as minimal a
To swap in your own React component, first, consult the list of available component overrides below. Determine the scope that corresponds to what you are trying to accomplish, and then author your React component accordingly.
<Banner type="success">
<strong>Tip:</strong><br/>
Custom components will automatically be provided with all props that the default component would accept.
<strong>Tip:</strong>
<br />
Custom components will automatically be provided with all props that the
default component would accept.
</Banner>
### Base Component Overrides
@@ -20,8 +22,9 @@ To swap in your own React component, first, consult the list of available compon
You can override a set of admin panel-wide components by providing a component to your base Payload config's `admin.components` property. The following options are available:
| Path | Description |
| --------------------- | -------------|
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`Nav`** | Contains the sidebar and mobile Nav in its entirety. |
| **`logout.Button`** | A custom React component.
| **`BeforeDashboard`** | Array of components to inject into the built-in Dashboard, _before_ the default dashboard contents. |
| **`AfterDashboard`** | Array of components to inject into the built-in Dashboard, _after_ the default dashboard contents. [Demo](https://github.com/payloadcms/payload/tree/master/test/admin/components/AfterDashboard/index.tsx) |
| **`BeforeLogin`** | Array of components to inject into the built-in Login, _before_ the default login form. |
@@ -38,8 +41,9 @@ You can override a set of admin panel-wide components by providing a component t
#### Full example:
`payload.config.js`
```ts
import { buildConfig } from 'payload/config'
import { buildConfig } from "payload/config";
import {
MyCustomNav,
MyCustomLogo,
@@ -47,7 +51,7 @@ import {
MyCustomAccount,
MyCustomDashboard,
MyProvider,
} from './customComponents';
} from "./customComponents";
export default buildConfig({
admin: {
@@ -67,14 +71,14 @@ export default buildConfig({
});
```
*For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/master/test/admin/components).*
_For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/master/test/admin/components)._
### Collections
You can override components on a Collection-by-Collection basis via each Collection's `admin` property.
| Path | Description |
| ---------------- | -------------|
| ---------------- | ------------------------------------------------------------------------------------------------ |
| **`views.Edit`** | Used while a document within this Collection is being edited. |
| **`views.List`** | The `List` view is used to render a paginated, filterable table of Documents in this Collection. |
@@ -83,7 +87,7 @@ You can override components on a Collection-by-Collection basis via each Collect
As with Collections, You can override components on a global-by-global basis via their `admin` property.
| Path | Description |
| ---------------- | -------------|
| ---------------- | --------------------------------------- |
| **`views.Edit`** | Used while this Global is being edited. |
### Fields
@@ -91,14 +95,18 @@ As with Collections, You can override components on a global-by-global basis via
All Payload fields support the ability to swap in your own React components. So, for example, instead of rendering a default Text input, you might need to render a color picker that provides the editor with a custom color picker interface to restrict the data entered to colors only.
<Banner type="success">
<strong>Tip:</strong><br/>
Don't see a built-in field type that you need? Build it! Using a combination of custom validation and custom components, you can override the entirety of how a component functions within the admin panel and effectively create your own field type.
<strong>Tip:</strong>
<br />
Don't see a built-in field type that you need? Build it! Using a combination
of custom validation and custom components, you can override the entirety of
how a component functions within the admin panel and effectively create your
own field type.
</Banner>
**Fields support the following custom components:**
| Component | Description |
| --------------- |------------------------------------------------------------------------------------------------------------------------------|
| ------------ | --------------------------------------------------------------------------------------------------------------------------- |
| **`Filter`** | Override the text input that is presented in the `List` view when a user is filtering documents by the customized field. |
| **`Cell`** | Used in the `List` view's table to represent a table-based preview of the data stored in the field. [More](#cell-component) |
| **`Field`** | Swap out the field itself within all `Edit` views. [More](#field-component) |
@@ -108,7 +116,7 @@ All Payload fields support the ability to swap in your own React components. So,
These are the props that will be passed to your custom Cell to use in your own components.
| Property | Description |
|--------------|-------------------------------------------------------------------|
| ---------------- | ----------------------------------------------------------------- |
| **`field`** | An object that includes the field configuration. |
| **`colIndex`** | A unique number for the column in the list. |
| **`collection`** | An object with the config of the collection that the field is in. |
@@ -118,24 +126,14 @@ These are the props that will be passed to your custom Cell to use in your own c
#### Example
```tsx
import React from 'react';
import './index.scss';
const baseClass = 'custom-cell';
import React from "react";
import "./index.scss";
const baseClass = "custom-cell";
const CustomCell: React.FC<Props> = (props) => {
const {
field,
colIndex,
collection,
cellData,
rowData,
} = props;
const { field, colIndex, collection, cellData, rowData } = props;
return (
<span className={baseClass}>
{ cellData }
</span>
);
return <span className={baseClass}>{cellData}</span>;
};
```
@@ -148,21 +146,28 @@ When writing your own custom components you can make use of a number of hooks to
When swapping out the `Field` component, you'll be responsible for sending and receiving the field's `value` from the form itself. To do so, import the `useField` hook as follows:
```tsx
import { useField } from 'payload/components/forms'
import { useField } from "payload/components/forms";
type Props = { path: string }
type Props = { path: string };
const CustomTextField: React.FC<Props> = ({ path }) => {
// highlight-start
const { value, setValue } = useField<Props>({ path })
const { value, setValue } = useField<Props>({ path });
// highlight-end
return <input onChange={e => setValue(e.target.value)} value={value.path} />
}
return (
<input onChange={(e) => setValue(e.target.value)} value={value.path} />
);
};
```
<Banner type="success">
For more information regarding the hooks that are available to you while you build custom components, including the <strong>useField</strong> hook, <a href="/docs/admin/hooks" style={{color: 'black'}}>click here</a>.
For more information regarding the hooks that are available to you while you
build custom components, including the <strong>useField</strong> hook,{" "}
<a href="/docs/admin/hooks" style={{ color: "black" }}>
click here
</a>
.
</Banner>
## Custom routes
@@ -172,27 +177,30 @@ You can easily add your own custom routes to the Payload Admin panel using the `
**Custom routes support the following properties:**
| Property | Description |
| ----------------- | -------------|
| **`Component`** * | Pass in the component that should be rendered when a user navigates to this route. |
| **`path`** * | React Router `path`. [See the React Router docs](https://v5.reactrouter.com/web/api/Route/path-string-string) for more info. |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| **`Component`** \* | Pass in the component that should be rendered when a user navigates to this route. |
| **`path`** \* | React Router `path`. [See the React Router docs](https://v5.reactrouter.com/web/api/Route/path-string-string) for more info. |
| **`exact`** | React Router `exact` property. [More](https://v5.reactrouter.com/web/api/Route/exact-bool) |
| **`strict`** | React Router `strict` property. [More](https://v5.reactrouter.com/web/api/Route/strict-bool) |
| **`sensitive`** | React Router `sensitive` property. [More](https://v5.reactrouter.com/web/api/Route/sensitive-bool) |
*\* An asterisk denotes that a property is required.*
_\* An asterisk denotes that a property is required._
#### Custom route components
Your custom route components will be given all the props that a React Router `<Route />` typically would receive, as well as two props from Payload:
| Prop | Description |
| ---------------------- | -------------|
| ----------------------- | ---------------------------------------------------------------------------- |
| **`user`** | The currently logged in user. Will be `null` if no user is logged in. |
| **`canAccessAdmin`** * | If the currently logged in user is allowed to access the admin panel or not. |
| **`canAccessAdmin`** \* | If the currently logged in user is allowed to access the admin panel or not. |
<Banner type="warning">
<strong>Note:</strong><br/>
It's up to you to secure your custom routes. If your route requires a user to be logged in or to have certain access rights, you should handle that within your route component yourself.
<strong>Note:</strong>
<br />
It's up to you to secure your custom routes. If your route requires a user to
be logged in or to have certain access rights, you should handle that within
your route component yourself.
</Banner>
#### Example
@@ -208,7 +216,10 @@ To see how to pass in your custom views to create custom routes of your own, tak
As your admin customizations gets more complex you may want to share state between fields or other components. You can add custom providers to do add your own context to any Payload app for use in other custom components within the admin panel. Within your config add `admin.components.providers`, these can be used to share context or provide other custom functionality. Read the [React context](https://reactjs.org/docs/context.html) docs to learn more.
<Banner type="warning"><strong>Reminder:</strong> Don't forget to pass the **children** prop through the provider component for the admin UI to show</Banner>
<Banner type="warning">
<strong>Reminder:</strong> Don't forget to pass the **children** prop through
the provider component for the admin UI to show
</Banner>
### Styling Custom Components
@@ -225,7 +236,7 @@ To make use of Payload SCSS variables / mixins to use directly in your own compo
In any custom component you can get the selected locale with the `useLocale` hook. Here is a simple example:
```tsx
import { useLocale } from 'payload/components/utilities';
import { useLocale } from "payload/components/utilities";
const Greeting: React.FC = () => {
// highlight-start
@@ -233,12 +244,10 @@ const Greeting: React.FC = () => {
// highlight-end
const trans = {
en: 'Hello',
es: 'Hola',
en: "Hello",
es: "Hola",
};
return (
<span> { trans[locale] } </span>
);
return <span> {trans[locale]} </span>;
};
```

View File

@@ -23,7 +23,7 @@ The Payload Admin panel is built with Webpack, code-split, highly performant (ev
All options for the Admin panel are defined in your base Payload config file.
| Option | Description |
| -------------------- | -------------|
| --------------------- | -------------|
| `user` | The `slug` of a Collection that you want be used to log in to the Admin dashboard. [More](/docs/admin/overview#the-admin-user-collection) |
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `ogImage`, and `favicon`. |
| `disable` | If set to `true`, the entire Admin panel will be disabled. |
@@ -33,7 +33,9 @@ All options for the Admin panel are defined in your base Payload config file.
| `dateFormat` | Global date format that will be used for all dates in the Admin panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| `components` | Component overrides that affect the entirety of the Admin panel. [More](/docs/admin/components) |
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) |
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) | |
| **`logoutRoute`** | The route for the `logout` page. |
| **`inactivityRoute`** | The route for the `logout` inactivity page. |
### The Admin User Collection

View File

@@ -32,9 +32,12 @@ const Routes = () => {
const canAccessAdmin = permissions?.canAccessAdmin;
const config = useConfig();
const {
admin: {
user: userSlug,
logoutRoute,
inactivityRoute: logoutInactivityRoute,
components: {
routes: customRoutes,
} = {},
@@ -42,7 +45,8 @@ const Routes = () => {
routes,
collections,
globals,
} = useConfig();
} = config;
const userCollection = collections.find(({ slug }) => slug === userSlug);
@@ -103,10 +107,10 @@ const Routes = () => {
<Route path={`${match.url}/login`}>
<Login />
</Route>
<Route path={`${match.url}/logout`}>
<Route path={`${match.url}${logoutRoute}`}>
<Logout />
</Route>
<Route path={`${match.url}/logout-inactivity`}>
<Route path={`${match.url}${logoutInactivityRoute}`}>
<Logout inactivity />
</Route>

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useConfig } from '../../utilities/Config';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import LogOut from '../../icons/LogOut';
const baseClass = 'nav';
const DefaultLogout = () => {
const config = useConfig();
const {
routes: { admin },
admin: {
logoutRoute,
components: { logout }
}
} = config;
return (
<Link to={`${admin}${logoutRoute}`} className={`${baseClass}__log-out`}>
<LogOut />
</Link>
);
};
const Logout: React.FC = () => {
const {
admin: {
components: {
logout: { Button: CustomLogout } = {
Button: undefined,
},
} = {},
} = {},
} = useConfig();
return (
<RenderCustomComponent
CustomComponent={CustomLogout}
DefaultComponent={DefaultLogout}
/>
);
};
export default Logout;

View File

@@ -4,7 +4,6 @@ import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import Chevron from '../../icons/Chevron';
import LogOut from '../../icons/LogOut';
import Menu from '../../icons/Menu';
import CloseMenu from '../../icons/CloseMenu';
import Icon from '../../graphics/Icon';
@@ -14,6 +13,7 @@ import NavGroup from '../NavGroup';
import { groupNavItems, Group, EntityToGroup, EntityType } from '../../../utilities/groupNavItems';
import './index.scss';
import Logout from '../Logout';
const baseClass = 'nav';
@@ -31,7 +31,7 @@ const DefaultNav = () => {
admin: {
components: {
beforeNavLinks,
afterNavLinks,
afterNavLinks
},
},
} = useConfig();
@@ -137,12 +137,7 @@ const DefaultNav = () => {
>
<Account />
</Link>
<Link
to={`${admin}/logout`}
className={`${baseClass}__log-out`}
>
<LogOut />
</Link>
<Logout/>
</div>
</nav>
</div>

View File

@@ -15,7 +15,13 @@ const modalSlug = 'stay-logged-in';
const StayLoggedInModal: React.FC<Props> = (props) => {
const { refreshCookie } = props;
const history = useHistory();
const { routes: { admin } } = useConfig();
const config = useConfig();
const {
routes: { admin },
admin: {
logoutRoute
}
} = config;
const { toggleModal } = useModal();
return (
@@ -31,7 +37,7 @@ const StayLoggedInModal: React.FC<Props> = (props) => {
buttonStyle="secondary"
onClick={() => {
toggleModal(modalSlug);
history.push(`${admin}/logout`);
history.push(`${admin}${logoutRoute}`);
}}
>
Log out

View File

@@ -25,6 +25,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const {
admin: {
user: userSlug,
inactivityRoute: logoutInactivityRoute,
},
serverURL,
routes: {
@@ -57,7 +58,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setUser(json.user);
} else {
setUser(null);
push(`${admin}/logout-inactivity`);
push(`${admin}${logoutInactivityRoute}`);
}
}, 1000);
}
@@ -145,7 +146,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (remainingTime > 0) {
forceLogOut = setTimeout(() => {
setUser(null);
push(`${admin}/logout-inactivity`);
push(`${admin}${logoutInactivityRoute}`);
closeAllModals();
}, Math.min(remainingTime * 1000, maxTimeoutTime));
}

View File

@@ -18,12 +18,15 @@ const baseClass = 'login';
const Login: React.FC = () => {
const history = useHistory();
const { user, setToken } = useAuth();
const config = useConfig();
const {
admin: {
user: userSlug,
logoutRoute,
components: {
beforeLogin,
afterLogin,
logout
} = {},
},
serverURL,
@@ -32,7 +35,7 @@ const Login: React.FC = () => {
api,
},
collections,
} = useConfig();
} = config;
const collection = collections.find(({ slug }) => slug === userSlug);
@@ -56,7 +59,7 @@ const Login: React.FC = () => {
<p>
To log in with another user, you should
{' '}
<Link to={`${admin}/logout`}>log out</Link>
<Link to={`${admin}${logoutRoute}`}>log out</Link>
{' '}
first.
</p>

View File

@@ -17,7 +17,8 @@ import HiddenInput from '../../forms/field-types/HiddenInput';
const baseClass = 'reset-password';
const ResetPassword: React.FC = () => {
const { admin: { user: userSlug }, serverURL, routes: { admin, api } } = useConfig();
const config = useConfig();
const { admin: { user: userSlug, logoutRoute }, serverURL, routes: { admin, api } } = config;
const { token } = useParams<{ token?: string }>();
const history = useHistory();
const { user, setToken } = useAuth();
@@ -43,7 +44,7 @@ const ResetPassword: React.FC = () => {
<p>
To log in with another user, you should
{' '}
<Link to={`${admin}/logout`}>log out</Link>
<Link to={`${admin}${logoutRoute}`}>log out</Link>
{' '}
first.
</p>

View File

@@ -5,8 +5,13 @@ import Meta from '../../utilities/Meta';
import MinimalTemplate from '../../templates/Minimal';
const Unauthorized: React.FC = () => {
const { routes: { admin } } = useConfig();
const config = useConfig();
const {
routes: { admin },
admin: {
logoutRoute
},
} = config;
return (
<MinimalTemplate className="unauthorized">
<Meta
@@ -19,7 +24,7 @@ const Unauthorized: React.FC = () => {
<br />
<Button
el="link"
to={`${admin}/logout`}
to={`${admin}${logoutRoute}`}
>
Log out
</Button>

View File

@@ -19,7 +19,8 @@ export const defaults: Config = {
disable: false,
indexHTML: path.resolve(__dirname, '../admin/index.html'),
avatar: 'default',
components: {},
logoutRoute: '/logout',
inactivityRoute: '/logout-inactivity',
css: path.resolve(__dirname, '../admin/scss/custom.css'),
dateFormat: 'MMMM do yyyy, h:mm a',
},

View File

@@ -1,3 +1,4 @@
import { JSONDefinition } from 'graphql-scalars';
import joi from 'joi';
const component = joi.alternatives().try(
@@ -62,6 +63,8 @@ export default joi.object({
joi.string(),
component,
),
logoutRoute: joi.string(),
inactivityRoute: joi.string(),
components: joi.object()
.keys({
routes: joi.array()
@@ -82,6 +85,9 @@ export default joi.object({
beforeNavLinks: joi.array().items(component),
afterNavLinks: joi.array().items(component),
Nav: component,
logout: joi.object({
Button: component,
}),
views: joi.object({
Dashboard: component,
Account: component,

View File

@@ -154,6 +154,8 @@ export type Config = {
css?: string
dateFormat?: string
avatar?: 'default' | 'gravatar' | React.ComponentType<any>,
logoutRoute?: string,
inactivityRoute?: string,
components?: {
routes?: AdminRoute[]
providers?: React.ComponentType<{ children: React.ReactNode }>[]
@@ -164,6 +166,9 @@ export type Config = {
beforeNavLinks?: React.ComponentType<any>[]
afterNavLinks?: React.ComponentType<any>[]
Nav?: React.ComponentType<any>
logout?: {
Button?: React.ComponentType<any>,
}
graphics?: {
Icon?: React.ComponentType<any>
Logo?: React.ComponentType<any>

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { useConfig } from '../../../../src/admin/components/utilities/Config';
import LogOut from '../../../../src/admin/components/icons/LogOut';
const Logout: React.FC = () => {
const config = useConfig();
const {
routes: {
admin,
},
admin: {
logoutRoute
},
} = config;
return (
<a href={`${admin}${logoutRoute}#custom`}>
<LogOut />
</a>
);
};
export default Logout;

View File

@@ -8,6 +8,7 @@ import CustomDefaultRoute from './components/views/CustomDefault';
import BeforeLogin from './components/BeforeLogin';
import AfterNavLinks from './components/AfterNavLinks';
import { slug, globalSlug } from './shared';
import Logout from './components/Logout';
export interface Post {
id: string;
@@ -38,6 +39,9 @@ export default buildConfig({
beforeLogin: [
BeforeLogin,
],
logout: {
Button: Logout,
},
afterNavLinks: [
AfterNavLinks,
],

View File

@@ -15,6 +15,11 @@ require('@babel/register')({
const [testSuiteDir] = process.argv.slice(2);
if (!testSuiteDir) {
console.error('ERROR: You must provide an argument for "testSuiteDir"');
process.exit(1);
}
const configPath = path.resolve(__dirname, testSuiteDir, 'config.ts');
if (!fs.existsSync(configPath)) {

View File

@@ -2,8 +2,8 @@ import express from 'express';
import { v4 as uuid } from 'uuid';
import payload from '../src';
require("dotenv").config();
const expressApp = express();
const init = async () => {
await payload.initAsync({
secret: uuid(),

View File

@@ -15,7 +15,7 @@ const suiteName = args[0];
// Run all
if (!suiteName || args[0].startsWith('-')) {
const bail = args.includes('--bail');
const files = glob.sync(`${path.resolve(__dirname)}/**/*e2e.spec.ts`);
const files = glob.sync(`${path.resolve(__dirname).replace(/\\/g, '/')}/**/*e2e.spec.ts`);
console.log(`\n\nExecuting all ${files.length} E2E tests...`);
files.forEach((file) => {
clearWebpackCache();