1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-10 00:05:24 +02:00

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
This commit is contained in:
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

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

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 function Example() {
const [selectedUsers, setSelectedUsers] = useState([10]);
export { Example };
function Example() {
const [selectedUsers, setSelectedUsers] = useState([1]);
const users = [createMockUser(1, 'user1'), createMockUser(2, 'user2')];

View file

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

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

View file

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

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 {
@ -40,3 +41,17 @@ export function agentInterceptor(config: AxiosRequestConfig) {
}
axiosApiInstance.interceptors.request.use(agentInterceptor);
export function parseAxiosError(err: Error, msg = '') {
let resultErr = err;
let resultMsg = msg;
if ('isAxiosError' in err) {
const axiosError = err as AxiosError;
resultErr = new Error(`${axiosError.response?.data.message}`);
const msgDetails = axiosError.response?.data.details;
resultMsg = msg ? `${msg}: ${msgDetails}` : msgDetails;
}
return new PortainerError(resultMsg, resultErr);
}

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;