mirror of https://github.com/portainer/portainer
refactor(app): create access-control-form react component [EE-2332] (#6346)
* refactor(app): create access-control-form react component [EE-2332] fix [EE-2332] * chore(tests): setup msw for async tests and stories chore(sb): add msw support for storybook * refactor(access-control): move loading into component * fix(app): fix users and teams selector stories * chore(access-control): write test for validationpull/6108/head
parent
8dbb802fb1
commit
ecd0eb6170
|
@ -31,4 +31,5 @@ module.exports = {
|
|||
core: {
|
||||
builder: 'webpack5',
|
||||
},
|
||||
staticDirs: ['./public'],
|
||||
};
|
||||
|
|
|
@ -1,6 +1,22 @@
|
|||
import '../app/assets/css';
|
||||
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswDecorator } from 'msw-storybook-addon';
|
||||
import { handlers } from '@/setup-tests/server-handlers';
|
||||
|
||||
// Initialize MSW
|
||||
initMSW({
|
||||
onUnhandledRequest: ({ method, url }) => {
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
console.error(`Unhandled ${method} request to ${url}.
|
||||
|
||||
This exception has been only logged in the console, however, it's strongly recommended to resolve this error as you don't want unmocked data in Storybook stories.
|
||||
|
||||
If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses
|
||||
`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
|
@ -10,6 +26,9 @@ export const parameters = {
|
|||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
};
|
||||
|
||||
export const decorators = [
|
||||
|
@ -18,4 +37,5 @@ export const decorators = [
|
|||
<Story />
|
||||
</UIRouter>
|
||||
),
|
||||
mswDecorator,
|
||||
];
|
||||
|
|
|
@ -0,0 +1,328 @@
|
|||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (0.36.3).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929';
|
||||
const bypassHeaderName = 'x-msw-bypass';
|
||||
const activeClientIds = new Set();
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
return self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', async function (event) {
|
||||
return self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id;
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId);
|
||||
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll();
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId);
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId);
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId;
|
||||
});
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the "main" client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId);
|
||||
|
||||
if (client.frameType === 'top-level') {
|
||||
return client;
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll();
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible';
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event);
|
||||
const response = await getResponse(event, client, requestId);
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
(async function () {
|
||||
const clonedResponse = response.clone();
|
||||
sendToClient(client, {
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
type: clonedResponse.type,
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
body: clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||
headers: serializeHeaders(clonedResponse.headers),
|
||||
redirected: clonedResponse.redirected,
|
||||
},
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event;
|
||||
const requestClone = request.clone();
|
||||
const getOriginalResponse = () => fetch(requestClone);
|
||||
|
||||
// Bypass mocking when the request client is not active.
|
||||
if (!client) {
|
||||
return getOriginalResponse();
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return await getOriginalResponse();
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header
|
||||
if (requestClone.headers.get(bypassHeaderName) === 'true') {
|
||||
const cleanRequestHeaders = serializeHeaders(requestClone.headers);
|
||||
|
||||
// Remove the bypass header to comply with the CORS preflight check.
|
||||
delete cleanRequestHeaders[bypassHeaderName];
|
||||
|
||||
const originalRequest = new Request(requestClone, {
|
||||
headers: new Headers(cleanRequestHeaders),
|
||||
});
|
||||
|
||||
return fetch(originalRequest);
|
||||
}
|
||||
|
||||
// Send the request to the client-side MSW.
|
||||
const reqHeaders = serializeHeaders(request.headers);
|
||||
const body = await request.text();
|
||||
|
||||
const clientMessage = await sendToClient(client, {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: reqHeaders,
|
||||
cache: request.cache,
|
||||
mode: request.mode,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body,
|
||||
bodyUsed: request.bodyUsed,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
});
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_SUCCESS': {
|
||||
return delayPromise(() => respondWithMock(clientMessage), clientMessage.payload.delay);
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return getOriginalResponse();
|
||||
}
|
||||
|
||||
case 'NETWORK_ERROR': {
|
||||
const { name, message } = clientMessage.payload;
|
||||
const networkError = new Error(message);
|
||||
networkError.name = name;
|
||||
|
||||
// Rejecting a request Promise emulates a network error.
|
||||
throw networkError;
|
||||
}
|
||||
|
||||
case 'INTERNAL_ERROR': {
|
||||
const parsedBody = JSON.parse(clientMessage.payload.body);
|
||||
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Uncaught exception in the request handler for "%s %s":
|
||||
|
||||
${parsedBody.location}
|
||||
|
||||
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
|
||||
`,
|
||||
request.method,
|
||||
request.url
|
||||
);
|
||||
|
||||
return respondWithMock(clientMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return getOriginalResponse();
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event;
|
||||
const accept = request.headers.get('accept') || '';
|
||||
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = uuidv4();
|
||||
|
||||
return event.respondWith(
|
||||
handleRequest(event, requestId).catch((error) => {
|
||||
if (error.name === 'NetworkError') {
|
||||
console.warn('[MSW] Successfully emulated a network error for the "%s %s" request.', request.method, request.url);
|
||||
return;
|
||||
}
|
||||
|
||||
// At this point, any exception indicates an issue with the original request/response.
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||
request.method,
|
||||
request.url,
|
||||
`${error.name}: ${error.message}`
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
function serializeHeaders(headers) {
|
||||
const reqHeaders = {};
|
||||
headers.forEach((value, name) => {
|
||||
reqHeaders[name] = reqHeaders[name] ? [].concat(reqHeaders[name]).concat(value) : value;
|
||||
});
|
||||
return reqHeaders;
|
||||
}
|
||||
|
||||
function sendToClient(client, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error);
|
||||
}
|
||||
|
||||
resolve(event.data);
|
||||
};
|
||||
|
||||
client.postMessage(JSON.stringify(message), [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function delayPromise(cb, duration) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(cb()), duration);
|
||||
});
|
||||
}
|
||||
|
||||
function respondWithMock(clientMessage) {
|
||||
return new Response(clientMessage.payload.body, {
|
||||
...clientMessage.payload,
|
||||
headers: clientMessage.payload.headers,
|
||||
});
|
||||
}
|
||||
|
||||
function uuidv4() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c == 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
|
@ -21,7 +21,7 @@ export function BoxSelector<T extends number | string>({
|
|||
onChange,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className={clsx('boxselector_wrapper', styles.root)}>
|
||||
<div className={clsx('boxselector_wrapper', styles.root)} role="radiogroup">
|
||||
{options.map((option) => (
|
||||
<BoxSelectorItem
|
||||
key={option.id}
|
||||
|
@ -43,7 +43,7 @@ export function buildOption<T extends number | string>(
|
|||
label: string,
|
||||
description: string,
|
||||
value: T,
|
||||
feature: FeatureId
|
||||
feature?: FeatureId
|
||||
): BoxSelectorOption<T> {
|
||||
return { id, icon, label, description, value, feature };
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { TeamViewModel } from '@/portainer/models/team';
|
||||
|
||||
export function createMockTeam(id: number, name: string): TeamViewModel {
|
||||
return {
|
||||
Id: id,
|
||||
Name: name,
|
||||
Checked: false,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { TeamsSelector } from './TeamsSelector';
|
||||
import { createMockTeam } from './TeamsSelector.mocks';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/TeamsSelector',
|
||||
component: TeamsSelector,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
export { Example };
|
||||
|
||||
function Example() {
|
||||
const [selectedTeams, setSelectedTeams] = useState([1]);
|
||||
|
||||
const teams = [createMockTeam(1, 'team1'), createMockTeam(2, 'team2')];
|
||||
|
||||
return (
|
||||
<TeamsSelector
|
||||
value={selectedTeams}
|
||||
onChange={setSelectedTeams}
|
||||
teams={teams}
|
||||
placeholder="Select one or more teams"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import Select from 'react-select';
|
||||
|
||||
import { Team, TeamId } from '@/portainer/teams/types';
|
||||
|
||||
interface Props {
|
||||
value: TeamId[];
|
||||
onChange(value: TeamId[]): void;
|
||||
teams: Team[];
|
||||
dataCy?: string;
|
||||
inputId?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function TeamsSelector({
|
||||
value,
|
||||
onChange,
|
||||
teams,
|
||||
dataCy,
|
||||
inputId,
|
||||
placeholder,
|
||||
}: Props) {
|
||||
return (
|
||||
<Select
|
||||
isMulti
|
||||
getOptionLabel={(team) => team.Name}
|
||||
getOptionValue={(team) => String(team.Id)}
|
||||
options={teams}
|
||||
value={teams.filter((team) => value.includes(team.Id))}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={(selectedTeams) =>
|
||||
onChange(selectedTeams.map((team) => team.Id))
|
||||
}
|
||||
data-cy={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { TeamsSelector } from './TeamsSelector';
|
|
@ -11,8 +11,10 @@ const meta: Meta = {
|
|||
|
||||
export default meta;
|
||||
|
||||
export function Example() {
|
||||
const [selectedUsers, setSelectedUsers] = useState([10]);
|
||||
export { Example };
|
||||
|
||||
function Example() {
|
||||
const [selectedUsers, setSelectedUsers] = useState([1]);
|
||||
|
||||
const users = [createMockUser(1, 'user1'), createMockUser(2, 'user2')];
|
||||
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import Select from 'react-select';
|
||||
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
type UserId = number;
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
interface Props {
|
||||
value: UserId[];
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { Meta, Story } from '@storybook/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { AccessControlForm } from './AccessControlForm';
|
||||
import { AccessControlFormData } from './model';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/AccessControlForm',
|
||||
component: AccessControlForm,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
enum Role {
|
||||
Admin = 1,
|
||||
User,
|
||||
}
|
||||
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
interface Args {
|
||||
userRole: Role;
|
||||
}
|
||||
|
||||
function Template({ userRole }: Args) {
|
||||
const defaults = new AccessControlFormData();
|
||||
defaults.ownership =
|
||||
userRole === Role.Admin
|
||||
? ResourceControlOwnership.ADMINISTRATORS
|
||||
: ResourceControlOwnership.PRIVATE;
|
||||
|
||||
const [value, setValue] = useState(defaults);
|
||||
|
||||
const userProviderState = useMemo(
|
||||
() => ({ user: new UserViewModel({ Role: userRole }) }),
|
||||
[userRole]
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UserContext.Provider value={userProviderState}>
|
||||
<AccessControlForm values={value} onChange={setValue} />
|
||||
</UserContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const AdminAccessControl: Story<Args> = Template.bind({});
|
||||
AdminAccessControl.args = {
|
||||
userRole: Role.Admin,
|
||||
};
|
||||
|
||||
export const NonAdminAccessControl: Story<Args> = Template.bind({});
|
||||
NonAdminAccessControl.args = {
|
||||
userRole: Role.User,
|
||||
};
|
|
@ -0,0 +1,320 @@
|
|||
import { server, rest } from '@/setup-tests/server';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import {
|
||||
renderWithQueryClient,
|
||||
within,
|
||||
waitFor,
|
||||
} from '@/react-tools/test-utils';
|
||||
import { Team } from '@/portainer/teams/types';
|
||||
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
|
||||
import { createMockTeams } from '@/react-tools/test-mocks';
|
||||
|
||||
import { AccessControlForm } from './AccessControlForm';
|
||||
import { AccessControlFormData } from './model';
|
||||
|
||||
test('renders correctly', async () => {
|
||||
const values: AccessControlFormData = new AccessControlFormData();
|
||||
|
||||
const { findByText } = await renderComponent(values);
|
||||
|
||||
expect(await findByText('Access control')).toBeVisible();
|
||||
});
|
||||
|
||||
test('when AccessControlEnabled is true, ownership selector should be visible', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
|
||||
const { queryByRole } = await renderComponent(values);
|
||||
|
||||
expect(queryByRole('radiogroup')).toBeVisible();
|
||||
});
|
||||
|
||||
test('when AccessControlEnabled is false, ownership selector should be hidden', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
accessControlEnabled: false,
|
||||
};
|
||||
|
||||
const { queryByRole } = await renderComponent(values);
|
||||
|
||||
expect(queryByRole('radiogroup')).toBeNull();
|
||||
});
|
||||
|
||||
test('when hideTitle is true, title should be hidden', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
|
||||
const { queryByRole } = await renderComponent(values, jest.fn(), {
|
||||
hideTitle: true,
|
||||
});
|
||||
|
||||
expect(queryByRole('Access control')).toBeNull();
|
||||
});
|
||||
|
||||
test('when isAdmin and AccessControlEnabled, ownership selector should admin and restricted options', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
expect(await selectorQueries.findByLabelText(/Administrator/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when isAdmin, AccessControlEnabled and admin ownership is selected, no extra options are visible', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.ADMINISTRATORS,
|
||||
};
|
||||
|
||||
const { findByRole, queryByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn(),
|
||||
{
|
||||
isAdmin: true,
|
||||
}
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Administrator/)).toBeChecked();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).not.toBeChecked();
|
||||
|
||||
expect(queryByLabelText('extra-options')).toBeNull();
|
||||
});
|
||||
|
||||
test('when isAdmin, AccessControlEnabled and restricted ownership is selected, show team and users selectors', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.RESTRICTED,
|
||||
};
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn(),
|
||||
{
|
||||
isAdmin: true,
|
||||
}
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(
|
||||
await selectorQueries.findByLabelText(/Administrator/)
|
||||
).not.toBeChecked();
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeChecked();
|
||||
|
||||
const extraOptions = await findByLabelText('extra-options');
|
||||
expect(extraOptions).toBeVisible();
|
||||
|
||||
if (!extraOptions) {
|
||||
throw new Error('extra options section is missing');
|
||||
}
|
||||
|
||||
const extraQueries = within(extraOptions);
|
||||
expect(await extraQueries.findByText(/Authorized users/)).toBeVisible();
|
||||
expect(await extraQueries.findByText(/Authorized teams/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not an admin and access control is enabled and no teams, should have only private option', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
teams: [],
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(selectorQueries.queryByLabelText(/Private/)).toBeVisible();
|
||||
expect(selectorQueries.queryByLabelText(/Restricted/)).toBeNull();
|
||||
});
|
||||
|
||||
test('when user is not an admin and access control is enabled and there is 1 team, should have private and restricted options', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
teams: createMockTeams(1),
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Private/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not an admin, access control is enabled, there are more then 1 team and ownership is restricted, team selector should be visible', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.RESTRICTED,
|
||||
};
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Private/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
|
||||
const extraOptions = await findByLabelText('extra-options');
|
||||
expect(extraOptions).toBeVisible();
|
||||
|
||||
if (!extraOptions) {
|
||||
throw new Error('extra options section is missing');
|
||||
}
|
||||
|
||||
const extraQueries = within(extraOptions);
|
||||
expect(extraQueries.queryByLabelText(/Authorized teams/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not an admin, access control is enabled, there is 1 team and ownership is restricted, team selector not should be visible', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.RESTRICTED,
|
||||
};
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn(),
|
||||
{
|
||||
teams: createMockTeams(1),
|
||||
isAdmin: false,
|
||||
}
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Private/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
|
||||
const extraOptions = await findByLabelText('extra-options');
|
||||
expect(extraOptions).toBeVisible();
|
||||
|
||||
if (!extraOptions) {
|
||||
throw new Error('extra options section is missing');
|
||||
}
|
||||
|
||||
const extraQueries = within(extraOptions);
|
||||
expect(extraQueries.queryByText(/Authorized teams/)).toBeNull();
|
||||
});
|
||||
|
||||
test('when user is not an admin, access control is enabled, and ownership is restricted, user selector not should be visible', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.RESTRICTED,
|
||||
};
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn(),
|
||||
{
|
||||
isAdmin: false,
|
||||
}
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const extraOptions = await findByLabelText('extra-options');
|
||||
expect(extraOptions).toBeVisible();
|
||||
|
||||
if (!extraOptions) {
|
||||
throw new Error('extra options section is missing');
|
||||
}
|
||||
const extraQueries = within(extraOptions);
|
||||
|
||||
expect(extraQueries.queryByText(/Authorized users/)).toBeNull();
|
||||
});
|
||||
|
||||
interface AdditionalProps {
|
||||
teams?: Team[];
|
||||
users?: UserViewModel[];
|
||||
isAdmin?: boolean;
|
||||
hideTitle?: boolean;
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
}
|
||||
|
||||
async function renderComponent(
|
||||
values: AccessControlFormData,
|
||||
onChange = jest.fn(),
|
||||
{ isAdmin = false, hideTitle = false, teams, users }: AdditionalProps = {}
|
||||
) {
|
||||
const user = new UserViewModel({ Username: 'user', Role: isAdmin ? 1 : 2 });
|
||||
const state = { user };
|
||||
|
||||
if (teams) {
|
||||
server.use(rest.get('/api/teams', (req, res, ctx) => res(ctx.json(teams))));
|
||||
}
|
||||
|
||||
if (users) {
|
||||
server.use(rest.get('/api/users', (req, res, ctx) => res(ctx.json(users))));
|
||||
}
|
||||
|
||||
const renderResult = renderWithQueryClient(
|
||||
<UserContext.Provider value={state}>
|
||||
<AccessControlForm
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
hideTitle={hideTitle}
|
||||
/>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await renderResult.findByLabelText(/Enable access control/)
|
||||
).toBeVisible()
|
||||
);
|
||||
|
||||
return renderResult;
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
import _ from 'lodash';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
|
||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { Team } from '@/portainer/teams/types';
|
||||
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||
|
||||
import { AccessControlFormData } from './model';
|
||||
import { UsersField } from './UsersField';
|
||||
import { TeamsField } from './TeamsField';
|
||||
import { useLoadState } from './useLoadState';
|
||||
|
||||
export interface Props {
|
||||
values: AccessControlFormData;
|
||||
onChange(values: AccessControlFormData): void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
||||
const { users, teams, isLoading } = useLoadState();
|
||||
|
||||
const { user } = useUser();
|
||||
const isAdmin = user?.Role === 1;
|
||||
|
||||
const options = useOptions(isAdmin, teams);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(partialValues: Partial<typeof values>) => {
|
||||
onChange({ ...values, ...partialValues });
|
||||
},
|
||||
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
if (isLoading || !teams || !users) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideTitle && <FormSectionTitle>Access control</FormSectionTitle>}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={values.accessControlEnabled}
|
||||
name="ownership"
|
||||
label="Enable access control"
|
||||
tooltip="When enabled, you can restrict the access and management of this resource."
|
||||
onChange={(accessControlEnabled) =>
|
||||
handleChange({ accessControlEnabled })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{values.accessControlEnabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<BoxSelector
|
||||
radioName="access-control"
|
||||
value={values.ownership}
|
||||
options={options}
|
||||
onChange={(ownership) => handleChange({ ownership })}
|
||||
/>
|
||||
</div>
|
||||
{values.ownership === RCO.RESTRICTED && (
|
||||
<div aria-label="extra-options">
|
||||
{isAdmin && (
|
||||
<UsersField
|
||||
users={users}
|
||||
onChange={(authorizedUsers) =>
|
||||
handleChange({ authorizedUsers })
|
||||
}
|
||||
value={values.authorizedUsers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isAdmin || teams.length > 1) && (
|
||||
<TeamsField
|
||||
teams={teams}
|
||||
overrideTooltip={
|
||||
!isAdmin && teams.length > 1
|
||||
? 'As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
onChange={(authorizedTeams) =>
|
||||
handleChange({ authorizedTeams })
|
||||
}
|
||||
value={values.authorizedTeams}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useOptions(isAdmin: boolean, teams?: Team[]) {
|
||||
const [options, setOptions] = useState<Array<BoxSelectorOption<RCO>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(isAdmin ? adminOptions() : nonAdminOptions(teams));
|
||||
}, [isAdmin, teams]);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function adminOptions() {
|
||||
return [
|
||||
buildOption(
|
||||
'access_administrators',
|
||||
ownershipIcon('administrators'),
|
||||
'Administrators',
|
||||
'I want to restrict the management of this resource to administrators only',
|
||||
RCO.ADMINISTRATORS
|
||||
),
|
||||
buildOption(
|
||||
'access_restricted',
|
||||
ownershipIcon('restricted'),
|
||||
'Restricted',
|
||||
'I want to restrict the management of this resource to a set of users and/or teams',
|
||||
RCO.RESTRICTED
|
||||
),
|
||||
];
|
||||
}
|
||||
function nonAdminOptions(teams?: Team[]) {
|
||||
return _.compact([
|
||||
buildOption(
|
||||
'access_private',
|
||||
ownershipIcon('private'),
|
||||
'Private',
|
||||
'I want to this resource to be manageable by myself only',
|
||||
RCO.PRIVATE
|
||||
),
|
||||
teams &&
|
||||
teams.length > 0 &&
|
||||
buildOption(
|
||||
'access_restricted',
|
||||
ownershipIcon('restricted'),
|
||||
'Restricted',
|
||||
teams.length === 1
|
||||
? `I want any member of my team (${teams[0].Name}) to be able to manage this resource`
|
||||
: 'I want to restrict the management of this resource to one or more of my teams',
|
||||
RCO.RESTRICTED
|
||||
),
|
||||
]);
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
import { validationSchema } from './AccessControlForm.validation';
|
||||
|
||||
test('when access control is disabled, should be valid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
const object = { accessControlEnabled: false };
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
||||
|
||||
test('when only access control is enabled, should be invalid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
|
||||
await expect(
|
||||
schema.validate({ accessControlEnabled: true }, { strict: true })
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
|
||||
test('when access control is enabled and ownership not restricted, should be valid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.PRIVATE,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
].forEach(async (ownership) => {
|
||||
const object = { accessControlEnabled: false, ownership };
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted and no teams or users, should be invalid', async () => {
|
||||
[true, false].forEach(async (isAdmin) => {
|
||||
const schema = validationSchema(isAdmin);
|
||||
|
||||
await expect(
|
||||
schema.validate(
|
||||
{
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
},
|
||||
{ strict: true }
|
||||
)
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted, user is admin but no users, should be valid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
|
||||
await expect(
|
||||
schema.validate(
|
||||
{
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
},
|
||||
{ strict: true }
|
||||
)
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted, user is admin with teams and users, should be valid', async () => {
|
||||
const schema = validationSchema(false);
|
||||
|
||||
const object = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [1],
|
||||
};
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted, user is not admin with teams, should be valid', async () => {
|
||||
const schema = validationSchema(false);
|
||||
|
||||
const object = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
};
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
import { object, string, array, number, bool } from 'yup';
|
||||
|
||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
export function validationSchema(isAdmin: boolean) {
|
||||
return object().shape({
|
||||
accessControlEnabled: bool(),
|
||||
ownership: string()
|
||||
.oneOf(Object.values(ResourceControlOwnership))
|
||||
.when('accessControlEnabled', {
|
||||
is: true,
|
||||
then: (schema) => schema.required(),
|
||||
}),
|
||||
authorizedUsers: array(number()).when(
|
||||
['accessControlEnabled', 'ownership'],
|
||||
{
|
||||
is: (
|
||||
accessControlEnabled: boolean,
|
||||
ownership: ResourceControlOwnership
|
||||
) =>
|
||||
isAdmin &&
|
||||
accessControlEnabled &&
|
||||
ownership === ResourceControlOwnership.RESTRICTED,
|
||||
then: (schema) =>
|
||||
schema.required('You must specify at least one user.'),
|
||||
}
|
||||
),
|
||||
authorizedTeams: array(number()).when(
|
||||
['accessControlEnabled', 'ownership'],
|
||||
{
|
||||
is: (
|
||||
accessControlEnabled: boolean,
|
||||
ownership: ResourceControlOwnership
|
||||
) =>
|
||||
accessControlEnabled &&
|
||||
ownership === ResourceControlOwnership.RESTRICTED,
|
||||
then: (schema) => schema.required('You must specify at least one team'),
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { TeamsSelector } from '@/portainer/components/TeamsSelector';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Link } from '@/portainer/components/Link';
|
||||
import { Team } from '@/portainer/teams/types';
|
||||
|
||||
interface Props {
|
||||
teams: Team[];
|
||||
value: number[];
|
||||
overrideTooltip?: string;
|
||||
onChange(value: number[]): void;
|
||||
}
|
||||
|
||||
export function TeamsField({ teams, value, overrideTooltip, onChange }: Props) {
|
||||
return (
|
||||
<FormControl
|
||||
label="Authorized teams"
|
||||
tooltip={
|
||||
teams.length > 0
|
||||
? overrideTooltip ||
|
||||
'You can select which team(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
inputId="teams-selector"
|
||||
>
|
||||
{teams.length > 0 ? (
|
||||
<TeamsSelector
|
||||
teams={teams}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
inputId="teams-selector"
|
||||
/>
|
||||
) : (
|
||||
<span className="small text-muted">
|
||||
You have not yet created any teams. Head over to the
|
||||
<Link to="portainer.teams">Teams view</Link> to manage teams.
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { UsersSelector } from '@/portainer/components/UsersSelector';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { Link } from '@/portainer/components/Link';
|
||||
|
||||
interface Props {
|
||||
users: UserViewModel[];
|
||||
value: number[];
|
||||
onChange(value: number[]): void;
|
||||
}
|
||||
|
||||
export function UsersField({ users, value, onChange }: Props) {
|
||||
return (
|
||||
<FormControl
|
||||
label="Authorized users"
|
||||
tooltip={
|
||||
users.length > 0
|
||||
? 'You can select which user(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
inputId="users-selector"
|
||||
>
|
||||
{users.length > 0 ? (
|
||||
<UsersSelector
|
||||
users={users}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
inputId="users-selector"
|
||||
/>
|
||||
) : (
|
||||
<span className="small text-muted">
|
||||
You have not yet created any users. Head over to the
|
||||
<Link to="portainer.users">Users view</Link> to manage users.
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team"`;
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team"`;
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be valid 1`] = `"You must specify at least one team"`;
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be valid 2`] = `"You must specify at least one team"`;
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted, user is admin but no users, should be valid 1`] = `"You must specify at least one user."`;
|
||||
|
||||
exports[`when only access control is enabled, should be invalid 1`] = `"ownership is a required field"`;
|
|
@ -0,0 +1 @@
|
|||
export { AccessControlForm } from './AccessControlForm';
|
|
@ -0,0 +1,61 @@
|
|||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import {
|
||||
ResourceControlType,
|
||||
ResourceControlViewModel,
|
||||
} from '@/portainer/models/resourceControl/resourceControl';
|
||||
|
||||
import { parseFromResourceControl } from './model';
|
||||
|
||||
test('when resource control supplied, if user is not admin, will change ownership to rc ownership', () => {
|
||||
[RCO.ADMINISTRATORS, RCO.RESTRICTED, RCO.PUBLIC, RCO.PRIVATE].forEach(
|
||||
(ownership) => {
|
||||
const resourceControl = buildResourceControl(ownership);
|
||||
|
||||
const actual = parseFromResourceControl(false, resourceControl.Ownership);
|
||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('when resource control supplied and user is admin, if resource ownership is not private , will change ownership to rc ownership', () => {
|
||||
[RCO.ADMINISTRATORS, RCO.RESTRICTED, RCO.PUBLIC].forEach((ownership) => {
|
||||
const resourceControl = buildResourceControl(ownership);
|
||||
|
||||
const actual = parseFromResourceControl(true, resourceControl.Ownership);
|
||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
||||
});
|
||||
});
|
||||
|
||||
test('when resource control supplied, if ownership is public, will disabled access control', () => {
|
||||
const resourceControl = buildResourceControl(RCO.PUBLIC);
|
||||
|
||||
const actual = parseFromResourceControl(false, resourceControl.Ownership);
|
||||
|
||||
expect(actual.accessControlEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('when isAdmin and resource control not supplied, ownership should be set to Administrator', () => {
|
||||
const actual = parseFromResourceControl(true);
|
||||
|
||||
expect(actual.ownership).toBe(RCO.ADMINISTRATORS);
|
||||
});
|
||||
|
||||
test('when resource control supplied, if user is admin and resource ownership is private, will change ownership to restricted', () => {
|
||||
const resourceControl = buildResourceControl(RCO.PRIVATE);
|
||||
|
||||
const actual = parseFromResourceControl(true, resourceControl.Ownership);
|
||||
expect(actual.ownership).toBe(RCO.RESTRICTED);
|
||||
});
|
||||
|
||||
function buildResourceControl(ownership: RCO): ResourceControlViewModel {
|
||||
return {
|
||||
UserAccesses: [],
|
||||
TeamAccesses: [],
|
||||
Ownership: ownership,
|
||||
Id: 1,
|
||||
Public: false,
|
||||
ResourceId: 1,
|
||||
System: false,
|
||||
Type: ResourceControlType.Config,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
import { TeamId } from '@/portainer/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
export class AccessControlFormData {
|
||||
accessControlEnabled = true;
|
||||
|
||||
ownership = ResourceControlOwnership.PRIVATE;
|
||||
|
||||
authorizedUsers: UserId[] = [];
|
||||
|
||||
authorizedTeams: TeamId[] = [];
|
||||
}
|
||||
|
||||
export function parseFromResourceControl(
|
||||
isAdmin: boolean,
|
||||
resourceControlOwnership?: ResourceControlOwnership
|
||||
): AccessControlFormData {
|
||||
const formData = new AccessControlFormData();
|
||||
|
||||
if (resourceControlOwnership) {
|
||||
let ownership = resourceControlOwnership;
|
||||
if (isAdmin && ownership === ResourceControlOwnership.PRIVATE) {
|
||||
ownership = ResourceControlOwnership.RESTRICTED;
|
||||
}
|
||||
|
||||
let accessControl = formData.accessControlEnabled;
|
||||
if (ownership === ResourceControlOwnership.PUBLIC) {
|
||||
accessControl = false;
|
||||
}
|
||||
|
||||
formData.ownership = ownership;
|
||||
formData.accessControlEnabled = accessControl;
|
||||
} else if (isAdmin) {
|
||||
formData.ownership = ResourceControlOwnership.ADMINISTRATORS;
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
<div>
|
||||
<div ng-if="!$ctrl.hideTitle" class="col-sm-12 form-section-title">
|
||||
Access control
|
||||
</div>
|
||||
<div ng-if="!$ctrl.hideTitle" class="col-sm-12 form-section-title"> Access control </div>
|
||||
<!-- access-control-switch -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
|
@ -9,18 +7,18 @@
|
|||
Enable access control
|
||||
<portainer-tooltip position="bottom" message="When enabled, you can restrict the access and management of this resource."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input name="ownership" type="checkbox" ng-model="$ctrl.formData.AccessControlEnabled" /><i></i> </label>
|
||||
<label class="switch" style="margin-left: 20px"> <input name="ownership" type="checkbox" ng-model="$ctrl.formData.AccessControlEnabled" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !access-control-switch -->
|
||||
<!-- restricted-access -->
|
||||
<div class="form-group" ng-if="$ctrl.formData.AccessControlEnabled" style="margin-bottom: 0;">
|
||||
<div class="form-group" ng-if="$ctrl.formData.AccessControlEnabled" style="margin-bottom: 0">
|
||||
<div class="boxselector_wrapper">
|
||||
<div ng-if="$ctrl.isAdmin">
|
||||
<input type="radio" id="access_administrators" ng-model="$ctrl.formData.Ownership" value="administrators" />
|
||||
<label for="access_administrators">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="'administrators' | ownershipicon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
<i ng-class="'administrators' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Administrators
|
||||
</div>
|
||||
<p>I want to restrict the management of this resource to administrators only</p>
|
||||
|
@ -30,40 +28,34 @@
|
|||
<input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" />
|
||||
<label for="access_restricted">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Restricted
|
||||
</div>
|
||||
<p>
|
||||
I want to restrict the management of this resource to a set of users and/or teams
|
||||
</p>
|
||||
<p> I want to restrict the management of this resource to a set of users and/or teams </p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="!$ctrl.isAdmin">
|
||||
<input type="radio" id="access_private" ng-model="$ctrl.formData.Ownership" value="private" />
|
||||
<label for="access_private">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="'private' | ownershipicon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
<i ng-class="'private' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Private
|
||||
</div>
|
||||
<p>
|
||||
I want to this resource to be manageable by myself only
|
||||
</p>
|
||||
<p> I want to this resource to be manageable by myself only </p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="!$ctrl.isAdmin && $ctrl.availableTeams.length > 0">
|
||||
<input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" />
|
||||
<label for="access_restricted">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Restricted
|
||||
</div>
|
||||
<p ng-if="$ctrl.availableTeams.length === 1">
|
||||
I want any member of my team (<b>{{ $ctrl.availableTeams[0].Name }}</b
|
||||
>) to be able to manage this resource
|
||||
</p>
|
||||
<p ng-if="$ctrl.availableTeams.length > 1">
|
||||
I want to restrict the management of this resource to one or more of my teams
|
||||
</p>
|
||||
<p ng-if="$ctrl.availableTeams.length > 1"> I want to restrict the management of this resource to one or more of my teams </p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -88,7 +80,7 @@
|
|||
message="As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource."
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<span ng-if="$ctrl.isAdmin && $ctrl.availableTeams.length === 0" class="small text-muted" style="margin-left: 20px;">
|
||||
<span ng-if="$ctrl.isAdmin && $ctrl.availableTeams.length === 0" class="small text-muted" style="margin-left: 20px">
|
||||
You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
|
||||
</span>
|
||||
<span
|
||||
|
@ -102,7 +94,7 @@
|
|||
helper-elements="filter"
|
||||
search-property="Name"
|
||||
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
|
||||
style="margin-left: 20px;"
|
||||
style="margin-left: 20px"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -119,7 +111,7 @@
|
|||
message="You can select which user(s) will be able to manage this resource."
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<span ng-if="$ctrl.availableUsers.length === 0" class="small text-muted" style="margin-left: 20px;">
|
||||
<span ng-if="$ctrl.availableUsers.length === 0" class="small text-muted" style="margin-left: 20px">
|
||||
You have not yet created any users. Head over to the <a ui-sref="portainer.users">Users view</a> to manage users.
|
||||
</span>
|
||||
<span
|
||||
|
@ -133,7 +125,7 @@
|
|||
helper-elements="filter"
|
||||
search-property="Username"
|
||||
translation="{nothingSelected: 'Select one or more users', search: 'Search...'}"
|
||||
style="margin-left: 20px;"
|
||||
style="margin-left: 20px"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
/**
|
||||
* @deprecated use only for angularjs components. For react components use ./model.ts
|
||||
*/
|
||||
export function AccessControlFormData() {
|
||||
this.AccessControlEnabled = true;
|
||||
this.Ownership = RCO.PRIVATE;
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { getTeams } from '@/portainer/teams/teams.service';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { getUsers } from '@/portainer/services/api/userService';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
export function useLoadState() {
|
||||
const { teams, isLoading: isLoadingTeams } = useTeams();
|
||||
|
||||
const { users, isLoading: isLoadingUsers } = useUsers();
|
||||
|
||||
return { teams, users, isLoading: isLoadingTeams || isLoadingUsers };
|
||||
}
|
||||
|
||||
function useTeams() {
|
||||
const { isError, error, isLoading, data } = useQuery('teams', () =>
|
||||
getTeams()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error('Failure', error as Error, 'Failed retrieving teams');
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
return { isLoading, teams: data };
|
||||
}
|
||||
|
||||
function useUsers() {
|
||||
const { isError, error, isLoading, data } = useQuery<
|
||||
unknown,
|
||||
unknown,
|
||||
UserViewModel[]
|
||||
>('users', () => getUsers());
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error('Failure', error as Error, 'Failed retrieving users');
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
return { isLoading, users: data };
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import { ResourceControlOwnership as RCO } from './resourceControlOwnership';
|
||||
|
||||
export function ResourceControlViewModel(data) {
|
||||
this.Id = data.Id;
|
||||
this.Type = data.Type;
|
||||
this.ResourceId = data.ResourceId;
|
||||
this.UserAccesses = data.UserAccesses;
|
||||
this.TeamAccesses = data.TeamAccesses;
|
||||
this.Public = data.Public;
|
||||
this.System = data.System;
|
||||
this.Ownership = determineOwnership(this);
|
||||
}
|
||||
|
||||
function determineOwnership(resourceControl) {
|
||||
if (resourceControl.Public) {
|
||||
return RCO.PUBLIC;
|
||||
} else if (resourceControl.UserAccesses.length === 1 && resourceControl.TeamAccesses.length === 0) {
|
||||
return RCO.PRIVATE;
|
||||
} else if (resourceControl.UserAccesses.length > 1 || resourceControl.TeamAccesses.length > 0) {
|
||||
return RCO.RESTRICTED;
|
||||
} else {
|
||||
return RCO.ADMINISTRATORS;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
import { ResourceControlOwnership as RCO } from './resourceControlOwnership';
|
||||
|
||||
export enum ResourceControlType {
|
||||
// Container represents a resource control associated to a Docker container
|
||||
Container = 1,
|
||||
// Service represents a resource control associated to a Docker service
|
||||
Service,
|
||||
// Volume represents a resource control associated to a Docker volume
|
||||
Volume,
|
||||
// Network represents a resource control associated to a Docker network
|
||||
Network,
|
||||
// Secret represents a resource control associated to a Docker secret
|
||||
Secret,
|
||||
// Stack represents a resource control associated to a stack composed of Docker services
|
||||
Stack,
|
||||
// Config represents a resource control associated to a Docker config
|
||||
Config,
|
||||
// CustomTemplate represents a resource control associated to a custom template
|
||||
CustomTemplate,
|
||||
// ContainerGroup represents a resource control associated to an Azure container group
|
||||
ContainerGroup,
|
||||
}
|
||||
|
||||
export interface ResourceControlResponse {
|
||||
Id: number;
|
||||
Type: ResourceControlType;
|
||||
ResourceId: string | number;
|
||||
UserAccesses: unknown[];
|
||||
TeamAccesses: unknown[];
|
||||
Public: boolean;
|
||||
AdministratorsOnly: boolean;
|
||||
System: boolean;
|
||||
}
|
||||
|
||||
export class ResourceControlViewModel {
|
||||
Id: number;
|
||||
|
||||
Type: ResourceControlType;
|
||||
|
||||
ResourceId: string | number;
|
||||
|
||||
UserAccesses: unknown[];
|
||||
|
||||
TeamAccesses: unknown[];
|
||||
|
||||
Public: boolean;
|
||||
|
||||
System: boolean;
|
||||
|
||||
Ownership: RCO;
|
||||
|
||||
constructor(data: ResourceControlResponse) {
|
||||
this.Id = data.Id;
|
||||
this.Type = data.Type;
|
||||
this.ResourceId = data.ResourceId;
|
||||
this.UserAccesses = data.UserAccesses;
|
||||
this.TeamAccesses = data.TeamAccesses;
|
||||
this.Public = data.Public;
|
||||
this.System = data.System;
|
||||
this.Ownership = determineOwnership(this);
|
||||
}
|
||||
}
|
||||
|
||||
function determineOwnership(resourceControl: ResourceControlViewModel) {
|
||||
if (resourceControl.Public) {
|
||||
return RCO.PUBLIC;
|
||||
}
|
||||
|
||||
if (
|
||||
resourceControl.UserAccesses.length === 1 &&
|
||||
resourceControl.TeamAccesses.length === 0
|
||||
) {
|
||||
return RCO.PRIVATE;
|
||||
}
|
||||
|
||||
if (
|
||||
resourceControl.UserAccesses.length > 1 ||
|
||||
resourceControl.TeamAccesses.length > 0
|
||||
) {
|
||||
return RCO.RESTRICTED;
|
||||
}
|
||||
|
||||
return RCO.ADMINISTRATORS;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export const ResourceControlOwnership = Object.freeze({
|
||||
PUBLIC: 'public',
|
||||
PRIVATE: 'private',
|
||||
RESTRICTED: 'restricted',
|
||||
ADMINISTRATORS: 'administrators',
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
export enum ResourceControlOwnership {
|
||||
PUBLIC = 'public',
|
||||
PRIVATE = 'private',
|
||||
RESTRICTED = 'restricted',
|
||||
ADMINISTRATORS = 'administrators',
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
import PortainerError from '../error';
|
||||
import { get as localStorageGet } from '../hooks/useLocalStorage';
|
||||
|
||||
import {
|
||||
|
@ -40,3 +41,17 @@ export function agentInterceptor(config: AxiosRequestConfig) {
|
|||
}
|
||||
|
||||
axiosApiInstance.interceptors.request.use(agentInterceptor);
|
||||
|
||||
export function parseAxiosError(err: Error, msg = '') {
|
||||
let resultErr = err;
|
||||
let resultMsg = msg;
|
||||
|
||||
if ('isAxiosError' in err) {
|
||||
const axiosError = err as AxiosError;
|
||||
resultErr = new Error(`${axiosError.response?.data.message}`);
|
||||
const msgDetails = axiosError.response?.data.details;
|
||||
resultMsg = msg ? `${msg}: ${msgDetails}` : msgDetails;
|
||||
}
|
||||
|
||||
return new PortainerError(resultMsg, resultErr);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { Team, TeamId } from './types';
|
||||
|
||||
export async function getTeams() {
|
||||
try {
|
||||
const { data } = await axios.get<Team[]>(buildUrl());
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: TeamId) {
|
||||
let url = '/teams';
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export type TeamId = number;
|
||||
|
||||
export interface Team {
|
||||
Id: TeamId;
|
||||
Name: string;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export type UserId = number;
|
|
@ -0,0 +1,22 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
export function createMockUsers(count: number) {
|
||||
return _.range(1, count + 1).map((value) => ({
|
||||
Id: value,
|
||||
Username: `user${value}`,
|
||||
Role: _.random(1, 3),
|
||||
UserTheme: '',
|
||||
RoleName: '',
|
||||
AuthenticationMethod: '',
|
||||
Checked: false,
|
||||
EndpointAuthorizations: {},
|
||||
PortainerAuthorizations: {},
|
||||
}));
|
||||
}
|
||||
|
||||
export function createMockTeams(count: number) {
|
||||
return _.range(1, count + 1).map((value) => ({
|
||||
Id: value,
|
||||
Name: `team${value}`,
|
||||
}));
|
||||
}
|
|
@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
|
|||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
|
||||
import { PropsWithChildren, ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
function Provider({ children }: PropsWithChildren<unknown>) {
|
||||
return <UIRouter plugins={[pushStateLocationPlugin]}>{children}</UIRouter>;
|
||||
|
@ -17,3 +18,21 @@ export * from '@testing-library/react';
|
|||
|
||||
// override render method
|
||||
export { customRender as render };
|
||||
|
||||
export function renderWithQueryClient(ui: React.ReactElement) {
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const { rerender, ...result } = customRender(
|
||||
<QueryClientProvider client={testQueryClient}>{ui}</QueryClientProvider>
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
rerender: (rerenderUi: React.ReactElement) =>
|
||||
rerender(
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{rerenderUi}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { rest } from 'msw';
|
||||
|
||||
import { createMockTeams, createMockUsers } from '../react-tools/test-mocks';
|
||||
|
||||
export const handlers = [
|
||||
rest.get('/api/teams', async (req, res, ctx) =>
|
||||
res(ctx.json(createMockTeams(10)))
|
||||
),
|
||||
rest.get('/api/users', async (req, res, ctx) =>
|
||||
res(ctx.json(createMockUsers(10)))
|
||||
),
|
||||
];
|
|
@ -0,0 +1,7 @@
|
|||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { handlers } from './server-handlers';
|
||||
|
||||
const server = setupServer(...handlers);
|
||||
export { server, rest };
|
|
@ -0,0 +1,10 @@
|
|||
// test/setup-env.js
|
||||
// add this to your setupFilesAfterEnv config in jest so it's imported for every test file
|
||||
import { server } from './server';
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
// if you need to add a handler after calling setupServer for some specific test
|
||||
// this will remove that handler for the rest of them
|
||||
// (which is important for test isolation):
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
|
@ -54,7 +54,7 @@ module.exports = {
|
|||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
globalSetup: `<rootDir>/app/setup-tests.js`,
|
||||
globalSetup: `<rootDir>/app/setup-tests/global-setup.js`,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
@ -135,7 +135,7 @@ module.exports = {
|
|||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
setupFilesAfterEnv: ['<rootDir>/app/setup-tests/setup-msw.ts'],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
|
|
@ -108,8 +108,10 @@
|
|||
"js-base64": "^3.7.2",
|
||||
"js-yaml": "^3.14.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
"msw": "^0.36.3",
|
||||
"ng-file-upload": "~12.2.13",
|
||||
"parse-duration": "^1.0.2",
|
||||
"rc-slider": "^9.7.5",
|
||||
|
@ -206,6 +208,7 @@
|
|||
"load-grunt-tasks": "^3.5.2",
|
||||
"lodash-webpack-plugin": "^0.11.6",
|
||||
"mini-css-extract-plugin": "1",
|
||||
"msw-storybook-addon": "^1.5.0",
|
||||
"ngtemplate-loader": "^2.1.0",
|
||||
"plop": "^2.6.0",
|
||||
"postcss": "7",
|
||||
|
@ -244,4 +247,4 @@
|
|||
"pre-commit": "lint-staged"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
279
yarn.lock
279
yarn.lock
|
@ -1772,6 +1772,26 @@
|
|||
call-me-maybe "^1.0.1"
|
||||
glob-to-regexp "^0.3.0"
|
||||
|
||||
"@mswjs/cookies@^0.1.6":
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@mswjs/cookies/-/cookies-0.1.6.tgz#176f77034ab6d7373ae5c94bcbac36fee8869249"
|
||||
integrity sha512-A53XD5TOfwhpqAmwKdPtg1dva5wrng2gH5xMvklzbd9WLTSVU953eCRa8rtrrm6G7Cy60BOGsBRN89YQK0mlKA==
|
||||
dependencies:
|
||||
"@types/set-cookie-parser" "^2.4.0"
|
||||
set-cookie-parser "^2.4.6"
|
||||
|
||||
"@mswjs/interceptors@^0.12.7":
|
||||
version "0.12.7"
|
||||
resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.12.7.tgz#0d1cd4cd31a0f663e0455993951201faa09d0909"
|
||||
integrity sha512-eGjZ3JRAt0Fzi5FgXiV/P3bJGj0NqsN7vBS0J0FO2AQRQ0jCKQS4lEFm4wvlSgKQNfeuc/Vz6d81VtU3Gkx/zg==
|
||||
dependencies:
|
||||
"@open-draft/until" "^1.0.3"
|
||||
"@xmldom/xmldom" "^0.7.2"
|
||||
debug "^4.3.2"
|
||||
headers-utils "^3.0.2"
|
||||
outvariant "^1.2.0"
|
||||
strict-event-emitter "^0.2.0"
|
||||
|
||||
"@nodelib/fs.scandir@2.1.3":
|
||||
version "2.1.3"
|
||||
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz"
|
||||
|
@ -1819,6 +1839,11 @@
|
|||
resolved "https://registry.npmjs.org/@nxmix/tokenize-ansi/-/tokenize-ansi-3.0.0.tgz"
|
||||
integrity sha512-37QMpFIiQ6J31tavjMFCuWs3YIqXIDCuGvPiDVofFqvgXq6vM+8LqU4sqibsvb9JX/1SIeDp+SedOqpq2qc7TA==
|
||||
|
||||
"@open-draft/until@^1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca"
|
||||
integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==
|
||||
|
||||
"@pmmmwh/react-refresh-webpack-plugin@^0.5.1":
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.3.tgz#b8f0e035f6df71b5c4126cb98de29f65188b9e7b"
|
||||
|
@ -2171,7 +2196,7 @@
|
|||
prop-types "^15.7.2"
|
||||
regenerator-runtime "^0.13.7"
|
||||
|
||||
"@storybook/addons@6.4.9":
|
||||
"@storybook/addons@6.4.9", "@storybook/addons@^6.0.0":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.4.9.tgz#43b5dabf6781d863fcec0a0b293c236b4d5d4433"
|
||||
integrity sha512-y+oiN2zd+pbRWwkf6aQj4tPDFn+rQkrv7fiVoMxsYub+kKyZ3CNOuTSJH+A1A+eBL6DmzocChUyO6jvZFuh6Dg==
|
||||
|
@ -3028,6 +3053,11 @@
|
|||
resolved "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz"
|
||||
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
|
||||
|
||||
"@types/cookie@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
|
||||
integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
|
||||
|
||||
"@types/eslint-scope@^3.7.0":
|
||||
version "3.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e"
|
||||
|
@ -3117,6 +3147,14 @@
|
|||
"@types/through" "*"
|
||||
rxjs "^6.4.0"
|
||||
|
||||
"@types/inquirer@^8.1.3":
|
||||
version "8.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-8.1.3.tgz#dfda4c97cdbe304e4dceb378a80f79448ea5c8fe"
|
||||
integrity sha512-AayK4ZL5ssPzR1OtnOLGAwpT0Dda3Xi/h1G0l1oJDNrowp7T1423q4Zb8/emr7tzRlCy4ssEri0LWVexAqHyKQ==
|
||||
dependencies:
|
||||
"@types/through" "*"
|
||||
rxjs "^7.2.0"
|
||||
|
||||
"@types/interpret@*":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/@types/interpret/-/interpret-1.1.1.tgz"
|
||||
|
@ -3178,6 +3216,11 @@
|
|||
dependencies:
|
||||
"@types/sizzle" "*"
|
||||
|
||||
"@types/js-levenshtein@^1.1.0":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5"
|
||||
integrity sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==
|
||||
|
||||
"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
version "7.0.9"
|
||||
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz"
|
||||
|
@ -3362,6 +3405,13 @@
|
|||
resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz"
|
||||
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
|
||||
|
||||
"@types/set-cookie-parser@^2.4.0":
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.2.tgz#b6a955219b54151bfebd4521170723df5e13caad"
|
||||
integrity sha512-fBZgytwhYAUkj/jC/FAV4RQ5EerRup1YQsXQCh8rZfiHkc4UahC192oH0smGwsXol3cL3A5oETuAHeQHmhXM4w==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/sinonjs__fake-timers@^6.0.2":
|
||||
version "6.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.4.tgz#0ecc1b9259b76598ef01942f547904ce61a6a77d"
|
||||
|
@ -3860,6 +3910,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.6.0.tgz#2c275aa05c895eccebbfc34cfb223c6e8bd591a2"
|
||||
integrity sha512-ZkVeqEmRpBV2GHvjjUZqEai2PpUbuq8Bqd//vEYsp63J8WyexI8ppCqVS3Zs0QADf6aWuPdU+0XsPI647PVlQA==
|
||||
|
||||
"@xmldom/xmldom@^0.7.2":
|
||||
version "0.7.5"
|
||||
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d"
|
||||
integrity sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A==
|
||||
|
||||
"@xtuc/ieee754@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz"
|
||||
|
@ -4957,6 +5012,15 @@ bl@^1.0.0:
|
|||
readable-stream "^2.3.5"
|
||||
safe-buffer "^5.1.1"
|
||||
|
||||
bl@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
|
||||
integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
|
||||
dependencies:
|
||||
buffer "^5.5.0"
|
||||
inherits "^2.0.4"
|
||||
readable-stream "^3.4.0"
|
||||
|
||||
blob-tmp@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/blob-tmp/-/blob-tmp-1.0.0.tgz"
|
||||
|
@ -5277,6 +5341,14 @@ buffer@^5.2.1:
|
|||
base64-js "^1.0.2"
|
||||
ieee754 "^1.1.4"
|
||||
|
||||
buffer@^5.5.0:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
|
||||
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
|
||||
dependencies:
|
||||
base64-js "^1.3.1"
|
||||
ieee754 "^1.1.13"
|
||||
|
||||
buffer@^6.0.3:
|
||||
version "6.0.3"
|
||||
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
|
||||
|
@ -5539,6 +5611,14 @@ chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.1, chalk@^2.4.2, chalk@~2.4.
|
|||
escape-string-regexp "^1.0.5"
|
||||
supports-color "^5.3.0"
|
||||
|
||||
chalk@4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.1.tgz#c80b3fab28bf6371e6863325eee67e618b77e6ad"
|
||||
integrity sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==
|
||||
dependencies:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz"
|
||||
|
@ -5566,7 +5646,7 @@ chalk@^4.0.0, chalk@^4.1.0:
|
|||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chalk@~4.1.0:
|
||||
chalk@^4.1.1, chalk@~4.1.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
||||
|
@ -5817,6 +5897,11 @@ cli-spinners@^2.0.0:
|
|||
resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.3.0.tgz"
|
||||
integrity sha512-Xs2Hf2nzrvJMFKimOR7YR0QwZ8fc0u98kdtwN1eNAZzNQgH3vK2pXzff6GJtKh7S5hoJ87ECiAiZFS2fb5Ii2w==
|
||||
|
||||
cli-spinners@^2.5.0:
|
||||
version "2.6.1"
|
||||
resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d"
|
||||
integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==
|
||||
|
||||
cli-table3@0.6.0, cli-table3@~0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz"
|
||||
|
@ -5840,6 +5925,11 @@ cli-width@^2.0.0:
|
|||
resolved "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz"
|
||||
integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==
|
||||
|
||||
cli-width@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6"
|
||||
integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==
|
||||
|
||||
cliui@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
|
||||
|
@ -6187,6 +6277,11 @@ cookie@0.4.0:
|
|||
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz"
|
||||
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
|
||||
|
||||
cookie@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
|
||||
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
|
||||
|
||||
copy-concurrently@^1.0.0:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz"
|
||||
|
@ -8019,7 +8114,7 @@ events@^3.0.0:
|
|||
resolved "https://registry.npmjs.org/events/-/events-3.1.0.tgz"
|
||||
integrity sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg==
|
||||
|
||||
events@^3.2.0:
|
||||
events@^3.2.0, events@^3.3.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
|
||||
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
|
||||
|
@ -9263,6 +9358,11 @@ graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.
|
|||
resolved "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz"
|
||||
integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=
|
||||
|
||||
graphql@^15.5.1:
|
||||
version "15.8.0"
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.8.0.tgz#33410e96b012fa3bdb1091cc99a94769db212b38"
|
||||
integrity sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==
|
||||
|
||||
growly@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz"
|
||||
|
@ -9725,6 +9825,11 @@ header-case@^1.0.0:
|
|||
no-case "^2.2.0"
|
||||
upper-case "^1.1.3"
|
||||
|
||||
headers-utils@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/headers-utils/-/headers-utils-3.0.2.tgz#dfc65feae4b0e34357308aefbcafa99c895e59ef"
|
||||
integrity sha512-xAxZkM1dRyGV2Ou5bzMxBPNLoRCjcX+ya7KSWybQD2KwLphxsapUVK6x/02o7f4VU6GPSXch9vNY2+gkU8tYWQ==
|
||||
|
||||
hex-color-regex@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz"
|
||||
|
@ -10058,16 +10163,16 @@ icss-utils@^5.0.0, icss-utils@^5.1.0:
|
|||
resolved "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz"
|
||||
integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==
|
||||
|
||||
ieee754@^1.1.13, ieee754@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
||||
|
||||
ieee754@^1.1.4:
|
||||
version "1.1.13"
|
||||
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz"
|
||||
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
||||
|
||||
ieee754@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
||||
|
||||
iferr@^0.1.5:
|
||||
version "0.1.5"
|
||||
resolved "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz"
|
||||
|
@ -10228,6 +10333,26 @@ inquirer@^7.1.0:
|
|||
strip-ansi "^6.0.0"
|
||||
through "^2.3.6"
|
||||
|
||||
inquirer@^8.2.0:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.0.tgz#f44f008dd344bbfc4b30031f45d984e034a3ac3a"
|
||||
integrity sha512-0crLweprevJ02tTuA6ThpoAERAGyVILC4sS74uib58Xf/zSr1/ZWtmm7D5CI+bSQEaA04f0K7idaHpQbSWgiVQ==
|
||||
dependencies:
|
||||
ansi-escapes "^4.2.1"
|
||||
chalk "^4.1.1"
|
||||
cli-cursor "^3.1.0"
|
||||
cli-width "^3.0.0"
|
||||
external-editor "^3.0.3"
|
||||
figures "^3.0.0"
|
||||
lodash "^4.17.21"
|
||||
mute-stream "0.0.8"
|
||||
ora "^5.4.1"
|
||||
run-async "^2.4.0"
|
||||
rxjs "^7.2.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
through "^2.3.6"
|
||||
|
||||
internal-slot@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz"
|
||||
|
@ -10571,6 +10696,11 @@ is-installed-globally@~0.4.0:
|
|||
global-dirs "^3.0.0"
|
||||
is-path-inside "^3.0.2"
|
||||
|
||||
is-interactive@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e"
|
||||
integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==
|
||||
|
||||
is-lower-case@^1.1.0:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.npmjs.org/is-lower-case/-/is-lower-case-1.1.3.tgz"
|
||||
|
@ -10609,6 +10739,11 @@ is-negative-zero@^2.0.1:
|
|||
resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz"
|
||||
integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==
|
||||
|
||||
is-node-process@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.0.1.tgz#4fc7ac3a91e8aac58175fe0578abbc56f2831b23"
|
||||
integrity sha512-5IcdXuf++TTNt3oGl9EBdkvndXA8gmc4bz/Y+mdEpWh3Mcn/+kOw6hI7LD5CocqJWMzeb0I0ClndRVNdEPuJXQ==
|
||||
|
||||
is-number-object@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz"
|
||||
|
@ -10798,6 +10933,11 @@ is-unc-path@^1.0.0:
|
|||
dependencies:
|
||||
unc-path-regex "^0.1.2"
|
||||
|
||||
is-unicode-supported@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
|
||||
integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
|
||||
|
||||
is-upper-case@^1.1.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmjs.org/is-upper-case/-/is-upper-case-1.1.2.tgz"
|
||||
|
@ -11446,6 +11586,11 @@ js-base64@^3.7.2:
|
|||
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.2.tgz#816d11d81a8aff241603d19ce5761e13e41d7745"
|
||||
integrity sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==
|
||||
|
||||
js-levenshtein@^1.1.6:
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d"
|
||||
integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==
|
||||
|
||||
js-sha3@0.8.0:
|
||||
version "0.8.0"
|
||||
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"
|
||||
|
@ -11964,6 +12109,14 @@ log-symbols@^4.0.0:
|
|||
dependencies:
|
||||
chalk "^4.0.0"
|
||||
|
||||
log-symbols@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"
|
||||
integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==
|
||||
dependencies:
|
||||
chalk "^4.1.0"
|
||||
is-unicode-supported "^0.1.0"
|
||||
|
||||
log-update@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz"
|
||||
|
@ -12532,6 +12685,40 @@ ms@2.1.2, ms@^2.1.1:
|
|||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
|
||||
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||
|
||||
msw-storybook-addon@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/msw-storybook-addon/-/msw-storybook-addon-1.5.0.tgz#a302517921ecd994d518f2b50a8f74e562796f9a"
|
||||
integrity sha512-2TmCREX+lVFlYwu+l9bwoxeI5+iXinJ7xOAahDKNRhgIrymaRTjAmCQx3h2NT7DJDvZKQcPstgCdhl03Q7ocHA==
|
||||
dependencies:
|
||||
"@storybook/addons" "^6.0.0"
|
||||
is-node-process "^1.0.1"
|
||||
|
||||
msw@^0.36.3:
|
||||
version "0.36.3"
|
||||
resolved "https://registry.yarnpkg.com/msw/-/msw-0.36.3.tgz#7feb243a5fcf563806d45edc027bc36144741170"
|
||||
integrity sha512-Itzp/QhKaleZoslXDrNik3ramW9ynqzOdbwydX2ehBSSaZd5QoiAl/bHYcV33R6CEZcJgIX1N4s+G6XkF/bhkA==
|
||||
dependencies:
|
||||
"@mswjs/cookies" "^0.1.6"
|
||||
"@mswjs/interceptors" "^0.12.7"
|
||||
"@open-draft/until" "^1.0.3"
|
||||
"@types/cookie" "^0.4.1"
|
||||
"@types/inquirer" "^8.1.3"
|
||||
"@types/js-levenshtein" "^1.1.0"
|
||||
chalk "4.1.1"
|
||||
chokidar "^3.4.2"
|
||||
cookie "^0.4.1"
|
||||
graphql "^15.5.1"
|
||||
headers-utils "^3.0.2"
|
||||
inquirer "^8.2.0"
|
||||
is-node-process "^1.0.1"
|
||||
js-levenshtein "^1.1.6"
|
||||
node-fetch "^2.6.1"
|
||||
path-to-regexp "^6.2.0"
|
||||
statuses "^2.0.0"
|
||||
strict-event-emitter "^0.2.0"
|
||||
type-fest "^1.2.2"
|
||||
yargs "^17.3.0"
|
||||
|
||||
multicast-dns-service-types@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz"
|
||||
|
@ -13216,6 +13403,21 @@ ora@^3.4.0:
|
|||
strip-ansi "^5.2.0"
|
||||
wcwidth "^1.0.1"
|
||||
|
||||
ora@^5.4.1:
|
||||
version "5.4.1"
|
||||
resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18"
|
||||
integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==
|
||||
dependencies:
|
||||
bl "^4.1.0"
|
||||
chalk "^4.1.0"
|
||||
cli-cursor "^3.1.0"
|
||||
cli-spinners "^2.5.0"
|
||||
is-interactive "^1.0.0"
|
||||
is-unicode-supported "^0.1.0"
|
||||
log-symbols "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
wcwidth "^1.0.1"
|
||||
|
||||
os-browserify@^0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz"
|
||||
|
@ -13244,6 +13446,11 @@ ospath@^1.2.2:
|
|||
resolved "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz"
|
||||
integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=
|
||||
|
||||
outvariant@^1.2.0:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.2.1.tgz#e630f6cdc1dbf398ed857e36f219de4a005ccd35"
|
||||
integrity sha512-bcILvFkvpMXh66+Ubax/inxbKRyWTUiiFIW2DWkiS79wakrLGn3Ydy+GvukadiyfZjaL6C7YhIem4EZSM282wA==
|
||||
|
||||
overlayscrollbars@^1.13.1:
|
||||
version "1.13.1"
|
||||
resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-1.13.1.tgz"
|
||||
|
@ -13599,6 +13806,11 @@ path-to-regexp@0.1.7:
|
|||
resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz"
|
||||
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
|
||||
|
||||
path-to-regexp@^6.2.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.npmjs.org/path-type/-/path-type-1.1.0.tgz"
|
||||
|
@ -14873,7 +15085,7 @@ read-pkg@^5.2.0:
|
|||
string_decoder "~1.1.1"
|
||||
util-deprecate "~1.0.1"
|
||||
|
||||
readable-stream@^3.0.6, readable-stream@^3.6.0:
|
||||
readable-stream@^3.0.6, readable-stream@^3.4.0, readable-stream@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz"
|
||||
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
|
||||
|
@ -15419,6 +15631,13 @@ rxjs@^6.4.0, rxjs@^6.5.3, rxjs@^6.5.5:
|
|||
dependencies:
|
||||
tslib "^1.9.0"
|
||||
|
||||
rxjs@^7.2.0:
|
||||
version "7.5.1"
|
||||
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.1.tgz#af73df343cbcab37628197f43ea0c8256f54b157"
|
||||
integrity sha512-KExVEeZWxMZnZhUZtsJcFwz8IvPvgu4G2Z2QyqjZQzUGr32KDYuSxrEYO4w3tFFNbfLozcrKUTvTPi+E9ywJkQ==
|
||||
dependencies:
|
||||
tslib "^2.1.0"
|
||||
|
||||
rxjs@^7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.4.0.tgz#a12a44d7eebf016f5ff2441b87f28c9a51cebc68"
|
||||
|
@ -15693,6 +15912,11 @@ set-blocking@^2.0.0, set-blocking@~2.0.0:
|
|||
resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz"
|
||||
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
|
||||
|
||||
set-cookie-parser@^2.4.6:
|
||||
version "2.4.8"
|
||||
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.4.8.tgz#d0da0ed388bc8f24e706a391f9c9e252a13c58b2"
|
||||
integrity sha512-edRH8mBKEWNVIVMKejNnuJxleqYE/ZSdcT8/Nem9/mmosx12pctd80s2Oy00KNZzrogMZS5mauK2/ymL1bvlvg==
|
||||
|
||||
set-value@^2.0.0, set-value@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz"
|
||||
|
@ -16169,6 +16393,11 @@ static-extend@^0.1.1:
|
|||
resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz"
|
||||
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
|
||||
|
||||
statuses@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
|
||||
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
|
||||
|
||||
store2@^2.12.0:
|
||||
version "2.12.0"
|
||||
resolved "https://registry.npmjs.org/store2/-/store2-2.12.0.tgz"
|
||||
|
@ -16211,6 +16440,13 @@ stream-shift@^1.0.0:
|
|||
resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz"
|
||||
integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
|
||||
|
||||
strict-event-emitter@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.2.0.tgz#78e2f75dc6ea502e5d8a877661065a1e2deedecd"
|
||||
integrity sha512-zv7K2egoKwkQkZGEaH8m+i2D0XiKzx5jNsiSul6ja2IYFvil10A59Z9Y7PPAAe5OW53dQUf9CfsHKzjZzKkm1w==
|
||||
dependencies:
|
||||
events "^3.3.0"
|
||||
|
||||
string-argv@0.3.1:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz"
|
||||
|
@ -16979,7 +17215,7 @@ tslib@^1.9.0:
|
|||
resolved "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz"
|
||||
integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
|
||||
|
||||
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.3.0:
|
||||
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz"
|
||||
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
|
||||
|
@ -17052,6 +17288,11 @@ type-fest@^0.8.1:
|
|||
resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz"
|
||||
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
|
||||
|
||||
type-fest@^1.2.2:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1"
|
||||
integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==
|
||||
|
||||
type-is@~1.6.17, type-is@~1.6.18:
|
||||
version "1.6.18"
|
||||
resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"
|
||||
|
@ -18133,6 +18374,11 @@ yargs-parser@^20.2.2, yargs-parser@^20.2.7:
|
|||
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz"
|
||||
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
|
||||
|
||||
yargs-parser@^21.0.0:
|
||||
version "21.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.0.0.tgz#a485d3966be4317426dd56bdb6a30131b281dc55"
|
||||
integrity sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA==
|
||||
|
||||
yargs@^15.4.1:
|
||||
version "15.4.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
|
||||
|
@ -18176,6 +18422,19 @@ yargs@^17.0.1:
|
|||
y18n "^5.0.5"
|
||||
yargs-parser "^20.2.2"
|
||||
|
||||
yargs@^17.3.0:
|
||||
version "17.3.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.3.1.tgz#da56b28f32e2fd45aefb402ed9c26f42be4c07b9"
|
||||
integrity sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==
|
||||
dependencies:
|
||||
cliui "^7.0.2"
|
||||
escalade "^3.1.1"
|
||||
get-caller-file "^2.0.5"
|
||||
require-directory "^2.1.1"
|
||||
string-width "^4.2.3"
|
||||
y18n "^5.0.5"
|
||||
yargs-parser "^21.0.0"
|
||||
|
||||
yauzl@^2.10.0, yauzl@^2.4.2:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"
|
||||
|
|
Loading…
Reference in New Issue