feat: forbidden user interface (#4523)

* feat: forbidden user interface

* Delete App.stories.tsx

* Update package.json

* Delete package.svg

* fix
This commit is contained in:
Juan Picado 2024-03-03 19:06:39 +01:00 committed by GitHub
parent 4a81ed791a
commit c9962fe1d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 292 additions and 195 deletions

View File

@ -0,0 +1,7 @@
---
'@verdaccio/ui-theme': minor
'@verdaccio/ui-components': minor
'@verdaccio/config': minor
---
feat: forbidden user interface

View File

@ -137,7 +137,7 @@
"docker": "docker build -t verdaccio/verdaccio:local . --no-cache",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,yml,yaml,md}\"",
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,yml,yaml,md}\"",
"lint": "eslint --max-warnings 100 \"**/*.{js,jsx,ts,tsx}\"",
"lint": "eslint --max-warnings 70 \"**/*.{js,jsx,ts,tsx}\"",
"test": "pnpm --filter \"./packages/**\" test",
"test:e2e:cli": "pnpm --filter ...@verdaccio/e2e-cli-* test -- --coverage=false",
"test:e2e:ui": "pnpm --filter ...@verdaccio/e2e-ui test",

View File

@ -10,7 +10,7 @@ auth:
uplinks:
ver:
url: https://registry.verdaccio.org
url: https://registry.npmjs.org
security:
api:

View File

@ -7,7 +7,7 @@ web:
uplinks:
ver:
url: https://registry.verdaccio.org
url: https://registry.npmjs.org
log: { type: stdout, format: pretty, level: trace }

View File

@ -1,9 +1,12 @@
import assert from 'assert';
import buildDebug from 'debug';
import _ from 'lodash';
import { errorUtils } from '@verdaccio/core';
import { PackageAccess } from '@verdaccio/types';
const debug = buildDebug('verdaccio:config:utils');
export interface LegacyPackageList {
[key: string]: PackageAccess;
}
@ -61,6 +64,7 @@ export function normalisePackageAccess(packages: LegacyPackageList): LegacyPacka
for (const pkg in packages) {
if (Object.prototype.hasOwnProperty.call(packages, pkg)) {
const packageAccess = packages[pkg];
debug('package access %s for %s ', packageAccess, pkg);
const isInvalid = _.isObject(packageAccess) && _.isArray(packageAccess) === false;
assert(isInvalid, `CONFIG: bad "'${pkg}'" package description (object expected)`);

View File

@ -147,9 +147,11 @@
"error": {
"unspecific": "Something went wrong.",
"404": {
"page-not-found": "404 - Page not found",
"sorry-we-could-not-find-it": "Sorry, we couldn't find it..."
},
"401": {
"sorry-no-access": "Sorry, you need credentials access to see this page."
},
"app-context-not-correct-used": "The app context was not used correctly",
"theme-context-not-correct-used": "The theme context was not used correctly",
"package-meta-is-required-at-detail-context": "packageMeta is required at DetailContext"

View File

@ -1,7 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Loading, NotFound, RootState, VersionLayout } from '@verdaccio/ui-components';
import { Forbidden, Loading, NotFound, RootState, VersionLayout } from '@verdaccio/ui-components';
const Version: React.FC = () => {
const manifestStore = useSelector((state: RootState) => state.manifest);
@ -11,10 +11,13 @@ const Version: React.FC = () => {
return <Loading />;
}
if (manifestStore.forbidden) {
return <Forbidden />;
}
if (manifestStore.hasNotBeenFound) {
return <NotFound />;
}
return <VersionLayout />;
};

View File

@ -37,7 +37,7 @@ module.exports = Object.assign({}, config, {
branches: 70,
functions: 76,
lines: 80,
statements: 82,
statements: 81,
},
},
});

View File

@ -31,6 +31,15 @@ export const handlers = [
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/jquery', (req, res, ctx) => {
return res(ctx.json(require('./api/jquery-sidebar.json')));
}),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/JSONStream', (req, res, ctx) => {
return res(ctx.status(401));
}),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/semver', (req, res, ctx) => {
return res(ctx.status(500));
}),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/kleur', (req, res, ctx) => {
return res(ctx.status(404));
}),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/glob', (req, res, ctx) => {
return res(ctx.json(require('./api/glob-sidebar.json')));
}),

View File

