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 validation
pull/6108/head
Chaim Lev-Ari 2022-01-05 18:28:56 +02:00 committed by GitHub
parent 8dbb802fb1
commit ecd0eb6170
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1841 additions and 72 deletions

View File

@ -31,4 +31,5 @@ module.exports = {
core: { core: {
builder: 'webpack5', builder: 'webpack5',
}, },
staticDirs: ['./public'],
}; };

View File

@ -1,6 +1,22 @@
import '../app/assets/css'; import '../app/assets/css';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react'; 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 = { export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' }, actions: { argTypesRegex: '^on[A-Z].*' },
@ -10,6 +26,9 @@ export const parameters = {
date: /Date$/, date: /Date$/,
}, },
}, },
msw: {
handlers,
},
}; };
export const decorators = [ export const decorators = [
@ -18,4 +37,5 @@ export const decorators = [
<Story /> <Story />
</UIRouter> </UIRouter>
), ),
mswDecorator,
]; ];

View File

@ -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);
});
}

View File

@ -21,7 +21,7 @@ export function BoxSelector<T extends number | string>({
onChange, onChange,
}: Props<T>) { }: Props<T>) {
return ( return (
<div className={clsx('boxselector_wrapper', styles.root)}> <div className={clsx('boxselector_wrapper', styles.root)} role="radiogroup">
{options.map((option) => ( {options.map((option) => (
<BoxSelectorItem <BoxSelectorItem
key={option.id} key={option.id}
@ -43,7 +43,7 @@ export function buildOption<T extends number | string>(
label: string, label: string,
description: string, description: string,
value: T, value: T,
feature: FeatureId feature?: FeatureId
): BoxSelectorOption<T> { ): BoxSelectorOption<T> {
return { id, icon, label, description, value, feature }; return { id, icon, label, description, value, feature };
} }

View File

@ -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,
};
}

View File

@ -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"
/>
);
}

View File

@ -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}
/>
);
}

View File

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

View File

@ -11,8 +11,10 @@ const meta: Meta = {
export default meta; export default meta;
export function Example() { export { Example };
const [selectedUsers, setSelectedUsers] = useState([10]);
function Example() {
const [selectedUsers, setSelectedUsers] = useState([1]);
const users = [createMockUser(1, 'user1'), createMockUser(2, 'user2')]; const users = [createMockUser(1, 'user1'), createMockUser(2, 'user2')];

View File

@ -1,8 +1,7 @@
import Select from 'react-select'; import Select from 'react-select';
import { UserViewModel } from '@/portainer/models/user'; import { UserViewModel } from '@/portainer/models/user';
import { UserId } from '@/portainer/users/types';
type UserId = number;
interface Props { interface Props {
value: UserId[]; value: UserId[];

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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
),
]);
}

View File

@ -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);
});

View File

@ -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'),
}
),
});
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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"`;

