1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-09 15:55:23 +02:00

refactor(azure/aci): migrate create view to react [EE-2188] (#6371)

This commit is contained in:
Chaim Lev-Ari 2022-02-01 19:38:45 +02:00 committed by GitHub
parent 1bb02eea59
commit 6f6f78fbe5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1476 additions and 571 deletions

View file

@ -3,6 +3,7 @@ import Select from 'react-select';
import { Team, TeamId } from '@/portainer/teams/types';
interface Props {
name?: string;
value: TeamId[];
onChange(value: TeamId[]): void;
teams: Team[];
@ -12,6 +13,7 @@ interface Props {
}
export function TeamsSelector({
name,
value,
onChange,
teams,
@ -21,6 +23,7 @@ export function TeamsSelector({
}: Props) {
return (
<Select
name={name}
isMulti
getOptionLabel={(team) => team.Name}
getOptionValue={(team) => String(team.Id)}

View file

@ -5,6 +5,7 @@ import { UserId } from '@/portainer/users/types';
import './UsersSelector.css';
interface Props {
name?: string;
value: UserId[];
onChange(value: UserId[]): void;
users: UserViewModel[];
@ -14,6 +15,7 @@ interface Props {
}
export function UsersSelector({
name,
value,
onChange,
users,
@ -24,6 +26,7 @@ export function UsersSelector({
return (
<Select
isMulti
name={name}
getOptionLabel={(user) => user.Username}
getOptionValue={(user) => user.Id}
options={users}

View file

@ -1,5 +1,6 @@
import _ from 'lodash';
import { useEffect, useState, useCallback } from 'react';
import { FormikErrors } from 'formik';
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
@ -10,6 +11,8 @@ import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
import { FormError } from '../form-components/FormError';
import { AccessControlFormData } from './model';
import { UsersField } from './UsersField';
import { TeamsField } from './TeamsField';
@ -19,9 +22,17 @@ export interface Props {
values: AccessControlFormData;
onChange(values: AccessControlFormData): void;
hideTitle?: boolean;
errors?: FormikErrors<AccessControlFormData>;
formNamespace?: string;
}
export function AccessControlForm({ values, onChange, hideTitle }: Props) {
export function AccessControlForm({
values,
onChange,
hideTitle,
errors,
formNamespace,
}: Props) {
const { users, teams, isLoading } = useLoadState();
const { user } = useUser();
@ -49,7 +60,7 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
<div className="col-sm-12">
<SwitchField
checked={values.accessControlEnabled}
name="ownership"
name={withNamespace('accessControlEnabled')}
label="Enable access control"
tooltip="When enabled, you can restrict the access and management of this resource."
onChange={(accessControlEnabled) =>
@ -63,7 +74,7 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
<>
<div className="form-group">
<BoxSelector
radioName="access-control"
radioName={withNamespace('ownership')}
value={values.ownership}
options={options}
onChange={(ownership) => handleChange({ ownership })}
@ -73,16 +84,19 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
<div aria-label="extra-options">
{isAdmin && (
<UsersField
name={withNamespace('authorizedUsers')}
users={users}
onChange={(authorizedUsers) =>
handleChange({ authorizedUsers })
}
value={values.authorizedUsers}
errors={errors?.authorizedUsers}
/>
)}
{(isAdmin || teams.length > 1) && (
<TeamsField
name={withNamespace('authorizedTeams')}
teams={teams}
overrideTooltip={
!isAdmin && teams.length > 1
@ -93,14 +107,25 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
handleChange({ authorizedTeams })
}
value={values.authorizedTeams}
errors={errors?.authorizedTeams}
/>
)}
{typeof errors === 'string' && (
<div className="form-group col-md-12">
<FormError>{errors}</FormError>
</div>
)}
</div>
)}
</>
)}
</>
);
function withNamespace(name: string) {
return formNamespace ? `${formNamespace}.${name}` : name;
}
}
function useOptions(isAdmin: boolean, teams?: Team[]) {

View file

@ -43,6 +43,8 @@ test('when access control is enabled, ownership is restricted and no teams or us
{
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [],
authorizedUsers: [],
},
{ strict: true }
)
@ -50,19 +52,40 @@ test('when access control is enabled, ownership is restricted and no teams or us
});
});
test('when access control is enabled, ownership is restricted, user is admin but no users, should be valid', async () => {
test('when access control is enabled, ownership is restricted, user is admin should have either teams or users', async () => {
const schema = validationSchema(true);
const teams = {
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [1],
authorizedUsers: [],
};
await expect(
schema.validate(
{
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [1],
},
{ strict: true }
)
).rejects.toThrowErrorMatchingSnapshot();
await expect(schema.validate(teams, { strict: true })).resolves.toStrictEqual(
teams
);
const users = {
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [],
authorizedUsers: [1],
};
await expect(schema.validate(users, { strict: true })).resolves.toStrictEqual(
users
);
const both = {
accessControlEnabled: true,
ownership: ResourceControlOwnership.RESTRICTED,
authorizedTeams: [1],
authorizedUsers: [2],
};
await expect(schema.validate(both, { strict: true })).resolves.toStrictEqual(
both
);
});
test('when access control is enabled, ownership is restricted, user is admin with teams and users, should be valid', async () => {

View file

@ -3,39 +3,45 @@ 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.'),
return object()
.shape({
accessControlEnabled: bool(),
ownership: string()
.oneOf(Object.values(ResourceControlOwnership))
.when('accessControlEnabled', {
is: true,
then: (schema) => schema.required(),
}),
authorizedUsers: array(number()),
authorizedTeams: array(number()),
})
.test(
'user-and-team',
isAdmin
? 'You must specify at least one team or user.'
: 'You must specify at least one team.',
({
accessControlEnabled,
ownership,
authorizedTeams,
authorizedUsers,
}) => {
if (
!accessControlEnabled ||
ownership !== ResourceControlOwnership.RESTRICTED
) {
return true;
}
if (!isAdmin) {
return !!authorizedTeams && authorizedTeams.length > 0;
}
return (
!!authorizedTeams &&
!!authorizedUsers &&
(authorizedTeams.length > 0 || authorizedUsers.length > 0)
);
}
),
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

@ -4,13 +4,22 @@ import { Link } from '@/portainer/components/Link';
import { Team } from '@/portainer/teams/types';
interface Props {
name: string;
teams: Team[];
value: number[];
overrideTooltip?: string;
onChange(value: number[]): void;
errors?: string | string[];
}
export function TeamsField({ teams, value, overrideTooltip, onChange }: Props) {
export function TeamsField({
name,
teams,
value,
overrideTooltip,
onChange,
errors,
}: Props) {
return (
<FormControl
label="Authorized teams"
@ -21,9 +30,11 @@ export function TeamsField({ teams, value, overrideTooltip, onChange }: Props) {
: undefined
}
inputId="teams-selector"
errors={errors}
>
{teams.length > 0 ? (
<TeamsSelector
name={name}
teams={teams}
onChange={onChange}
value={value}

View file

@ -4,12 +4,14 @@ import { UserViewModel } from '@/portainer/models/user';
import { Link } from '@/portainer/components/Link';
interface Props {
name: string;
users: UserViewModel[];
value: number[];
onChange(value: number[]): void;
errors?: string | string[];
}
export function UsersField({ users, value, onChange }: Props) {
export function UsersField({ name, users, value, onChange, errors }: Props) {
return (
<FormControl
label="Authorized users"
@ -19,9 +21,11 @@ export function UsersField({ users, value, onChange }: Props) {
: undefined
}
inputId="users-selector"
errors={errors}
>
{users.length > 0 ? (
<UsersSelector
name={name}
users={users}
onChange={onChange}
value={value}

View file

@ -1,9 +1,7 @@
// 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 1`] = `"You must specify at least one team or user."`;
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, user is admin but no users, should be valid 1`] = `"You must specify at least one user."`;
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 only access control is enabled, should be invalid 1`] = `"ownership is a required field"`;

View file

@ -3,6 +3,8 @@ import clsx from 'clsx';
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
import { FormError } from '../FormError';
import styles from './FormControl.module.css';
type Size = 'small' | 'medium' | 'large';
@ -40,13 +42,7 @@ export function FormControl({
{errors && (
<div className="form-group col-md-12">
<div className="small text-warning">
<i
className="fa fa-exclamation-triangle space-right"
aria-hidden="true"
/>
{errors}
</div>
<FormError>{errors}</FormError>
</div>
)}
</div>

View file

@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
export function FormError({ children }: PropsWithChildren<unknown>) {
return (
<div className="small text-warning">
<i
className="fa fa-exclamation-triangle space-right"
aria-hidden="true"
/>
{children}
</div>
);
}

View file

@ -1,7 +1,7 @@
import clsx from 'clsx';
import { SelectHTMLAttributes } from 'react';
interface Option<T extends string | number> {
export interface Option<T extends string | number> {
value: T;
label: string;
}

View file

@ -20,6 +20,10 @@
display: flex;
}
.item-line.has-error {
margin-bottom: 20px;
}
.item-actions {
display: flex;
margin-left: 2px;

View file

@ -5,13 +5,17 @@ import { AddButton, Button } from '@/portainer/components/Button';
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
import { Input } from '../Input';
import { FormError } from '../FormError';
import styles from './InputList.module.css';
import { arrayMove } from './utils';
interface ItemProps<T> {
export type InputListError<T> = Record<keyof T, string>;
export interface ItemProps<T> {
item: T;
onChange(value: T): void;
error?: InputListError<T>;
}
type Key = string | number;
type ChangeType = 'delete' | 'create' | 'update';
@ -38,6 +42,7 @@ interface Props<T> {
addLabel?: string;
itemKeyGetter?(item: T, index: number): Key;
movable?: boolean;
errors?: InputListError<T>[] | string;
}
export function InputList<T = DefaultType>({
@ -50,6 +55,7 @@ export function InputList<T = DefaultType>({
addLabel = 'Add item',
itemKeyGetter = (item: T, index: number) => index,
movable,
errors,
}: Props<T>) {
const Item = item;
@ -70,12 +76,17 @@ export function InputList<T = DefaultType>({
<div className={clsx('col-sm-12 form-inline', styles.items)}>
{value.map((item, index) => {
const key = itemKeyGetter(item, index);
const error = typeof errors === 'object' ? errors[index] : undefined;
return (
<div key={key} className={clsx(styles.itemLine)}>
<div
key={key}
className={clsx(styles.itemLine, { [styles.hasError]: !!error })}
>
<Item
item={item}
onChange={(value: T) => handleChangeItem(key, value)}
error={error}
/>
<div className={styles.itemActions}>
{movable && (
@ -172,12 +183,15 @@ function defaultItemBuilder(): DefaultType {
return { value: '' };
}
function DefaultItem({ item, onChange }: ItemProps<DefaultType>) {
function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
return (
<Input
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
className={styles.defaultItem}
/>
<>
<Input
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
className={styles.defaultItem}
/>
<FormError>{error}</FormError>
</>
);
}

View file

@ -125,6 +125,6 @@ export function UserProvider({ children }: UserProviderProps) {
}
}
function isAdmin(user?: UserViewModel | null) {
export function isAdmin(user?: UserViewModel | null): boolean {
return !!user && user.Role === 1;
}

View file

@ -0,0 +1,51 @@
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
import { AccessControlFormData } from '../components/accessControlForm/model';
import { TeamId } from '../teams/types';
import { UserId } from '../users/types';
import { OwnershipParameters } from './types';
/**
* Transform AccessControlFormData to ResourceControlOwnershipParameters
* @param {int} userId ID of user performing the operation
* @param {AccessControlFormData} formValues Form data (generated by AccessControlForm)
* @param {int[]} subResources Sub Resources restricted by the ResourceControl
*/
export function parseOwnershipParameters(
userId: UserId,
formValues: AccessControlFormData,
subResources: (number | string)[] = []
): OwnershipParameters {
let { ownership } = formValues;
if (!formValues.accessControlEnabled) {
ownership = ResourceControlOwnership.PUBLIC;
}
let adminOnly = false;
let publicOnly = false;
let users: UserId[] = [];
let teams: TeamId[] = [];
switch (ownership) {
case ResourceControlOwnership.PUBLIC:
publicOnly = true;
break;
case ResourceControlOwnership.PRIVATE:
users.push(userId);
break;
case ResourceControlOwnership.RESTRICTED:
users = formValues.authorizedUsers;
teams = formValues.authorizedTeams;
break;
default:
adminOnly = true;
break;
}
return {
administratorsOnly: adminOnly,
public: publicOnly,
users,
teams,
subResources,
};
}

View file

@ -0,0 +1,48 @@
import { UserId } from '@/portainer/users/types';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
import axios, { parseAxiosError } from '../services/axios';
import { parseOwnershipParameters } from './helper';
import { OwnershipParameters } from './types';
/**
* Apply a ResourceControl after Resource creation
* @param userId ID of User performing the action
* @param accessControlData ResourceControl to apply
* @param resourceControl ResourceControl to update
* @param subResources SubResources managed by the ResourceControl
*/
export function applyResourceControl(
userId: UserId,
accessControlData: AccessControlFormData,
resourceControl: ResourceControlResponse,
subResources: (number | string)[] = []
) {
const ownershipParameters = parseOwnershipParameters(
userId,
accessControlData,
subResources
);
return updateResourceControl(resourceControl.Id, ownershipParameters);
}
/**
* Update a ResourceControl
* @param resourceControlId ID of involved resource
* @param ownershipParameters Transient type from view data to payload
*/
async function updateResourceControl(
resourceControlId: string | number,
ownershipParameters: OwnershipParameters
) {
try {
await axios.put(
`/resource_controls/${resourceControlId}`,
ownershipParameters
);
} catch (error) {
throw parseAxiosError(error as Error);
}
}

View file

@ -0,0 +1,13 @@
import { TeamId } from '@/portainer/teams/types';
import { UserId } from '@/portainer/users/types';
/**
* Transient type from view data to payload
*/
export interface OwnershipParameters {
administratorsOnly: boolean;
public: boolean;
users: UserId[];
teams: TeamId[];
subResources: (number | string)[];
}

View file

@ -1,4 +1,6 @@
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import axiosOrigin, { AxiosError, AxiosRequestConfig } from 'axios';
import { loadProgressBar } from 'axios-progress-bar';
import 'axios-progress-bar/dist/nprogress.css';
import PortainerError from '../error';
import { get as localStorageGet } from '../hooks/useLocalStorage';
@ -8,11 +10,13 @@ import {
portainerAgentTargetHeader,
} from './http-request.helper';
const axiosApiInstance = axios.create({ baseURL: 'api' });
const axios = axiosOrigin.create({ baseURL: 'api' });
export default axiosApiInstance;
loadProgressBar(undefined, axios);
axiosApiInstance.interceptors.request.use(async (config) => {
export default axios;
axios.interceptors.request.use(async (config) => {
const newConfig = { headers: config.headers || {}, ...config };
const jwt = localStorageGet('JWT', '');
@ -41,18 +45,28 @@ export function agentInterceptor(config: AxiosRequestConfig) {
return newConfig;
}
axiosApiInstance.interceptors.request.use(agentInterceptor);
axios.interceptors.request.use(agentInterceptor);
export function parseAxiosError(err: Error, msg = '') {
export function parseAxiosError(
err: Error,
msg = '',
parseError = defaultErrorParser
) {
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;
const { error, details } = parseError(err as AxiosError);
resultErr = error;
resultMsg = msg ? `${msg}: ${details}` : details;
}
return new PortainerError(resultMsg, resultErr);
}
function defaultErrorParser(axiosError: AxiosError) {
const message = axiosError.response?.data.message;
const details = axiosError.response?.data.details || message;
const error = new Error(message);
return { error, details };
}