@ -0,0 +1,32 @@
import React from 'react';
import { MemoryRouter } from 'react-router';
import AppRoute from './AppRoute';
export default {
title: 'App/Main',
};
export const ApplicationStoryBook: any = () => (
<MemoryRouter initialEntries={[`/-/web/detail/storybook`]}>
<AppRoute />
</MemoryRouter>
);
export const ApplicationjQuery: any = () => (
<MemoryRouter initialEntries={[`/-/web/detail/jquery`]}>
<AppRoute />
</MemoryRouter>
);
export const ApplicationForbidden: any = () => (
<MemoryRouter initialEntries={[`/-/web/detail/JSONStream`]}>
<AppRoute />
</MemoryRouter>
);
export const ApplicationNotFound: any = () => (
<MemoryRouter initialEntries={[`/-/web/detail/kleur`]}>
<AppRoute />
</MemoryRouter>
);

View File

@ -0,0 +1,42 @@
import React from 'react';
import { Route as ReactRouterDomRoute, Switch } from 'react-router-dom';
import { NotFound, Route, VersionProvider, loadable } from '../index';
const VersionPage = loadable(() => import(/* webpackChunkName: "Version" */ './pages/Version'));
const Front = loadable(() => import(/* webpackChunkName: "Home" */ './pages/Front'));
const AppRoute: React.FC = () => {
return (
<Switch>
<ReactRouterDomRoute exact={true} path={Route.ROOT}>
<Front />
</ReactRouterDomRoute>
<ReactRouterDomRoute exact={true} path={Route.PACKAGE}>
<VersionProvider>
<VersionPage />
</VersionProvider>
</ReactRouterDomRoute>
<ReactRouterDomRoute exact={true} path={Route.PACKAGE_VERSION}>
<VersionProvider>
<VersionPage />
</VersionProvider>
</ReactRouterDomRoute>
<ReactRouterDomRoute exact={true} path={Route.SCOPE_PACKAGE_VERSION}>
<VersionProvider>
<VersionPage />
</VersionProvider>
</ReactRouterDomRoute>
<ReactRouterDomRoute exact={true} path={Route.SCOPE_PACKAGE}>
<VersionProvider>
<VersionPage />
</VersionProvider>
</ReactRouterDomRoute>
<ReactRouterDomRoute>
<NotFound />
</ReactRouterDomRoute>
</Switch>
);
};
export default AppRoute;

View File

@ -0,0 +1,3 @@
import { Home } from '../../../index';
export default Home;

View File

@ -0,0 +1 @@
export { default } from './Home';

View File

@ -0,0 +1,28 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Forbidden from '../../../components/Forbidden';
import { Loading, NotFound, RootState, VersionLayout } from '../../../index';
const Version: React.FC = () => {
const manifestStore = useSelector((state: RootState) => state.manifest);
const isLoading = useSelector((state: RootState) => state?.loading?.models.manifest);
if (isLoading) {
return <Loading />;
}
// @ts-expect-error
if (manifestStore.forbidden) {
return <Forbidden />;
}
// @ts-expect-error
if (manifestStore.hasNotBeenFound) {
return <NotFound />;
}
return <VersionLayout />;
};
export default Version;

View File

@ -0,0 +1 @@
export { default } from './Version';

View File