View File

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

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -1,7 +1,5 @@
<div> <div>
<div ng-if="!$ctrl.hideTitle" class="col-sm-12 form-section-title"> <div ng-if="!$ctrl.hideTitle" class="col-sm-12 form-section-title"> Access control </div>
Access control
</div>
<!-- access-control-switch --> <!-- access-control-switch -->
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
@ -9,18 +7,18 @@
Enable access control Enable access control
<portainer-tooltip position="bottom" message="When enabled, you can restrict the access and management of this resource."></portainer-tooltip> <portainer-tooltip position="bottom" message="When enabled, you can restrict the access and management of this resource."></portainer-tooltip>
</label> </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>
</div> </div>
<!-- !access-control-switch --> <!-- !access-control-switch -->
<!-- restricted-access --> <!-- 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 class="boxselector_wrapper">
<div ng-if="$ctrl.isAdmin"> <div ng-if="$ctrl.isAdmin">
<input type="radio" id="access_administrators" ng-model="$ctrl.formData.Ownership" value="administrators" /> <input type="radio" id="access_administrators" ng-model="$ctrl.formData.Ownership" value="administrators" />
<label for="access_administrators"> <label for="access_administrators">
<div class="boxselector_header"> <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 Administrators
</div> </div>
<p>I want to restrict the management of this resource to administrators only</p> <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" /> <input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" />
<label for="access_restricted"> <label for="access_restricted">
<div class="boxselector_header"> <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 Restricted
</div> </div>
<p> <p> I want to restrict the management of this resource to a set of users and/or teams </p>
I want to restrict the management of this resource to a set of users and/or teams
</p>
</label> </label>
</div> </div>
<div ng-if="!$ctrl.isAdmin"> <div ng-if="!$ctrl.isAdmin">
<input type="radio" id="access_private" ng-model="$ctrl.formData.Ownership" value="private" /> <input type="radio" id="access_private" ng-model="$ctrl.formData.Ownership" value="private" />
<label for="access_private"> <label for="access_private">
<div class="boxselector_header"> <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 Private
</div> </div>
<p> <p> I want to this resource to be manageable by myself only </p>
I want to this resource to be manageable by myself only
</p>
</label> </label>
</div> </div>
<div ng-if="!$ctrl.isAdmin && $ctrl.availableTeams.length > 0"> <div ng-if="!$ctrl.isAdmin && $ctrl.availableTeams.length > 0">
<input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" /> <input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" />
<label for="access_restricted"> <label for="access_restricted">
<div class="boxselector_header"> <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 Restricted
</div> </div>
<p ng-if="$ctrl.availableTeams.length === 1"> <p ng-if="$ctrl.availableTeams.length === 1">
I want any member of my team (<b>{{ $ctrl.availableTeams[0].Name }}</b I want any member of my team (<b>{{ $ctrl.availableTeams[0].Name }}</b
>) to be able to manage this resource >) to be able to manage this resource
</p> </p>
<p ng-if="$ctrl.availableTeams.length > 1"> <p ng-if="$ctrl.availableTeams.length > 1"> I want to restrict the management of this resource to one or more of my teams </p>
I want to restrict the management of this resource to one or more of my teams
</p>
</label> </label>
</div> </div>
</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." message="As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource."
></portainer-tooltip> ></portainer-tooltip>
</label> </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. You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
</span> </span>
<span <span
@ -102,7 +94,7 @@
helper-elements="filter" helper-elements="filter"
search-property="Name" search-property="Name"
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}" translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
style="margin-left: 20px;" style="margin-left: 20px"
> >
</span> </span>
</div> </div>
@ -119,7 +111,7 @@
message="You can select which user(s) will be able to manage this resource." message="You can select which user(s) will be able to manage this resource."
></portainer-tooltip> ></portainer-tooltip>
</label> </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. You have not yet created any users. Head over to the <a ui-sref="portainer.users">Users view</a> to manage users.
</span> </span>
<span <span
@ -133,7 +125,7 @@
helper-elements="filter" helper-elements="filter"
search-property="Username" search-property="Username"
translation="{nothingSelected: 'Select one or more users', search: 'Search...'}" translation="{nothingSelected: 'Select one or more users', search: 'Search...'}"
style="margin-left: 20px;" style="margin-left: 20px"
> >
</span> </span>
</div> </div>

View File

@ -1,5 +1,8 @@
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership'; import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
/**
* @deprecated use only for angularjs components. For react components use ./model.ts
*/
export function AccessControlFormData() { export function AccessControlFormData() {
this.AccessControlEnabled = true; this.AccessControlEnabled = true;
this.Ownership = RCO.PRIVATE; this.Ownership = RCO.PRIVATE;

View File

@ -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 };
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -1,6 +0,0 @@
export const ResourceControlOwnership = Object.freeze({
PUBLIC: 'public',
PRIVATE: 'private',
RESTRICTED: 'restricted',
ADMINISTRATORS: 'administrators',
});

View File

@ -0,0 +1,6 @@
export enum ResourceControlOwnership {
PUBLIC = 'public',
PRIVATE = 'private',
RESTRICTED = 'restricted',
ADMINISTRATORS = 'administrators',
}

View File

@ -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 { get as localStorageGet } from '../hooks/useLocalStorage';
import { import {
@ -40,3 +41,17 @@ export function agentInterceptor(config: AxiosRequestConfig) {
} }
axiosApiInstance.interceptors.request.use(agentInterceptor); 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);
}

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
export type TeamId = number;
export interface Team {
Id: TeamId;
Name: string;
}

View File

@ -0,0 +1 @@
export type UserId = number;

View File

