Merge pull request #11268 from strapi/v4/migrate-plugin-documentation

[v4] migrate the documentation plugin
This commit is contained in:
Alexandre BODIN 2021-10-21 09:11:06 +02:00 committed by GitHub
commit a639e92022
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2182 additions and 2917 deletions

View File

@ -14,6 +14,13 @@ module.exports = () => ({
},
},
},
documentation: {
config: {
info: {
version: '2.0.0',
},
},
},
myplugin: {
enabled: true,
resolve: `./src/plugins/myplugin`, // From the root of the project

View File

@ -46,7 +46,7 @@
"axios": "^0.21.1",
"babel-loader": "8.2.2",
"babel-plugin-styled-components": "1.12.0",
"bcryptjs": "^2.4.3",
"bcryptjs": "2.4.3",
"chalk": "^4.1.1",
"chokidar": "^3.5.1",
"classnames": "^2.3.1",

View File

@ -193,13 +193,13 @@ describe('Role CRUD End to End', () => {
"action": "plugin::documentation.settings.regenerate",
"displayName": "Regenerate",
"plugin": "documentation",
"subCategory": "settings",
"subCategory": "general",
},
Object {
"action": "plugin::documentation.settings.update",
"displayName": "Update and delete",
"plugin": "documentation",
"subCategory": "settings",
"subCategory": "general",
},
Object {
"action": "plugin::upload.assets.copy-link",
@ -407,6 +407,12 @@ describe('Role CRUD End to End', () => {
"displayName": "Update",
"subCategory": "general",
},
Object {
"action": "plugin::documentation.settings.read",
"category": "documentation",
"displayName": "Access the documentation settings page",
"subCategory": "general",
},
Object {
"action": "plugin::email.settings.read",
"category": "email",
@ -666,13 +672,13 @@ describe('Role CRUD End to End', () => {
"action": "plugin::documentation.settings.regenerate",
"displayName": "Regenerate",
"plugin": "documentation",
"subCategory": "settings",
"subCategory": "general",
},
Object {
"action": "plugin::documentation.settings.update",
"displayName": "Update and delete",
"plugin": "documentation",
"subCategory": "settings",
"subCategory": "general",
},
Object {
"action": "plugin::upload.assets.copy-link",
@ -892,6 +898,12 @@ describe('Role CRUD End to End', () => {
"displayName": "Update",
"subCategory": "general",
},
Object {
"action": "plugin::documentation.settings.read",
"category": "documentation",
"displayName": "Access the documentation settings page",
"subCategory": "general",
},
Object {
"action": "plugin::email.settings.read",
"category": "email",

View File

@ -41,7 +41,6 @@ module.exports = (dir, initialConfig = {}) => {
const rootConfig = {
launchedAt: Date.now(),
appPath: dir,
paths: CONFIG_PATHS,
serveAdminPanel,
autoReload,

View File

@ -34,8 +34,10 @@ describe('Session middleware', () => {
},
use: jest.fn(),
},
dirs: {
root: __dirname,
},
config: {
appPath: __dirname,
database: {
connections: {
mysql: {},

View File

@ -9,7 +9,7 @@ const session = require('koa-session');
*/
module.exports = strapi => {
const requireStore = store => {
return require(path.resolve(strapi.config.appPath, 'node_modules', 'koa-' + store));
return require(path.resolve(strapi.dirs.root, 'node_modules', 'koa-' + store));
};
const defineStore = session => {

View File

@ -1,26 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
margin-bottom: 28px;
background: #ffffff;
padding: 22px 28px 18px;
border-radius: 2px;
box-shadow: 0 2px 4px #e3e9f3;
-webkit-font-smoothing: antialiased;
`;
const Title = styled.div`
padding-top: 0px;
line-height: 18px;
> span {
font-weight: 600;
color: #333740;
font-size: 18px;
}
> p {
color: #787e8f;
font-size: 13px;
}
`;
export { Wrapper, Title };

View File

@ -1,39 +0,0 @@
/**
*
* Block
*/
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Wrapper, Title } from './components';
const renderMsg = msg => <p>{msg}</p>;
const Block = ({ children, description, style, title }) => (
<div className="col-md-12">
<Wrapper style={style}>
<Title>
<FormattedMessage id={title} />
<FormattedMessage id={description}>{renderMsg}</FormattedMessage>
</Title>
{children}
</Wrapper>
</div>
);
Block.defaultProps = {
children: null,
description: 'app.utils.defaultMessage',
style: {},
title: 'app.utils.defaultMessage',
};
Block.propTypes = {
children: PropTypes.any,
description: PropTypes.string,
style: PropTypes.object,
title: PropTypes.string,
};
export default Block;

View File

@ -1,36 +0,0 @@
import React from 'react';
import { auth, InputsIndex as Input, useNotification } from '@strapi/helper-plugin';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import getTrad from '../../utils/getTrad';
const Copy = () => {
const toggleNotification = useNotification();
const handleCopy = () => {
toggleNotification({
type: 'info',
message: { id: getTrad('containers.HomePage.copied') },
});
};
return (
<CopyToClipboard text={auth.getToken()} onCopy={handleCopy}>
<div className="row" style={{ zIndex: '99' }}>
<Input
style={{ zIndex: '9', cursor: 'pointer' }}
inputStyle={{ cursor: 'pointer' }}
name="jwtToken"
value={auth.getToken()}
type="string"
onChange={() => {}}
label={{ id: getTrad('containers.HomePage.form.jwtToken') }}
inputDescription={{
id: getTrad('containers.HomePage.form.jwtToken.description'),
}}
/>
</div>
</CopyToClipboard>
);
};
export default Copy;

View File

@ -0,0 +1,14 @@
import styled from 'styled-components';
import { FieldAction } from '@strapi/parts/Field';
const FieldActionWrapper = styled(FieldAction)`
svg {
height: 1rem;
width: 1rem;
path {
fill: ${({ theme }) => theme.colors.neutral600};
}
}
`;
export default FieldActionWrapper;

View File

@ -1,72 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Header as BaseHeader } from '@buffetjs/custom';
import { Button } from '@buffetjs/core';
import { CheckPermissions } from '@strapi/helper-plugin';
import openWithNewTab from '../../utils/openWithNewTab';
import pluginPermissions from '../../permissions';
import getTrad from '../../utils/getTrad';
const Header = ({ currentDocVersion, docPrefixURL }) => {
const { formatMessage } = useIntl();
const headerActions = [
{
color: 'none',
label: formatMessage({
id: getTrad('containers.HomePage.Button.open'),
defaultMessage: 'Open the documentation',
}),
className: 'buttonOutline',
onClick: () => {
const slash = docPrefixURL.startsWith('/') ? '' : '/';
return openWithNewTab(`${slash}${docPrefixURL}/v${currentDocVersion}`);
},
type: 'button',
key: 'button-open',
Component: props => (
<CheckPermissions permissions={pluginPermissions.open}>
<Button {...props} />
</CheckPermissions>
),
},
{
label: formatMessage({
id: getTrad('containers.HomePage.Button.update'),
defaultMessage: 'Update',
}),
color: 'success',
type: 'submit',
key: 'button-submit',
Component: props => (
<CheckPermissions permissions={pluginPermissions.update}>
<Button {...props} />
</CheckPermissions>
),
},
];
return (
<BaseHeader
actions={headerActions}
content={formatMessage({
id: getTrad('containers.HomePage.PluginHeader.description'),
defaultMessage: 'Configure the documentation plugin',
})}
title={{
label: formatMessage({
id: getTrad('containers.HomePage.PluginHeader.title'),
defaultMessage: 'Documentation - Settings',
}),
}}
/>
);
};
Header.propTypes = {
currentDocVersion: PropTypes.string.isRequired,
docPrefixURL: PropTypes.string.isRequired,
};
export default Header;

View File

@ -1,67 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { CheckPermissions } from '@strapi/helper-plugin';
import pluginPermissions from '../../permissions';
import openWithNewTab from '../../utils/openWithNewTab';
import { StyledButton } from './components';
const ButtonContainer = ({ currentDocVersion, isHeader, onClick, onClickDelete, version }) => {
if (isHeader) {
return <div />;
}
const handleClickGenerate = e => {
e.preventDefault();
onClick(version);
};
const handleDelete = e => {
e.preventDefault();
onClickDelete(version);
};
return (
<div>
<StyledButton
type="openDocumentation"
onClick={e => {
e.preventDefault();
openWithNewTab(`/documentation/v${version}`);
}}
>
<FormattedMessage id="documentation.components.Row.open" />
</StyledButton>
<CheckPermissions permissions={pluginPermissions.regenerate}>
<StyledButton
// FIXME when new DS ready
type="generateDocumentation"
onClick={handleClickGenerate}
>
<FormattedMessage id="documentation.components.Row.regenerate" />
</StyledButton>
</CheckPermissions>
<CheckPermissions permissions={pluginPermissions.update}>
<StyledButton type={version === currentDocVersion ? '' : 'trash'} onClick={handleDelete} />
</CheckPermissions>
</div>
);
};
ButtonContainer.defaultProps = {
currentDocVersion: '1.0.0',
isHeader: false,
onClick: () => {},
onClickDelete: () => {},
version: '',
};
ButtonContainer.propTypes = {
currentDocVersion: PropTypes.string,
isHeader: PropTypes.bool,
onClick: PropTypes.func,
onClickDelete: PropTypes.func,
version: PropTypes.string,
};
export default ButtonContainer;

View File

@ -1,83 +0,0 @@
import styled, { css } from 'styled-components';
import { Button } from '@strapi/helper-plugin';
const Wrapper = styled.div`
height: 54px;
display: flex;
line-height: 53px;
margin: 0 28px 0 36px;
justify-content: space-between;
border-bottom: 1px solid rgba(14, 22, 34, 0.04);
font-size: 13px;
color: #333740;
> div:first-child {
flex: 0 0 70px;
font-weight: 500;
}
> div:nth-child(2) {
flex: 0 0 160px;
text-align: left;
font-weight: 500 !important;
}
> div:last-child {
flex: 0 0 400px;
align-self: center;
text-align: right;
}
-webkit-font-smoothing: antialiased;
`;
const StyledButton = styled(Button)`
height: 26px;
margin: 0;
padding: 0 15px;
line-height: initial;
font-size: 13px;
font-weight: 500;
${({ type }) => {
if (type === 'openDocumentation') {
return css`
margin-right: 10px;
border: 1px solid #dfe0e1;
&:before {
margin-right: 10px;
content: '\f08e';
font-family: 'FontAwesome';
font-size: 10px;
}
`;
}
if (type === 'generateDocumentation') {
return css`
background: #e6f0fb;
border: 1px solid #aed4fb;
color: #007eff;
&:before {
margin-right: 10px;
content: '\f021';
font-family: 'FontAwesome';
font-size: 10px;
}
`;
}
if (type === 'trash') {
return css`
margin-left: 25px;
font-weight: 400;
&:before {
margin-right: 10px;
content: '\f2ed';
font-family: 'FontAwesome';
font-size: 12 px;
}
`;
}
return css`
margin-left: 45px;
font-weight: 400;
`;
}}
`;
export { Wrapper, StyledButton };

View File

@ -1,51 +0,0 @@
/**
* Row
*/
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import ButtonContainer from './ButtonContainer';
import { Wrapper } from './components';
function Row({ currentDocVersion, data, isHeader, onClickDelete, onUpdateDoc }) {
const { version, generatedDate } = data;
return (
<Wrapper>
<div>{version}</div>
<div>
{isHeader ? (
<FormattedMessage id="documentation.components.Row.generatedDate" />
) : (
<span>{generatedDate}</span>
)}
</div>
<ButtonContainer
currentDocVersion={currentDocVersion}
isHeader={isHeader}
version={version}
onClickDelete={onClickDelete}
onClick={onUpdateDoc}
/>
</Wrapper>
);
}
Row.defaultProps = {
currentDocVersion: '1.0.0',
data: {},
isHeader: false,
onClickDelete: () => {},
onUpdateDoc: () => {},
};
Row.propTypes = {
currentDocVersion: PropTypes.string,
data: PropTypes.object,
isHeader: PropTypes.bool,
onClickDelete: PropTypes.func,
onUpdateDoc: PropTypes.func,
};
export default Row;

View File

@ -25,7 +25,9 @@ export default {
},
permissions: pluginPermissions.main,
Component: async () => {
const component = await import(/* webpackChunkName: "documentation-page" */ './pages/App');
const component = await import(
/* webpackChunkName: "documentation-page" */ './pages/PluginPage'
);
return component;
},
@ -41,7 +43,24 @@ export default {
pluginLogo,
});
},
bootstrap() {},
bootstrap(app) {
app.addSettingsLink('global', {
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Documentation',
},
id: 'documentation',
to: `/settings/${pluginId}`,
Component: async () => {
const component = await import(
/* webpackChunkName: "documentation-settings" */ './pages/SettingsPage'
);
return component;
},
permissions: pluginPermissions.main,
});
},
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map(locale => {

View File

@ -1,52 +0,0 @@
/**
*
* This component is the skeleton around the actual pages, and should only
* contain code that should be seen on all pages. (e.g. navigation bar)
*
*/
import React from 'react';
import { useIntl } from 'react-intl';
import { CheckPagePermissions, NoContent } from '@strapi/helper-plugin';
import { Layout, HeaderLayout, ContentLayout } from '@strapi/parts/Layout';
import { Main } from '@strapi/parts/Main';
import pluginPermissions from '../../permissions';
import { getTrad } from '../../utils';
// import HomePage from '../HomePage';
const ComingSoon = () => {
const { formatMessage } = useIntl();
return (
<Layout>
<Main>
<HeaderLayout
title={formatMessage({
id: getTrad('plugin.name'),
defaultMessage: 'Documentation',
})}
/>
<ContentLayout>
<NoContent
content={{
id: getTrad('coming.soon'),
defaultMessage:
'This content is currently under construction and will be back in a few weeks!',
}}
/>
</ContentLayout>
</Main>
</Layout>
);
};
function App() {
return (
<CheckPagePermissions permissions={pluginPermissions.main}>
{/* <HomePage /> */}
<ComingSoon />
</CheckPagePermissions>
);
}
export default App;

View File

@ -1,59 +0,0 @@
import styled from 'styled-components';
const ContainerFluid = styled.div`
padding: 18px 30px;
> div:first-child {
max-height: 33px;
}
.buttonOutline {
height: 30px;
padding: 0 15px;
border: 1px solid #dfe0e1;
font-weight: 500;
font-size: 13px;
&:before {
margin-right: 10px;
content: '\f08e';
font-family: 'FontAwesome';
font-size: 10px;
}
}
`;
const StyledRow = styled.div`
padding-top: 11px;
> div:last-child {
> div {
padding-bottom: 0 !important;
}
}
> div:first-child {
> div {
margin-bottom: 22px !important;
}
}
`;
const VersionWrapper = styled.div`
margin-top: -9px;
margin-left: -28px;
margin-right: -28px;
> div:first-child {
height: 18px;
line-height: 16px;
border-bottom: 0;
> div:nth-child(2) {
font-weight: 600;
color: #9ea7b8;
}
}
> div:last-child {
> div {
border-bottom: none;
}
}
`;
export { ContainerFluid, StyledRow, VersionWrapper };

View File

@ -1,175 +0,0 @@
/*
*
* HomePage
*
*/
import React, { useEffect, useState } from 'react';
import flatten from 'lodash/flatten';
import {
PopUpWarning,
LoadingIndicatorPage,
CheckPermissions,
SizedInput,
getYupInnerErrors,
} from '@strapi/helper-plugin';
import pluginPermissions from '../../permissions';
import getTrad from '../../utils/getTrad';
import Block from '../../components/Block';
import Copy from '../../components/Copy';
import Header from '../../components/Header';
import Row from '../../components/Row';
import { ContainerFluid, StyledRow, VersionWrapper } from './components';
import useHomePage from './useHomePage';
import schema from './utils/schema';
const HomePage = () => {
const [versionToDelete, setVersionToDelete] = useState(null);
const [{ formErrors, modifiedData }, setState] = useState({
formErrors: null,
modifiedData: {
restrictedAccess: true,
password: '',
},
});
const { data, isLoading, deleteMutation, submitMutation, regenerateDocMutation } = useHomePage();
useEffect(() => {
if (data?.form) {
const initialData = flatten(data.form).reduce((acc, current) => {
acc[current.name] = current.value;
return acc;
}, {});
setState({ formErrors: null, modifiedData: initialData });
}
}, [data]);
const handleChange = ({ target: { name, value } }) => {
setState(prev => ({
...prev,
modifiedData: {
...prev.modifiedData,
[name]: value,
},
}));
};
const handleDeleteDoc = version => {
setVersionToDelete(version);
};
const handleConfirmDeleteDoc = () => {
deleteMutation.mutate({ prefix: data.prefix, version: versionToDelete });
toggleModal();
};
const handleSubmit = async e => {
e.preventDefault();
try {
await schema.validate(modifiedData, { abortEarly: false });
setState(prev => ({ ...prev, formErrors: null }));
submitMutation.mutate({ body: modifiedData, prefix: data.prefix });
} catch (err) {
const errors = getYupInnerErrors(err);
setState(prev => ({ ...prev, formErrors: errors }));
}
};
const handleUpdateDoc = version => {
regenerateDocMutation.mutate({ version, prefix: data.prefix });
};
const toggleModal = () => {
setVersionToDelete(null);
};
console.log(data);
if (isLoading) {
return <LoadingIndicatorPage />;
}
// FIXME
if (!data) {
return null;
}
return (
<ContainerFluid className="container-fluid">
<PopUpWarning
isOpen={versionToDelete !== null}
toggleModal={toggleModal}
content={{
title: 'components.popUpWarning.title',
message: getTrad('containers.HomePage.PopUpWarning.message'),
cancel: 'app.components.Button.cancel',
confirm: getTrad('containers.HomePage.PopUpWarning.confirm'),
}}
popUpWarningType="danger"
onConfirm={handleConfirmDeleteDoc}
/>
<form onSubmit={handleSubmit}>
<Header currentDocVersion={data.currentVersion} docPrefixURL={data.prefix} />
<StyledRow className="row">
<Block>
<Copy />
</Block>
<CheckPermissions permissions={pluginPermissions.update}>
<Block>
<div className="row">
<SizedInput
description={getTrad(
'containers.HomePage.form.restrictedAccess.inputDescription'
)}
label={getTrad('containers.HomePage.form.restrictedAccess')}
name="restrictedAccess"
onChange={handleChange}
size={{ xs: 6 }}
type="bool"
value={modifiedData.restrictedAccess}
/>
{modifiedData.restrictedAccess && (
<SizedInput
description={getTrad('containers.HomePage.form.password.inputDescription')}
label={getTrad('containers.HomePage.form.password')}
error={formErrors?.password}
name="password"
onChange={handleChange}
size={{ xs: 6 }}
type="password"
value={modifiedData.password}
/>
)}
</div>
</Block>
</CheckPermissions>
<Block title={getTrad('containers.HomePage.Block.title')}>
<VersionWrapper>
<Row isHeader />
{data.docVersions.map(doc => {
return (
<Row
key={doc.generatedDate}
data={doc}
currentDocVersion={data.currentVersion}
onClickDelete={handleDeleteDoc}
onUpdateDoc={handleUpdateDoc}
/>
);
})}
</VersionWrapper>
</Block>
</StyledRow>
</form>
</ContainerFluid>
);
};
export default HomePage;

View File

@ -1,56 +0,0 @@
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { useNotification } from '@strapi/helper-plugin';
import { fetchData, deleteDoc, regenerateDoc, submit } from './utils/api';
import getTrad from '../../utils/getTrad';
const useHomePage = () => {
const queryClient = useQueryClient();
const toggleNotification = useNotification();
const { isLoading, data } = useQuery('get-documentation', () => fetchData(toggleNotification));
const handleError = err => {
toggleNotification({
type: 'warning',
message: err.response.payload.message,
});
};
const deleteMutation = useMutation(deleteDoc, {
onSuccess: async () => {
await queryClient.invalidateQueries('get-documentation');
toggleNotification({
type: 'info',
message: { id: getTrad('notification.delete.success') },
});
},
onError: handleError,
});
const submitMutation = useMutation(submit, {
onSuccess: () => {
queryClient.invalidateQueries('get-documentation');
toggleNotification({
type: 'success',
message: { id: getTrad('notification.update.success') },
});
},
onError: handleError,
});
const regenerateDocMutation = useMutation(regenerateDoc, {
onSuccess: () => {
queryClient.invalidateQueries('get-documentation');
toggleNotification({
type: 'info',
message: { id: getTrad('notification.generate.success') },
});
},
onError: handleError,
});
return { data, isLoading, deleteMutation, submitMutation, regenerateDocMutation };
};
export default useHomePage;

View File

@ -0,0 +1,195 @@
/**
*
* This component is the skeleton around the actual pages, and should only
* contain code that should be seen on all pages. (e.g. navigation bar)
*
*/
import React, { useState } from 'react';
import { useIntl } from 'react-intl';
import {
CheckPermissions,
ConfirmDialog,
LoadingIndicatorPage,
stopPropagation,
EmptyStateLayout,
useFocusWhenNavigate,
} from '@strapi/helper-plugin';
import { Button } from '@strapi/parts/Button';
import { Layout, HeaderLayout, ContentLayout } from '@strapi/parts/Layout';
import { Main } from '@strapi/parts/Main';
import { IconButton } from '@strapi/parts/IconButton';
import { Text, TableLabel } from '@strapi/parts/Text';
import { Row } from '@strapi/parts/Row';
import { Table, Tr, Thead, Th, Tbody, Td } from '@strapi/parts/Table';
import DeleteIcon from '@strapi/icons/DeleteIcon';
import Show from '@strapi/icons/Show';
import Reload from '@strapi/icons/Reload';
import permissions from '../../permissions';
import { getTrad } from '../../utils';
import openWithNewTab from '../../utils/openWithNewTab';
import useReactQuery from '../utils/useReactQuery';
const PluginPage = () => {
useFocusWhenNavigate();
const { formatMessage } = useIntl();
const { data, isLoading, deleteMutation, regenerateDocMutation } = useReactQuery();
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [isConfirmButtonLoading, setIsConfirmButtonLoading] = useState(false);
const [versionToDelete, setVersionToDelete] = useState();
const colCount = 4;
const rowCount = (data?.docVersions?.length || 0) + 1;
const openDocVersion = () => {
const slash = data?.prefix.startsWith('/') ? '' : '/';
openWithNewTab(`${slash}${data?.prefix}/v${data?.currentVersion}`);
};
const handleRegenerateDoc = version => {
regenerateDocMutation.mutate({ version, prefix: data?.prefix });
};
const handleShowConfirmDelete = () => {
setShowConfirmDelete(!showConfirmDelete);
};
const handleConfirmDelete = async () => {
setIsConfirmButtonLoading(true);
await deleteMutation.mutateAsync({ prefix: data?.prefix, version: versionToDelete });
setShowConfirmDelete(!showConfirmDelete);
setIsConfirmButtonLoading(false);
};
const handleClickDelete = version => {
setVersionToDelete(version);
setShowConfirmDelete(!showConfirmDelete);
};
return (
<Layout>
<Main>
<HeaderLayout
title={formatMessage({
id: getTrad('plugin.name'),
defaultMessage: 'Documentation',
})}
subtitle={formatMessage({
id: getTrad('pages.PluginPage.header.description'),
defaultMessage: 'Configure the documentation plugin',
})}
primaryAction={
// eslint-disable-next-line
<CheckPermissions permissions={permissions.open}>
<Button onClick={openDocVersion} startIcon={<Show />}>
{formatMessage({
id: getTrad('pages.PluginPage.Button.open'),
defaultMessage: 'Open Documentation',
})}
</Button>
</CheckPermissions>
}
/>
<ContentLayout>
{isLoading && <LoadingIndicatorPage>Plugin is loading</LoadingIndicatorPage>}
{data?.docVersions.length ? (
<Table colCount={colCount} rowCount={rowCount}>
<Thead>
<Tr>
<Th>
<TableLabel textColor="neutral600">
{formatMessage({
id: getTrad('pages.PluginPage.table.version'),
defaultMessage: 'Version',
})}
</TableLabel>
</Th>
<Th>
<TableLabel textColor="neutral600">
{formatMessage({
id: getTrad('pages.PluginPage.table.generated'),
defaultMessage: 'Last Generated',
})}
</TableLabel>
</Th>
</Tr>
</Thead>
<Tbody>
{data.docVersions
.sort((a, b) => (a.generatedDate < b.generatedDate ? 1 : -1))
.map(doc => (
<Tr key={doc.version}>
<Td width="50%">
<Text>{doc.version}</Text>
</Td>
<Td width="50%">
<Text>{doc.generatedDate}</Text>
</Td>
<Td>
<Row justifyContent="end" {...stopPropagation}>
<IconButton
onClick={openDocVersion}
noBorder
icon={<Show />}
label={formatMessage(
{
id: getTrad('pages.PluginPage.table.icon.show'),
defaultMessage: 'Open {target}',
},
{ target: `${doc.version}` }
)}
/>
<CheckPermissions permissions={permissions.regenerate}>
<IconButton
onClick={() => handleRegenerateDoc(doc.version)}
noBorder
icon={<Reload />}
label={formatMessage(
{
id: getTrad('pages.PluginPage.table.icon.regenerate'),
defaultMessage: 'Regnerate {target}',
},
{ target: `${doc.version}` }
)}
/>
</CheckPermissions>
<CheckPermissions permissions={permissions.update}>
{doc.version !== data.currentVersion && (
<IconButton
onClick={() => handleClickDelete(doc.version)}
noBorder
icon={<DeleteIcon />}
label={formatMessage(
{
id: getTrad('pages.PluginPage.table.icon.delete'),
defaultMessage: 'Delete {target}',
},
{ target: `${doc.version}` }
)}
/>
)}
</CheckPermissions>
</Row>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<EmptyStateLayout />
)}
</ContentLayout>
<ConfirmDialog
isConfirmButtonLoading={isConfirmButtonLoading}
onConfirm={handleConfirmDelete}
onToggleDialog={handleShowConfirmDelete}
isOpen={showConfirmDelete}
/>
</Main>
</Layout>
);
};
export default PluginPage;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,23 @@
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const handlers = [
rest.get('*/getInfos', (req, res, ctx) => {
return res(
ctx.delay(1000),
ctx.status(200),
ctx.json({
currentVersion: '1.0.0',
docVersions: [
{ version: '1.0.0', generatedDoc: '10/05/2021 2:52:44 PM' },
{ version: '1.2.0', generatedDoc: '11/05/2021 3:00:00 PM' },
],
prefix: '/documentation',
})
);
}),
];
const server = setupServer(...handlers);
export default server;

View File

@ -0,0 +1,181 @@
import React, { useState } from 'react';
import { useIntl } from 'react-intl';
import { Formik } from 'formik';
import {
CheckPermissions,
Form,
LoadingIndicatorPage,
useFocusWhenNavigate,
} from '@strapi/helper-plugin';
// Strapi Parts
import { ContentLayout, HeaderLayout } from '@strapi/parts/Layout';
import { Main } from '@strapi/parts/Main';
import { Button } from '@strapi/parts/Button';
import { Box } from '@strapi/parts/Box';
import { Stack } from '@strapi/parts/Stack';
import { H3 } from '@strapi/parts/Text';
import { ToggleInput } from '@strapi/parts/ToggleInput';
import { TextInput } from '@strapi/parts/TextInput';
import { Grid, GridItem } from '@strapi/parts/Grid';
// Strapi Icons
import Show from '@strapi/icons/Show';
import Hide from '@strapi/icons/Hide';
import Check from '@strapi/icons/Check';
import permissions from '../../permissions';
import { getTrad } from '../../utils';
import useReactQuery from '../utils/useReactQuery';
import FieldActionWrapper from '../../components/FieldActionWrapper';
import schema from '../utils/schema';
const SettingsPage = () => {
useFocusWhenNavigate();
const { formatMessage } = useIntl();
const { submitMutation, data, isLoading } = useReactQuery();
const [passwordShown, setPasswordShown] = useState(false);
const handleUpdateSettingsSubmit = body => {
submitMutation.mutate({
prefix: data?.prefix,
body,
});
};
return (
<Main>
{isLoading ? (
<LoadingIndicatorPage>Plugin settings are loading</LoadingIndicatorPage>
) : (
<Formik
initialValues={{
restrictedAccess: data?.documentationAccess.restrictedAccess || false,
password: '',
}}
onSubmit={handleUpdateSettingsSubmit}
validationSchema={schema}
>
{({ handleSubmit, values, handleChange, errors, setFieldTouched, setFieldValue }) => {
return (
<Form noValidate onSubmit={handleSubmit}>
<HeaderLayout
title={formatMessage({
id: getTrad('plugin.name'),
defaultMessage: 'Documentation',
})}
subtitle={formatMessage({
id: getTrad('pages.SettingsPage.header.description'),
defaultMessage: 'Configure the documentation plugin',
})}
primaryAction={
<CheckPermissions permissions={permissions.update}>
<Button type="submit" startIcon={<Check />}>
{formatMessage({
id: getTrad('pages.SettingsPage.Button.save'),
defaultMessage: 'Save',
})}
</Button>
</CheckPermissions>
}
/>
<ContentLayout>
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Stack size={4}>
<H3 as="h2">
{formatMessage({
id: getTrad('pages.SettingsPage.title'),
defaultMessage: 'Settings',
})}
</H3>
<Grid gap={4}>
<GridItem col={6} s={12}>
<ToggleInput
name="restrictedAccess"
label={formatMessage({
id: getTrad('pages.SettingsPage.toggle.label'),
defaultMessage: 'Restricted Access',
})}
hint={formatMessage({
id: getTrad('pages.SettingsPage.toggle.hint'),
defaultMessage: 'Make the documentation endpoint private',
})}
checked={values.restrictedAccess}
onChange={() => {
if (values.restrictedAccess === true) {
setFieldValue('password', '', false);
setFieldTouched('password', false, false);
}
setFieldValue('restrictedAccess', !values.restrictedAccess, false);
}}
onLabel="On"
offLabel="Off"
/>
</GridItem>
{values.restrictedAccess && (
<GridItem col={6} s={12}>
<TextInput
label={formatMessage({
id: getTrad('pages.SettingsPage.password.label'),
defaultMessage: 'Password',
})}
name="password"
placeholder="**********"
type={passwordShown ? 'text' : 'password'}
value={values.password}
onChange={handleChange}
error={
errors.password
? formatMessage({
id: errors.password,
defaultMessage: 'Invalid value',
})
: null
}
endAction={
<FieldActionWrapper
onClick={e => {
e.stopPropagation();
setPasswordShown(prev => !prev);
}}
label={formatMessage(
passwordShown
? {
id: 'Auth.form.password.show-password',
defaultMessage: 'Show password',
}
: {
id: 'Auth.form.password.hide-password',
defaultMessage: 'Hide password',
}
)}
>
{passwordShown ? <Show /> : <Hide />}
</FieldActionWrapper>
}
/>
</GridItem>
)}
</Grid>
</Stack>
</Box>
</ContentLayout>
</Form>
);
}}
</Formik>
)}
</Main>
);
};
export default SettingsPage;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const handlers = [
rest.get('*/getInfos', (req, res, ctx) => {
return res(
ctx.delay(1000),
ctx.status(200),
ctx.json({
documentationAccess: { restrictedAccess: false, password: '' },
})
);
}),
];
const server = setupServer(...handlers);
export default server;

View File

@ -1,11 +1,11 @@
import { request } from '@strapi/helper-plugin';
import pluginId from '../../../pluginId';
import pluginId from '../../pluginId';
const deleteDoc = ({ prefix, version }) => {
return request(`${prefix}/deleteDoc/${version}`, { method: 'DELETE' });
};
const fetchData = async toggleNotification => {
const fetchDocumentationVersions = async toggleNotification => {
try {
const data = await request(`/${pluginId}/getInfos`, { method: 'GET' });
@ -25,6 +25,7 @@ const regenerateDoc = ({ prefix, version }) => {
return request(`${prefix}/regenerateDoc`, { method: 'POST', body: { version } });
};
const submit = ({ prefix, body }) => request(`${prefix}/updateSettings`, { method: 'PUT', body });
const updateSettings = ({ prefix, body }) =>
request(`${prefix}/updateSettings`, { method: 'PUT', body });
export { deleteDoc, fetchData, regenerateDoc, submit };
export { deleteDoc, fetchDocumentationVersions, regenerateDoc, updateSettings };

View File

@ -0,0 +1,46 @@
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { useNotification } from '@strapi/helper-plugin';
import { fetchDocumentationVersions, deleteDoc, regenerateDoc, updateSettings } from './api';
import getTrad from '../../utils/getTrad';
const useReactQuery = () => {
const queryClient = useQueryClient();
const toggleNotification = useNotification();
const { isLoading, data } = useQuery('get-documentation', () =>
fetchDocumentationVersions(toggleNotification)
);
const handleError = err => {
toggleNotification({
type: 'warning',
message: err.response.payload.message,
});
};
const handleSuccess = (type, tradId) => {
queryClient.invalidateQueries('get-documentation');
toggleNotification({
type,
message: { id: getTrad(tradId) },
});
};
const deleteMutation = useMutation(deleteDoc, {
onSuccess: () => handleSuccess('info', 'notification.delete.success'),
onError: error => handleError(error),
});
const submitMutation = useMutation(updateSettings, {
onSuccess: () => handleSuccess('success', 'notification.update.success'),
onError: handleError,
});
const regenerateDocMutation = useMutation(regenerateDoc, {
onSuccess: () => handleSuccess('info', 'notification.generate.success'),
onError: error => handleError(error),
});
return { data, isLoading, deleteMutation, submitMutation, regenerateDocMutation };
};
export default useReactQuery;

View File

@ -1,12 +1,9 @@
{
"coming-soon": "This content is currently under construction and will be back in a few weeks!",
"components.Row.generatedDate": "Last generation",
"components.Row.open": "Open",
"components.Row.regenerate": "Regenerate",
"containers.HomePage.Block.title": "Versions",
"containers.HomePage.Button.open": "Open the documentation",
"containers.HomePage.Button.update": "Update",
"containers.HomePage.PluginHeader.description": "Configure the documentation plugin",
"containers.HomePage.PluginHeader.title": "Documentation - Settings",
"containers.HomePage.PopUpWarning.confirm": "I understand",
"containers.HomePage.PopUpWarning.message": "Are you sure you want to delete this version?",
@ -26,7 +23,20 @@
"notification.delete.success": "Doc deleted",
"notification.generate.success": "Doc generated",
"notification.update.success": "Settings updated successfully",
"pages.PluginPage.Button.open": "Open documentation",
"pages.PluginPage.header.description": "Configure the documentation plugin",
"pages.PluginPage.table.generated": "Last generated",
"pages.PluginPage.table.icon.delete": "Delete {target}",
"pages.PluginPage.table.icon.regnerate": "Regenerate {target}",
"pages.PluginPage.table.icon.show": "Open {target}",
"pages.PluginPage.table.version": "Version",
"pages.SettingPage.title": "Settings",
"pages.SettingsPage.Button.description": "Configure the documentation plugin",
"pages.SettingsPage.header.save": "Save",
"pages.SettingsPage.password.label": "Password",
"pages.SettingsPage.toggle.label": "Restricted Access",
"pages.SettingsPage.toggle.hint": "Make the documentation endpoint private",
"plugin.description.long": "Create an OpenAPI Document and visualize your API with SWAGGER UI.",
"plugin.description.short": "Create an OpenAPI Document and visualize your API with SWAGGER UI.",
"plugin.name": "Documentation"
}
}

View File

@ -14,12 +14,14 @@
},
"dependencies": {
"@strapi/helper-plugin": "3.6.8",
"bcryptjs": "2.4.3",
"cheerio": "^1.0.0-rc.5",
"fs-extra": "^9.1.0",
"koa-session": "6.2.0",
"koa-static": "^5.0.0",
"lodash": "4.17.21",
"moment": "^2.29.1",
"path-to-regexp": "^3.1.0",
"path-to-regexp": "6.2.0",
"pluralize": "8.0.0",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.0.3",
"react-dom": "^17.0.2",

View File

@ -1,10 +1,6 @@
/* eslint-disable no-unreachable */
'use strict';
const fs = require('fs');
const path = require('path');
const _ = require('lodash');
// Add permissions
const RBAC_ACTIONS = [
{
@ -17,123 +13,44 @@ const RBAC_ACTIONS = [
section: 'plugins',
displayName: 'Update and delete',
uid: 'settings.update',
subCategory: 'settings',
pluginName: 'documentation',
},
{
section: 'plugins',
displayName: 'Regenerate',
uid: 'settings.regenerate',
subCategory: 'settings',
pluginName: 'documentation',
},
{
section: 'settings',
displayName: 'Access the documentation settings page',
uid: 'settings.read',
pluginName: 'documentation',
category: 'documentation',
},
];
/**
*
* @param {{strapi: import("@strapi/strapi").Strapi}} args
*/
module.exports = async ({ strapi }) => {
await strapi.admin.services.permission.actionProvider.registerMany(RBAC_ACTIONS);
return;
const pluginStore = strapi.store({
environment: '',
type: 'plugin',
name: 'documentation',
});
// Check if the plugin users-permissions is installed because the documentation needs it
if (Object.keys(strapi.plugins).indexOf('users-permissions') === -1) {
throw new Error(
'In order to make the documentation plugin works the users-permissions one is required'
);
}
const config = await pluginStore.get({ key: 'config' });
const pluginStore = strapi.store({ type: 'plugin', name: 'documentation' });
const restrictedAccess = await pluginStore.get({ key: 'config' });
if (!restrictedAccess) {
if (!config) {
pluginStore.set({ key: 'config', value: { restrictedAccess: false } });
}
let shouldUpdateFullDoc = false;
const services = strapi.plugins['documentation'].services.documentation;
// Generate plugins' documentation
const pluginsWithDocumentationNeeded = services.getPluginsWithDocumentationNeeded();
pluginsWithDocumentationNeeded.forEach(plugin => {
const isDocExisting = services.checkIfPluginDocumentationFolderExists(plugin);
if (!isDocExisting) {
services.createDocumentationDirectory(services.getPluginDocumentationPath(plugin));
// create the overrides directory
services.createDocumentationDirectory(services.getPluginOverrideDocumentationPath(plugin));
services.createPluginDocumentationFile(plugin);
shouldUpdateFullDoc = true;
} else {
const needToUpdatePluginDoc = services.checkIfPluginDocNeedsUpdate(plugin);
if (needToUpdatePluginDoc) {
services.createPluginDocumentationFile(plugin);
shouldUpdateFullDoc = true;
}
}
});
// Retrieve all the apis from the apis directory
const apis = services.getApis();
// Generate APIS' documentation
apis.forEach(api => {
const isDocExisting = services.checkIfDocumentationFolderExists(api);
if (!isDocExisting) {
// If the documentation directory doesn't exist create it
services.createDocumentationDirectory(services.getDocumentationPath(api));
// Create the overrides directory
services.createDocumentationDirectory(services.getDocumentationOverridesPath(api));
// Create the documentation files per version
services.createDocumentationFile(api); // Then create the {api}.json documentation file
shouldUpdateFullDoc = true;
} else {
const needToUpdateAPIDoc = services.checkIfAPIDocNeedsUpdate(api);
if (needToUpdateAPIDoc) {
services.createDocumentationFile(api);
shouldUpdateFullDoc = true;
}
}
});
const fullDoc = services.generateFullDoc();
// Verify that the correct documentation folder exists in the documentation plugin
const isMergedDocumentationExists = services.checkIfMergedDocumentationFolderExists();
const documentationPath = services.getMergedDocumentationPath();
if (isMergedDocumentationExists) {
/**
* Retrieve all tags from the documentation and join them
* @param {Object} documentation
* @returns {String}
*/
const getDocTagsToString = documentation => {
return _.get(documentation, 'tags', [])
.map(tag => {
return tag.name.toLowerCase();
})
.sort((a, b) => a - b)
.join('.');
};
const oldDoc = require(path.resolve(documentationPath, 'full_documentation.json'));
const oldDocTags = getDocTagsToString(oldDoc);
const currentDocTags = getDocTagsToString(fullDoc);
// If the tags are different (an api has been deleted) we need to rebuild the documentation
if (oldDocTags !== currentDocTags) {
shouldUpdateFullDoc = true;
}
}
if (!isMergedDocumentationExists || shouldUpdateFullDoc) {
// Create the folder
services.createDocumentationDirectory(documentationPath);
// Write the file
fs.writeFileSync(
path.resolve(documentationPath, 'full_documentation.json'),
JSON.stringify(fullDoc, null, 2),
'utf8'
);
}
await strapi
.plugin('documentation')
.service('documentation')
.generateFullDoc();
};

View File

@ -21,10 +21,11 @@ module.exports = {
path: '/documentation',
showGeneratedFiles: true,
generateDefaultResponse: true,
plugins: ['email', 'upload'],
},
servers: [
{
url: 'http://localhost:1337',
url: 'http://localhost:1337/api',
description: 'Development server',
},
{
@ -45,4 +46,13 @@ module.exports = {
bearerAuth: [],
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
};

View File

@ -1,8 +1,16 @@
'use strict';
const defaultConfig = require('./default-config');
const defaultDocumentationConfig = require('./default-config');
const defaultConfig = {
...defaultDocumentationConfig,
session: {
key: 'plugin::documentation.sess',
maxAge: 'session',
secretKeys: ['mySecret'],
},
};
module.exports = {
default: defaultConfig,
validator() {},
};

View File

@ -8,6 +8,7 @@
// Core dependencies.
const path = require('path');
const bcrypt = require('bcryptjs');
// Public dependencies.
const fs = require('fs-extra');
@ -17,15 +18,15 @@ const koaStatic = require('koa-static');
module.exports = {
async getInfos(ctx) {
try {
const service = strapi.plugins.documentation.services.documentation;
const docVersions = service.retrieveDocumentationVersions();
const form = await service.retrieveFrontForm();
const docService = strapi.plugin('documentation').service('documentation');
const docVersions = docService.getDocumentationVersions();
const documentationAccess = await docService.getDocumentationAccess();
ctx.send({
docVersions,
currentVersion: service.getDocumentationVersion(),
prefix: strapi.config.get('plugin.documentation.x-strapi-config').path,
form,
currentVersion: docService.getDocumentationVersion(),
prefix: strapi.plugin('documentation').config('x-strapi-config').path,
documentationAccess,
});
} catch (err) {
ctx.badRequest(null, err.message);
@ -42,10 +43,13 @@ module.exports = {
const version =
major && minor && patch
? `${major}.${minor}.${patch}`
: strapi.plugins.documentation.config.info.version;
: strapi
.plugin('documentation')
.service('documentation')
.getDocumentationVersion();
const openAPISpecsPath = path.join(
strapi.config.appPath,
'extensions',
strapi.dirs.extensions,
'documentation',
'documentation',
version,
@ -65,8 +69,7 @@ module.exports = {
try {
const layoutPath = path.resolve(
strapi.config.appPath,
'extensions',
strapi.dirs.extensions,
'documentation',
'public',
'index.html'
@ -78,12 +81,7 @@ module.exports = {
ctx.url = path.basename(`${ctx.url}/index.html`);
try {
const staticFolder = path.resolve(
strapi.config.appPath,
'extensions',
'documentation',
'public'
);
const staticFolder = path.resolve(strapi.dirs.extensions, 'documentation', 'public');
return koaStatic(staticFolder)(ctx, next);
} catch (e) {
strapi.log.error(e);
@ -118,8 +116,7 @@ module.exports = {
try {
const layoutPath = path.resolve(
strapi.config.appPath,
'extensions',
strapi.dirs.extensions,
'documentation',
'public',
'login.html'
@ -130,12 +127,7 @@ module.exports = {
ctx.url = path.basename(`${ctx.url}/login.html`);
try {
const staticFolder = path.resolve(
strapi.config.appPath,
'extensions',
'documentation',
'public'
);
const staticFolder = path.resolve(strapi.dirs.extensions, 'documentation', 'public');
return koaStatic(staticFolder)(ctx, next);
} catch (e) {
strapi.log.error(e);
@ -153,18 +145,19 @@ module.exports = {
body: { password },
} = ctx.request;
const { password: storedPassword } = await strapi
const { password: hash } = await strapi
.store({ type: 'plugin', name: 'documentation', key: 'config' })
.get();
const isValid = await strapi.plugins['users-permissions'].services.user.validatePassword(
password,
storedPassword
);
const isValid = await bcrypt.compare(password, hash);
let querystring = '?error=password';
if (isValid) {
ctx.session.documentation = password;
ctx.session.documentation = {
logged: true,
};
querystring = '';
}
@ -176,126 +169,71 @@ module.exports = {
},
async regenerateDoc(ctx) {
const service = strapi.plugins.documentation.services.documentation;
const documentationVersions = service.retrieveDocumentationVersions().map(el => el.version);
const {
request: {
body: { version },
admin,
},
} = ctx;
const { version } = ctx.request.body;
const service = strapi.service('plugin::documentation.documentation');
const documentationVersions = service.getDocumentationVersions().map(el => el.version);
if (_.isEmpty(version)) {
return ctx.badRequest(
null,
admin ? 'documentation.error.noVersion' : 'Please provide a version.'
);
return ctx.badRequest('Please provide a version.');
}
if (!documentationVersions.includes(version)) {
return ctx.badRequest(
null,
admin
? 'documentation.error.regenerateDoc.versionMissing'
: 'The version you are trying to generate does not exist.'
);
return ctx.badRequest('The version you are trying to generate does not exist.');
}
try {
strapi.reload.isWatching = false;
const fullDoc = service.generateFullDoc(version);
const documentationPath = service.getMergedDocumentationPath(version);
// Write the file
fs.writeFileSync(
path.resolve(documentationPath, 'full_documentation.json'),
JSON.stringify(fullDoc, null, 2),
'utf8'
);
await service.generateFullDoc(version);
ctx.send({ ok: true });
} catch (err) {
ctx.badRequest(null, admin ? 'documentation.error.regenerateDoc' : 'An error occured');
} finally {
strapi.reload.isWatching = true;
}
},
async deleteDoc(ctx) {
strapi.reload.isWatching = false;
const service = strapi.plugins.documentation.services.documentation;
const documentationVersions = service.retrieveDocumentationVersions().map(el => el.version);
const { version } = ctx.params;
const {
params: { version },
request: { admin },
} = ctx;
const service = strapi.service('plugin::documentation.documentation');
const documentationVersions = service.getDocumentationVersions().map(el => el.version);
if (_.isEmpty(version)) {
return ctx.badRequest(
null,
admin ? 'documentation.error.noVersion' : 'Please provide a version.'
);
return ctx.badRequest('Please provide a version.');
}
if (!documentationVersions.includes(version)) {
return ctx.badRequest(
null,
admin
? 'documentation.error.deleteDoc.versionMissing'
: 'The version you are trying to delete does not exist.'
);
return ctx.badRequest('The version you are trying to delete does not exist.');
}
try {
strapi.reload.isWatching = false;
await service.deleteDocumentation(version);
ctx.send({ ok: true });
} catch (err) {
ctx.badRequest(null, admin ? 'notification.error' : err.message);
} finally {
strapi.reload.isWatching = true;
}
},
async updateSettings(ctx) {
const {
admin,
body: { restrictedAccess, password },
} = ctx.request;
const usersPermService = strapi.plugins['users-permissions'].services;
const { restrictedAccess, password } = ctx.request.body;
const pluginStore = strapi.store({ type: 'plugin', name: 'documentation' });
const prevConfig = await pluginStore.get({ key: 'config' });
const config = {
restrictedAccess: Boolean(restrictedAccess),
};
if (restrictedAccess && _.isEmpty(password)) {
return ctx.badRequest(
null,
admin ? 'users-permissions.Auth.form.error.password.provide' : 'Please provide a password'
);
if (restrictedAccess) {
if (_.isEmpty(password)) {
return ctx.badRequest('Please provide a password');
}
config.password = await bcrypt.hash(password, 10);
}
const isNewPassword = !_.isEmpty(password) && password !== prevConfig.password;
if (isNewPassword && usersPermService.user.isHashed(password)) {
// Throw an error if the password selected by the user
// contains more than two times the symbol '$'.
return ctx.badRequest(
null,
admin
? 'users-permissions.Auth.form.error.password.format'
: 'our password cannot contain more than three times the symbol `$`.'
);
}
if (isNewPassword) {
prevConfig.password = await usersPermService.user.hashPassword({
password,
});
}
_.set(prevConfig, 'restrictedAccess', restrictedAccess);
await pluginStore.set({ key: 'config', value: prevConfig });
await pluginStore.set({ key: 'config', value: config });
return ctx.send({ ok: true });
},

View File

@ -0,0 +1,17 @@
'use strict';
const bootstrap = require('./bootstrap');
const services = require('./services');
const routes = require('./routes');
const controllers = require('./controllers');
const register = require('./register');
const config = require('./config');
module.exports = () => ({
bootstrap,
config,
routes,
controllers,
register,
services,
});

View File

@ -1,61 +1,30 @@
'use strict';
const path = require('path');
const _ = require('lodash');
const koaStatic = require('koa-static');
const session = require('koa-session');
const swaggerUi = require('swagger-ui-dist');
const initialRoutes = [];
module.exports = async ({ strapi }) => {
const sessionConfig = strapi.config.get('plugin.documentation').session;
strapi.server.app.keys = sessionConfig.secretKeys;
strapi.server.app.use(session(sessionConfig, strapi.server.app));
// TODO: delete when refactoring documentation plugin for v4
module.exports = {
defaults: { documentation: { enabled: true } },
load: {
beforeInitialize() {
strapi.config.middleware.load.before.push('documentation');
strapi.server.routes([
{
method: 'GET',
path: '/plugins/documentation/(.*)',
async handler(ctx, next) {
ctx.url = path.basename(ctx.url);
initialRoutes.push(..._.cloneDeep(strapi.plugins.documentation.routes));
return koaStatic(swaggerUi.getAbsoluteFSPath(), {
maxage: 86400000,
defer: true,
})(ctx, next);
},
config: {
auth: false,
},
},
initialize() {
const swaggerUi = require('swagger-ui-dist');
// Find the plugins routes.
strapi.plugins.documentation.routes = strapi.plugins.documentation.routes.map(
(route, index) => {
if (route.handler === 'Documentation.getInfos') {
return route;
}
if (route.handler === 'Documentation.index' || route.path === '/login') {
route.config.policies = initialRoutes[index].config.policies;
}
// Set prefix to empty to be able to customise it.
if (strapi.config.has('plugins.documentation.x-strapi-config.path')) {
route.config.prefix = '';
route.path = `/${strapi.config.get('plugin.documentation.x-strapi-config').path}${
route.path
}`.replace('//', '/');
}
return route;
}
);
strapi.server.routes([
{
method: 'GET',
path: '/plugins/documentation/(.*)',
async handler(ctx, next) {
ctx.url = path.basename(ctx.url);
return koaStatic(swaggerUi.getAbsoluteFSPath(), {
maxage: strapi.config.middleware.settings.public.maxAge,
defer: true,
})(ctx, next);
},
},
]);
},
},
]);
};

View File

@ -9,7 +9,7 @@ module.exports = async (ctx, next) => {
return next();
}
if (!ctx.session.documentation) {
if (!ctx.session.documentation || !ctx.session.documentation.logged) {
const querystring = ctx.querystring ? `?${ctx.querystring}` : '';
return ctx.redirect(
@ -18,15 +18,7 @@ module.exports = async (ctx, next) => {
}/login${querystring}`
);
}
const isValid = await strapi.plugins['users-permissions'].services.user.validatePassword(
ctx.session.documentation,
config.password
);
if (!isValid) {
ctx.session.documentation = null;
}
// Execute the action.
await next();
return next();
};

View File

@ -1,7 +0,0 @@
'use strict';
const index = require('./index-policy');
module.exports = {
index,
};

View File

@ -0,0 +1,11 @@
'use strict';
const registerDocumentationMiddleWare = require('./middlewares/documentation');
/**
* Register upload plugin
* @param {{ strapi: import('@strapi/strapi').Strapi }}
*/
module.exports = async ({ strapi }) => {
await registerDocumentationMiddleWare({ strapi });
};

View File

@ -1,4 +1,5 @@
'use strict';
const restrictAccess = require('../middlewares/restrict-access');
module.exports = [
{
@ -6,9 +7,15 @@ module.exports = [
path: '/',
handler: 'documentation.index',
config: {
auth: false,
middlewares: [restrictAccess],
policies: [
'plugin::documentation.index',
{ name: 'admin::hasPermissions', config: { actions: ['plugin::documentation.read'] } },
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::documentation.read'],
},
},
],
},
},
@ -17,9 +24,15 @@ module.exports = [
path: '/v:major(\\d+).:minor(\\d+).:patch(\\d+)',
handler: 'documentation.index',
config: {
auth: false,
middlewares: [restrictAccess],
policies: [
'plugin::documentation.index',
{ name: 'admin::hasPermissions', config: { actions: ['plugin::documentation.read'] } },
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::documentation.read'],
},
},
],
},
},
@ -28,8 +41,14 @@ module.exports = [
path: '/login',
handler: 'documentation.loginView',
config: {
auth: false,
policies: [
{ name: 'admin::hasPermissions', config: { actions: ['plugin::documentation.read'] } },
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::documentation.read'],
},
},
],
},
},
@ -38,6 +57,7 @@ module.exports = [
path: '/login',
handler: 'documentation.login',
config: {
auth: false,
policies: [
{ name: 'admin::hasPermissions', config: { actions: ['plugin::documentation.read'] } },
],

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +0,0 @@
[
[
{
"name": "restrictedAccess",
"label": {
"id": "documentation.containers.HomePage.form.restrictedAccess"
},
"type": "toggle",
"inputDescription": {
"id": "documentation.containers.HomePage.form.restrictedAccess.inputDescription"
},
"inputDescriptionStyle": { "marginBottom": "2px"},
"value": false
},
{
"name": "password",
"label": {
"id": "documentation.containers.HomePage.form.password"
},
"customBootstrapClass": "col-md-4",
"type": "password",
"inputDescription": {
"id": "documentation.containers.HomePage.form.password.inputDescription"
},
"validations": { "required": true },
"value": ""
}
]
]

View File

@ -0,0 +1,174 @@
'use strict';
const _ = require('lodash');
const pathToRegexp = require('path-to-regexp');
const queryParams = require('../query-params');
const buildApiRequests = require('./build-api-requests');
const buildApiResponses = require('./build-api-responses');
/**
* @description Parses a route with ':variable'
*
* @param {string} routePath - The route's path property
* @returns {string}
*/
const parsePathWithVariables = routePath => {
return pathToRegexp
.parse(routePath)
.map(token => {
if (_.isObject(token)) {
return token.prefix + '{' + token.name + '}';
}
return token;
})
.join('');
};
/**
* @description Builds the required object for a path parameter
*
* @param {string} routePath - The route's path property
*
* @returns {object } Swagger path params object
*/
const getPathParams = routePath => {
return pathToRegexp
.parse(routePath)
.filter(token => _.isObject(token))
.map(param => {
return {
name: param.name,
in: 'path',
description: '',
deprecated: false,
required: true,
schema: { type: 'string' },
};
});
};
/**
*
* @param {string} prefix - The route prefix
* @param {string} path - The route path
*
* @returns {string}
*/
const getPathWithPrefix = (prefix, path) => {
if (path.includes('localizations')) {
return path;
}
if (path.endsWith('/')) {
return prefix;
}
return prefix.concat(path);
};
/**
*
* @param {object} api - Information about the api
* @param {object} api.routeInfo - The routes for a given api or plugin
* @param {string} api.routeInfo.prefix - The prefix for all routes
* @param {array} api.routeInfo.routes - The routes for the current api
* @param {object} api.attributes - The attributes for a given api or plugin
* @param {string} api.tag - A descriptor for OpenAPI
*
* @returns {object}
*/
const getPaths = ({ routeInfo, attributes, tag }) => {
const paths = routeInfo.routes.reduce((acc, route) => {
// TODO: Find a more reliable way to determine list of entities vs a single entity
const isListOfEntities = route.handler.split('.').pop() === 'find';
const methodVerb = route.method.toLowerCase();
const hasPathParams = route.path.includes('/:');
const pathWithPrefix = routeInfo.prefix
? getPathWithPrefix(routeInfo.prefix, route.path)
: route.path;
const routePath = hasPathParams ? parsePathWithVariables(pathWithPrefix) : pathWithPrefix;
const { responses } = buildApiResponses(attributes, route, isListOfEntities);
const swaggerConfig = {
responses,
tags: [_.upperFirst(tag)],
parameters: [],
requestBody: {},
};
if (isListOfEntities) {
swaggerConfig.parameters.push(...queryParams);
}
if (hasPathParams) {
const pathParams = getPathParams(route.path);
swaggerConfig.parameters.push(...pathParams);
}
if (['post', 'put'].includes(methodVerb)) {
const { requestBody } = buildApiRequests(attributes, route);
swaggerConfig.requestBody = requestBody;
}
_.set(acc, `${routePath}.${methodVerb}`, swaggerConfig);
return acc;
}, {});
return { paths };
};
/**
* @description - Builds the Swagger paths object for each api
*
* @param {object} api - Information about the current api
* @property {string} api.name - The name of the api
* @property {string} api.getter - The getter for the api (api | plugin)
* @property {array} api.ctNames - The name of all contentTypes found on the api
*
* @returns {object}
*/
module.exports = api => {
if (!api.ctNames.length && api.getter === 'plugin') {
// Set arbitrary attributes
const attributes = { foo: { type: 'string' } };
const routeInfo = strapi.plugin(api.name).routes['admin'];
const apiInfo = {
routeInfo,
attributes,
tag: api.name,
};
return getPaths(apiInfo);
}
// An api could have multiple contentTypes
for (const contentTypeName of api.ctNames) {
// Get the attributes found on the api's contentType
const uid = `${api.getter}::${api.name}.${contentTypeName}`;
const ct = strapi.contentType(uid);
const attributes = ct.attributes;
// Get the routes for the current api
const routeInfo =
api.getter === 'plugin'
? strapi.plugin(api.name).routes['content-api']
: strapi.api[api.name].routes[contentTypeName];
// Parse an identifier for OpenAPI tag if the api name and contentType name don't match
const tag = api.name === contentTypeName ? api.name : `${api.name} - ${contentTypeName}`;
const apiInfo = {
routeInfo,
attributes,
tag,
};
return getPaths(apiInfo);
}
};

View File

@ -0,0 +1,41 @@
'use strict';
const cleanSchemaAttributes = require('../clean-schema-attributes');
/**
*
* @param {object} attributes - The attributes found on a contentType
* @param {object} route - The current route
*
* @returns The Swagger requestBody
*/
module.exports = (attributes, route) => {
const requiredAttributes = Object.entries(attributes)
.filter(([, attribute]) => attribute.required)
.map(([attributeName, attribute]) => {
return { [attributeName]: attribute };
});
const requestAttributes =
route.method === 'POST' && requiredAttributes.length
? Object.assign({}, ...requiredAttributes)
: attributes;
return {
requestBody: {
required: true,
content: {
'application/json': {
schema: {
properties: {
data: {
type: 'object',
properties: cleanSchemaAttributes(requestAttributes, { isRequest: true }),
},
},
},
},
},
},
};
};

View File

@ -0,0 +1,108 @@
'use strict';
const getSchemaData = require('../get-schema-data');
const cleanSchemaAttributes = require('../clean-schema-attributes');
const errorResponse = require('../error-response');
/**
*
* @param {boolean} isSingleEntity - Checks for a single entity
* @returns {object} The correctly formatted meta object
*/
const getMeta = isListOfEntities => {
if (isListOfEntities) {
return {
type: 'object',
properties: {
pagination: {
properties: {
page: { type: 'integer' },
pageSize: { type: 'integer', minimum: 25 },
pageCount: { type: 'integer', maximum: 1 },
total: { type: 'integer' },
},
},
},
};
}
return { type: 'object' };
};
/**
* @description - Builds the Swagger response object for a given api
*
* @param {object} attributes - The attributes found on a contentType
* @param {object} route - The current route
* @param {boolean} isListOfEntities - Checks for a list of entitities
*
* @returns The Swagger responses
*/
module.exports = (attributes, route, isListOfEntities = false) => {
let schema;
if (route.method === 'DELETE') {
schema = {
type: 'integer',
format: 'int64',
};
} else {
schema = {
properties: {
data: getSchemaData(isListOfEntities, cleanSchemaAttributes(attributes)),
meta: getMeta(isListOfEntities),
},
};
}
return {
responses: {
'200': {
content: {
'application/json': {
schema,
},
},
},
'400': {
description: 'Bad Request',
content: {
'application/json': {
schema: errorResponse,
},
},
},
'401': {
description: 'Unauthorized',
content: {
'application/json': {
schema: errorResponse,
},
},
},
'403': {
description: 'Forbidden',
content: {
'application/json': {
schema: errorResponse,
},
},
},
'404': {
description: 'Not Found',
content: {
'application/json': {
schema: errorResponse,
},
},
},
'500': {
description: 'Internal Server Error',
content: {
'application/json': {
schema: errorResponse,
},
},
},
},
};
};

View File

@ -0,0 +1,11 @@
'use strict';
const buildApiResponses = require('./build-api-responses');
const buildApiRequests = require('./build-api-requests');
const builApiEndpointPath = require('./build-api-endpoint-path');
module.exports = {
buildApiResponses,
buildApiRequests,
builApiEndpointPath,
};

View File

@ -0,0 +1,205 @@
'use strict';
const _ = require('lodash');
const getSchemaData = require('./get-schema-data');
/**
* @description - Converts types found on attributes to OpenAPI specific data types
*
* @param {object} attributes - The attributes found on a contentType
* @param {{ typeMap: Map, isRequest: boolean }} opts
* @returns Attributes using OpenAPI acceptable data types
*/
const cleanSchemaAttributes = (attributes, { typeMap = new Map(), isRequest = false } = {}) => {
const attributesCopy = _.cloneDeep(attributes);
for (const prop in attributesCopy) {
const attribute = attributesCopy[prop];
if (attribute.default) {
delete attributesCopy[prop].default;
}
switch (attribute.type) {
case 'password': {
if (!isRequest) {
delete attributesCopy[prop];
break;
}
attributesCopy[prop] = { type: 'string', format: 'password', example: '*******' };
break;
}
case 'email': {
attributesCopy[prop] = { type: 'string', format: 'email' };
break;
}
case 'string':
case 'text':
case 'richtext': {
attributesCopy[prop] = { type: 'string' };
break;
}
case 'timestamp': {
attributesCopy[prop] = { type: 'string', format: 'timestamp', example: Date.now() };
break;
}
case 'time': {
attributesCopy[prop] = { type: 'string', format: 'time', example: '12:54.000' };
break;
}
case 'date': {
attributesCopy[prop] = { type: 'string', format: 'date' };
break;
}
case 'datetime': {
attributesCopy[prop] = { type: 'string', format: 'date-time' };
break;
}
case 'boolean': {
attributesCopy[prop] = { type: 'boolean' };
break;
}
case 'enumeration': {
attributesCopy[prop] = { type: 'string', enum: attribute.enum };
break;
}
case 'decimal':
case 'float': {
attributesCopy[prop] = { type: 'number', format: 'float' };
break;
}
case 'integer': {
attributesCopy[prop] = { type: 'integer' };
break;
}
case 'biginteger': {
attributesCopy[prop] = { type: 'string', pattern: '^\\d*$', example: '123456789' };
break;
}
case 'json': {
attributesCopy[prop] = {};
break;
}
case 'uid': {
attributesCopy[prop] = { type: 'string' };
break;
}
case 'component': {
const componentAttributes = strapi.components[attribute.component].attributes;
if (attribute.repeatable) {
attributesCopy[prop] = {
type: 'array',
items: {
type: 'object',
properties: {
...(isRequest ? {} : { id: { type: 'string' } }),
...cleanSchemaAttributes(componentAttributes, { typeMap, isRequest }),
},
},
};
} else {
attributesCopy[prop] = {
type: 'object',
properties: {
...(isRequest ? {} : { id: { type: 'string' } }),
...cleanSchemaAttributes(componentAttributes, {
typeMap,
isRequest,
}),
},
};
}
break;
}
case 'dynamiczone': {
const components = attribute.components.map(component => {
const componentAttributes = strapi.components[component].attributes;
return {
type: 'object',
properties: {
...(isRequest ? {} : { id: { type: 'string' } }),
__component: { type: 'string' },
...cleanSchemaAttributes(componentAttributes, { typeMap, isRequest }),
},
};
});
attributesCopy[prop] = {
type: 'array',
items: {
anyOf: components,
},
};
break;
}
case 'media': {
const imageAttributes = strapi.contentType('plugin::upload.file').attributes;
const isListOfEntities = attribute.multiple;
if (isRequest) {
const oneOfType = {
oneOf: [{ type: 'integer' }, { type: 'string' }],
example: 'string or id',
};
attributesCopy[prop] = isListOfEntities ? { type: 'array', items: oneOfType } : oneOfType;
break;
}
attributesCopy[prop] = {
type: 'object',
properties: {
data: getSchemaData(isListOfEntities, cleanSchemaAttributes(imageAttributes)),
},
};
break;
}
case 'relation': {
const isListOfEntities = attribute.relation.includes('ToMany');
if (isRequest) {
const oneOfType = {
oneOf: [{ type: 'integer' }, { type: 'string' }],
example: 'string or id',
};
attributesCopy[prop] = isListOfEntities ? { type: 'array', items: oneOfType } : oneOfType;
break;
}
if (!attribute.target || typeMap.has(attribute.target)) {
attributesCopy[prop] = {
type: 'object',
properties: { data: getSchemaData(isListOfEntities, {}) },
};
break;
}
typeMap.set(attribute.target, true);
const targetAttributes = strapi.contentType(attribute.target).attributes;
attributesCopy[prop] = {
type: 'object',
properties: {
data: getSchemaData(
isListOfEntities,
cleanSchemaAttributes(targetAttributes, { typeMap, isRequest })
),
},
};
break;
}
default: {
throw new Error(`Invalid type ${attribute.type} while generating open api schema.`);
}
}
}
return attributesCopy;
};
module.exports = cleanSchemaAttributes;

View File

@ -0,0 +1,22 @@
'use strict';
module.exports = {
type: 'object',
required: ['error'],
properties: {
error: {
type: 'object',
properties: {
status: {
type: 'integer',
},
name: {
type: 'string',
},
message: {
type: 'string',
},
},
},
},
};

View File

@ -0,0 +1,32 @@
'use strict';
/**
* @description Determines the format of the data response
*
* @param {boolean} isListOfEntities - Checks for a multiple entities
* @param {object} attributes - The attributes found on a contentType
* @returns object | array of attributes
*/
module.exports = (isListOfEntities, attributes) => {
if (isListOfEntities) {
return {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
attributes: { type: 'object', properties: attributes },
},
},
};
}
return {
type: 'object',
properties: {
id: { type: 'string' },
attributes: { type: 'object', properties: attributes },
},
};
};

View File

@ -0,0 +1,84 @@
'use strict';
module.exports = [
{
name: 'sort',
in: 'query',
description: 'Sort by attributes ascending (asc) or descending (desc)',
deprecated: false,
required: false,
schema: {
type: 'string',
},
},
{
name: 'pagination[withCount]',
in: 'query',
description: 'Retun page/pageSize (default: true)',
deprecated: false,
required: false,
schema: {
type: 'boolean',
},
},
{
name: 'pagination[page]',
in: 'query',
description: 'Page number (default: 0)',
deprecated: false,
required: false,
schema: {
type: 'integer',
},
},
{
name: 'pagination[pageSize]',
in: 'query',
description: 'Page size (default: 25)',
deprecated: false,
required: false,
schema: {
type: 'integer',
},
},
{
name: 'pagination[start]',
in: 'query',
description: 'Offset value (default: 0)',
deprecated: false,
required: false,
schema: {
type: 'integer',
},
},
{
name: 'pagination[limit]',
in: 'query',
description: 'Number of entities to return (default: 25)',
deprecated: false,
required: false,
schema: {
type: 'integer',
},
},
{
name: 'fields',
in: 'query',
description: 'Fields to return (ex: title,author)',
deprecated: false,
required: false,
schema: {
type: 'string',
},
},
{
name: 'populate',
in: 'query',
description: 'Relations to return',
deprecated: false,
required: false,
schema: {
type: 'string',
},
},
];

View File

@ -1,21 +1,3 @@
'use strict';
const bootstrap = require('./server/bootstrap');
const policies = require('./server/policies');
const services = require('./server/services');
const routes = require('./server/routes');
const controllers = require('./server/controllers');
const middlewares = require('./server/middlewares');
const config = require('./server/config');
module.exports = () => {
return {
bootstrap,
config,
routes,
controllers,
middlewares,
policies,
services,
};
};
module.exports = require('./server');

View File

@ -17,7 +17,7 @@
"@purest/providers": "^1.0.2",
"@strapi/helper-plugin": "3.6.8",
"@strapi/utils": "3.6.8",
"bcryptjs": "^2.4.3",
"bcryptjs": "2.4.3",
"grant-koa": "5.4.8",
"jsonwebtoken": "^8.1.0",
"koa2-ratelimit": "^0.9.0",

View File

@ -7206,7 +7206,7 @@ bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2:
dependencies:
tweetnacl "^0.14.3"
bcryptjs@2.4.3, bcryptjs@^2.4.3:
bcryptjs@2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=
@ -18476,6 +18476,11 @@ path-to-regexp@0.1.7:
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
path-to-regexp@6.2.0, path-to-regexp@^6.1.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.0.tgz#f7b3803336104c346889adece614669230645f38"
integrity sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==
path-to-regexp@^1.7.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
@ -18483,16 +18488,6 @@ path-to-regexp@^1.7.0:
dependencies:
isarray "0.0.1"
path-to-regexp@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.2.0.tgz#fa7877ecbc495c601907562222453c43cc204a5f"
integrity sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==
path-to-regexp@^6.1.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.0.tgz#f7b3803336104c346889adece614669230645f38"
integrity sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==
path-type@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"