@ -0,0 +1,38 @@
import React from 'react';
import { MemoryRouter } from 'react-router';
import { fireEvent, render, screen } from '../../test/test-react-testing-library';
import Forbidden from './Forbidden';
const mockHistory = jest.fn();
jest.mock('react-router-dom', () => ({
useHistory: () => ({
push: mockHistory,
}),
}));
describe('<Forbidden /> component', () => {
test('should load the component in default state', () => {
render(
<MemoryRouter>
<Forbidden />
</MemoryRouter>
);
expect(screen.getByTestId('LockIcon')).toBeInTheDocument();
expect(screen.getByText('error.401.sorry-no-access')).toBeInTheDocument();
expect(screen.getByText('button.go-to-the-home-page')).toBeInTheDocument();
});
test('go to Home Page button click', async () => {
const { getByTestId } = render(
<MemoryRouter>
<Forbidden />
</MemoryRouter>
);
const node = getByTestId('not-found-go-to-home-button');
fireEvent.click(node);
expect(mockHistory).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,56 @@
/* eslint-disable react/forbid-component-props */
/* eslint-disable verdaccio/jsx-no-style */
import styled from '@emotion/styled';
import LockIcon from '@mui/icons-material/Lock';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { Theme } from '../..';
import Heading from '../Heading';
const Foebidden: React.FC = () => {
const history = useHistory();
const { t } = useTranslation();
const handleGoHome = useCallback(() => {
history.push('/');
}, [history]);
return (
<Box
alignItems="center"
data-testid="404"
display="flex"
flexDirection="column"
flexGrow={1}
justifyContent="center"
p={2}
>
<Container>
<LockIcon color="primary" style={{ fontSize: 236 }} />
</Container>
<StyledHeading className="not-found-text" variant="h4">
{t('error.401.sorry-no-access')}
</StyledHeading>
<Button data-testid="not-found-go-to-home-button" onClick={handleGoHome} variant="contained">
{t('button.go-to-the-home-page')}
</Button>
</Box>
);
};
export default Foebidden;
const Container = styled('div')({
margin: '0 auto',
});
const StyledHeading = styled(Heading)<{ theme?: Theme }>(({ theme }) => ({
color: theme?.palette.mode === 'light' ? theme?.palette.primary.main : theme?.palette.white,
marginBottom: 16,
}));

View File

@ -0,0 +1 @@
export { default } from './Forbidden';

View File

@ -1,4 +1,6 @@
/* eslint-disable verdaccio/jsx-no-style */
import styled from '@emotion/styled';
import FolderOffIcon from '@mui/icons-material/FolderOff';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import React, { useCallback } from 'react';
@ -7,8 +9,6 @@ import { useHistory } from 'react-router-dom';
import { Theme } from '../../';
import Heading from '../Heading';
// @ts-ignore
import PackageImg from './img/package.svg';
const NotFound: React.FC = () => {
const history = useHistory();
@ -28,7 +28,9 @@ const NotFound: React.FC = () => {
justifyContent="center"
p={2}
>
<EmptyPackage alt={t('error.404.page-not-found')} src={PackageImg} />
<Container>
<FolderOffIcon color="primary" style={{ fontSize: 236 }} />
</Container>
<StyledHeading className="not-found-text" variant="h4">
{t('error.404.sorry-we-could-not-find-it')}
</StyledHeading>
@ -41,8 +43,7 @@ const NotFound: React.FC = () => {
export default NotFound;
const EmptyPackage = styled('img')({
width: '150px',
const Container = styled('div')({
margin: '0 auto',
});

View File

@ -1,7 +1,7 @@
import React from 'react';
import { MemoryRouter } from 'react-router';
import { fireEvent, render } from '../../test/test-react-testing-library';
import { fireEvent, render, screen } from '../../test/test-react-testing-library';
import NotFound from './NotFound';
const mockHistory = jest.fn();
@ -14,12 +14,15 @@ jest.mock('react-router-dom', () => ({
describe('<NotFound /> component', () => {
test('should load the component in default state', () => {
const { container } = render(
render(
<MemoryRouter>
<NotFound />
</MemoryRouter>
);
expect(container.firstChild).toMatchSnapshot();
expect(screen.getByTestId('FolderOffIcon')).toBeInTheDocument();
expect(screen.getByText('button.go-to-the-home-page')).toBeInTheDocument();
expect(screen.getByText('error.404.sorry-we-could-not-find-it')).toBeInTheDocument();
});
test('go to Home Page button click', async () => {

View File

@ -1,171 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<NotFound /> component should load the component in default state 1`] = `
.emotion-0 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
-ms-flex-positive: 1;
flex-grow: 1;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
padding: 16px;
}
.emotion-1 {
width: 150px;
margin: 0 auto;
}
.emotion-4 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 2.125rem;
line-height: 1.235;
color: #4b5e40;
margin-bottom: 16px;
}
.emotion-5 {
display: -webkit-inline-box;
display: -webkit-inline-flex;
display: -ms-inline-flexbox;
display: inline-flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
position: relative;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
background-color: transparent;
outline: 0;
border: 0;
margin: 0;
border-radius: 0;
padding: 0;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
vertical-align: middle;
-moz-appearance: none;
-webkit-appearance: none;
-webkit-text-decoration: none;
text-decoration: none;
color: inherit;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 500;
font-size: 0.875rem;
line-height: 1.75;
text-transform: uppercase;
min-width: 64px;
padding: 6px 16px;
border-radius: 4px;
-webkit-transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
color: #fff;
background-color: #4b5e40;
box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12);
}
.emotion-5::-moz-focus-inner {
border-style: none;
}
.emotion-5.Mui-disabled {
pointer-events: none;
cursor: default;
}
@media print {
.emotion-5 {
-webkit-print-color-adjust: exact;
color-adjust: exact;
}
}
.emotion-5:hover {
-webkit-text-decoration: none;
text-decoration: none;
background-color: rgb(52, 65, 44);
box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12);
}
@media (hover: none) {
.emotion-5:hover {
background-color: #4b5e40;
}
}
.emotion-5:active {
box-shadow: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12);
}
.emotion-5.Mui-focusVisible {
box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12);
}
.emotion-5.Mui-disabled {
color: rgba(0, 0, 0, 0.26);
box-shadow: none;
background-color: rgba(0, 0, 0, 0.12);
}
.emotion-6 {
overflow: hidden;
pointer-events: none;
position: absolute;
z-index: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
border-radius: inherit;
}
<div
class="MuiBox-root emotion-0"
data-testid="404"
>
<img
alt="error.404.page-not-found"
class="emotion-1 emotion-2"
src="[object Object]"
/>
<h4
class="MuiTypography-root MuiTypography-h4 not-found-text emotion-3 emotion-4"
>
error.404.sorry-we-could-not-find-it
</h4>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium emotion-5"
data-testid="not-found-go-to-home-button"
tabindex="0"
type="button"
>
button.go-to-the-home-page
<span
class="MuiTouchRipple-root emotion-6"
/>
</button>
</div>
`;

View File

@ -22,6 +22,7 @@ export { default as Label } from './components/Label';
export { default as Logo } from './components/Logo';
export { default as MenuItem } from './components/MenuItem';
export { default as NotFound } from './components/NotFound';
export { default as Forbidden } from './components/Forbidden';
export { default as LoginDialog } from './components/LoginDialog';
export { default as Search } from './components/Search';
export { default as HeaderInfoDialog } from './components/HeaderInfoDialog';

View File

@ -27,3 +27,13 @@ export const DetailJquery: any = () => (
</Route>
</MemoryRouter>
);
export const DetailForbidden: any = () => (
<MemoryRouter initialEntries={[`/-/web/detail/JSONStream`]}>
<Route exact={true} path="/-/web/detail/:package">
<VersionProvider>
<Detail />
</VersionProvider>
</Route>
</MemoryRouter>
);

View File

@ -14,7 +14,8 @@ describe('api', () => {
const handled = await handleResponseType(response);
expect(handled).toEqual([ok, responseText]);
expect(handled[0]).toBeFalsy();
expect(handled[1]).toBeDefined();
});
test('should test tgz scenario', async () => {
@ -123,7 +124,7 @@ describe('api', () => {
})
);
await expect(api.request('/resource')).rejects.toThrow(new Error('something went wrong'));
await expect(api.request('/resource')).rejects.toThrow(new Error('Unknown error'));
});
test('when api returns an error 5.x.x', async () => {

View File

@ -1,5 +1,10 @@
import storage from './storage';
class CustomError extends Error {
// @ts-ignore
code: number;
}
/**
* Handles response according to content type
* @param {object} response
@ -25,7 +30,8 @@ export function handleResponseType(response: Response): Promise<[boolean, any]>
}
}
return Promise.all([response.ok, response.text()]);
// error handling
return Promise.all([response.ok, response]);
}
const AuthHeader = 'Authorization';
@ -55,12 +61,13 @@ class API {
})
.then(handleResponseType)
.then((response) => {
if (response[0]) {
resolve(response[1]);
const [ok, data] = response;
if (ok === true) {
resolve(data);
} else {
// eslint-disable-next-line no-console
console.error(response);
reject(new Error('something went wrong'));
const error = new CustomError(data?.statusText ?? 'Unknown error');
error.code = data?.status ?? 500;
reject(error);
}
})
.catch((error) => {

View File

@ -33,6 +33,18 @@ export const manifest = createModel<RootModel>()({
return {
...state,
hasNotBeenFound: true,
forbidden: false,
manifest: undefined,
packageName: undefined,
packageVersion: undefined,
readme: undefined,
};
},
forbidden(state) {
return {
...state,
forbidden: true,
hasNotBeenFound: false,
manifest: undefined,
packageName: undefined,
packageVersion: undefined,
@ -50,6 +62,7 @@ export const manifest = createModel<RootModel>()({
...state,
isError: true,
hasNotBeenFound: false,
forbidden: false,
manifest: undefined,
packageName: undefined,
packageVersion: undefined,
@ -64,6 +77,7 @@ export const manifest = createModel<RootModel>()({
packageVersion,
readme,
hasNotBeenFound: false,
forbidden: false,
};
},
},
@ -91,7 +105,11 @@ export const manifest = createModel<RootModel>()({
);
dispatch.manifest.saveManifest({ packageName, packageVersion, manifest, readme });
} catch (error: any) {
dispatch.manifest.notFound();
if (error.code === 404) {
dispatch.manifest.notFound();
} else {
dispatch.manifest.forbidden();
}
}
},
}),