@ -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}`,
}));
}

View File

@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
import { render, RenderOptions } from '@testing-library/react'; import { render, RenderOptions } from '@testing-library/react';
import { UIRouter, pushStateLocationPlugin } from '@uirouter/react'; import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
import { PropsWithChildren, ReactElement } from 'react'; import { PropsWithChildren, ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
function Provider({ children }: PropsWithChildren<unknown>) { function Provider({ children }: PropsWithChildren<unknown>) {
return <UIRouter plugins={[pushStateLocationPlugin]}>{children}</UIRouter>; return <UIRouter plugins={[pushStateLocationPlugin]}>{children}</UIRouter>;
@ -17,3 +18,21 @@ export * from '@testing-library/react';
// override render method // override render method
export { customRender as render }; 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>
),
};
}

View File

@ -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)))
),
];

View File

@ -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 };

View File

@ -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());

View File

@ -54,7 +54,7 @@ module.exports = {
// forceCoverageMatch: [], // forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites // 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 // A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined, // globalTeardown: undefined,
@ -135,7 +135,7 @@ module.exports = {
// setupFiles: [], // setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test // 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. // The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5, // slowTestThreshold: 5,

View File

@ -108,8 +108,10 @@
"js-base64": "^3.7.2", "js-base64": "^3.7.2",
"js-yaml": "^3.14.0", "js-yaml": "^3.14.0",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"moment": "^2.29.1", "moment": "^2.29.1",
"msw": "^0.36.3",
"ng-file-upload": "~12.2.13", "ng-file-upload": "~12.2.13",
"parse-duration": "^1.0.2", "parse-duration": "^1.0.2",
"rc-slider": "^9.7.5", "rc-slider": "^9.7.5",
@ -206,6 +208,7 @@
"load-grunt-tasks": "^3.5.2", "load-grunt-tasks": "^3.5.2",
"lodash-webpack-plugin": "^0.11.6", "lodash-webpack-plugin": "^0.11.6",
"mini-css-extract-plugin": "1", "mini-css-extract-plugin": "1",
"msw-storybook-addon": "^1.5.0",
"ngtemplate-loader": "^2.1.0", "ngtemplate-loader": "^2.1.0",
"plop": "^2.6.0", "plop": "^2.6.0",
"postcss": "7", "postcss": "7",

279
yarn.lock
View File

@ -1772,6 +1772,26 @@
call-me-maybe "^1.0.1" call-me-maybe "^1.0.1"
glob-to-regexp "^0.3.0" 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": "@nodelib/fs.scandir@2.1.3":
version "2.1.3" version "2.1.3"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz" 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" resolved "https://registry.npmjs.org/@nxmix/tokenize-ansi/-/tokenize-ansi-3.0.0.tgz"
integrity sha512-37QMpFIiQ6J31tavjMFCuWs3YIqXIDCuGvPiDVofFqvgXq6vM+8LqU4sqibsvb9JX/1SIeDp+SedOqpq2qc7TA== 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": "@pmmmwh/react-refresh-webpack-plugin@^0.5.1":
version "0.5.3" version "0.5.3"
resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.3.tgz#b8f0e035f6df71b5c4126cb98de29f65188b9e7b" 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" prop-types "^15.7.2"
regenerator-runtime "^0.13.7" regenerator-runtime "^0.13.7"
"@storybook/addons@6.4.9": "@storybook/addons@6.4.9", "@storybook/addons@^6.0.0":
version "6.4.9" version "6.4.9"
resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.4.9.tgz#43b5dabf6781d863fcec0a0b293c236b4d5d4433" resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.4.9.tgz#43b5dabf6781d863fcec0a0b293c236b4d5d4433"
integrity sha512-y+oiN2zd+pbRWwkf6aQj4tPDFn+rQkrv7fiVoMxsYub+kKyZ3CNOuTSJH+A1A+eBL6DmzocChUyO6jvZFuh6Dg== 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" resolved "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== 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": "@types/eslint-scope@^3.7.0":
version "3.7.1" version "3.7.1"
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e"
@ -3117,6 +3147,14 @@
"@types/through" "*" "@types/through" "*"
rxjs "^6.4.0" 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@*": "@types/interpret@*":
version "1.1.1" version "1.1.1"
resolved "https://registry.npmjs.org/@types/interpret/-/interpret-1.1.1.tgz" resolved "https://registry.npmjs.org/@types/interpret/-/interpret-1.1.1.tgz"
@ -3178,6 +3216,11 @@
dependencies: dependencies:
"@types/sizzle" "*" "@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": "@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" version "7.0.9"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz" 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" resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz"
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== 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": "@types/sinonjs__fake-timers@^6.0.2":
version "6.0.4" version "6.0.4"
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.4.tgz#0ecc1b9259b76598ef01942f547904ce61a6a77d" 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" resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.6.0.tgz#2c275aa05c895eccebbfc34cfb223c6e8bd591a2"
integrity sha512-ZkVeqEmRpBV2GHvjjUZqEai2PpUbuq8Bqd//vEYsp63J8WyexI8ppCqVS3Zs0QADf6aWuPdU+0XsPI647PVlQA== 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": "@xtuc/ieee754@^1.2.0":
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz" 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" readable-stream "^2.3.5"
safe-buffer "^5.1.1" 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: blob-tmp@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/blob-tmp/-/blob-tmp-1.0.0.tgz" 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" base64-js "^1.0.2"
ieee754 "^1.1.4" 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: buffer@^6.0.3:
version "6.0.3" version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" 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" escape-string-regexp "^1.0.5"
supports-color "^5.3.0" 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: chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz" 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" ansi-styles "^4.1.0"
supports-color "^7.1.0" supports-color "^7.1.0"
chalk@~4.1.0: chalk@^4.1.1, chalk@~4.1.0:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== 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" resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.3.0.tgz"
integrity sha512-Xs2Hf2nzrvJMFKimOR7YR0QwZ8fc0u98kdtwN1eNAZzNQgH3vK2pXzff6GJtKh7S5hoJ87ECiAiZFS2fb5Ii2w== 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: cli-table3@0.6.0, cli-table3@~0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz" 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" resolved "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz"
integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== 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: cliui@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" 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" resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz"
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== 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: copy-concurrently@^1.0.0:
version "1.0.5" version "1.0.5"
resolved "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz" 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" resolved "https://registry.npmjs.org/events/-/events-3.1.0.tgz"
integrity sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg== integrity sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg==
events@^3.2.0: events@^3.2.0, events@^3.3.0:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== 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" resolved "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz"
integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= 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: growly@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz" 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" no-case "^2.2.0"
upper-case "^1.1.3" 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: hex-color-regex@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz" 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" resolved "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz"
integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== 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: ieee754@^1.1.4:
version "1.1.13" version "1.1.13"
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz"
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== 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: iferr@^0.1.5:
version "0.1.5" version "0.1.5"
resolved "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz" resolved "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz"
@ -10228,6 +10333,26 @@ inquirer@^7.1.0:
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
through "^2.3.6" 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: internal-slot@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz" 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" global-dirs "^3.0.0"
is-path-inside "^3.0.2" 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: is-lower-case@^1.1.0:
version "1.1.3" version "1.1.3"
resolved "https://registry.npmjs.org/is-lower-case/-/is-lower-case-1.1.3.tgz" 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" resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz"
integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== 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: is-number-object@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz" 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: dependencies:
unc-path-regex "^0.1.2" 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: is-upper-case@^1.1.0:
version "1.1.2" version "1.1.2"
resolved "https://registry.npmjs.org/is-upper-case/-/is-upper-case-1.1.2.tgz" 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" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.2.tgz#816d11d81a8aff241603d19ce5761e13e41d7745"
integrity sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ== 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: js-sha3@0.8.0:
version "0.8.0" version "0.8.0"
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"
@ -11964,6 +12109,14 @@ log-symbols@^4.0.0:
dependencies: dependencies:
chalk "^4.0.0" 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: log-update@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz" 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" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 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: multicast-dns-service-types@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz" 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" strip-ansi "^5.2.0"
wcwidth "^1.0.1" 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: os-browserify@^0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz" 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" resolved "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz"
integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs= 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: overlayscrollbars@^1.13.1:
version "1.13.1" version "1.13.1"
resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-1.13.1.tgz" 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" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz"
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= 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: path-type@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz" 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" string_decoder "~1.1.1"
util-deprecate "~1.0.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" version "3.6.0"
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@ -15419,6 +15631,13 @@ rxjs@^6.4.0, rxjs@^6.5.3, rxjs@^6.5.5:
dependencies: dependencies:
tslib "^1.9.0" 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: rxjs@^7.4.0:
version "7.4.0" version "7.4.0"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.4.0.tgz#a12a44d7eebf016f5ff2441b87f28c9a51cebc68" 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" resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= 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: set-value@^2.0.0, set-value@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz" 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" resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz"
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= 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: store2@^2.12.0:
version "2.12.0" version "2.12.0"
resolved "https://registry.npmjs.org/store2/-/store2-2.12.0.tgz" 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" resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz"
integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== 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: string-argv@0.3.1:
version "0.3.1" version "0.3.1"
resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz" 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" resolved "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz"
integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== 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" version "2.3.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz" resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== 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" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz"
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== 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: type-is@~1.6.17, type-is@~1.6.18:
version "1.6.18" version "1.6.18"
resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz" 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" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz"
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== 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: yargs@^15.4.1:
version "15.4.1" version "15.4.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
@ -18176,6 +18422,19 @@ yargs@^17.0.1:
y18n "^5.0.5" y18n "^5.0.5"
yargs-parser "^20.2.2" 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: yauzl@^2.10.0, yauzl@^2.4.2:
version "2.10.0" version "2.10.0"
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"