mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(namespace): migrate namespace access view to react [r8s-141] (#87)
This commit is contained in:
parent
8ed7cd80cb
commit
e9fc6d5598
62 changed files with 1018 additions and 610 deletions
|
@ -1,39 +0,0 @@
|
|||
import { UserX } from 'lucide-react';
|
||||
|
||||
import { name } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/columns/name';
|
||||
import { type } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/columns/type';
|
||||
import { Access } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/types';
|
||||
import { RemoveAccessButton } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/RemoveAccessButton';
|
||||
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { Datatable } from '@@/datatables';
|
||||
|
||||
const tableKey = 'kubernetes_resourcepool_access';
|
||||
const columns = [name, type];
|
||||
const store = createPersistedStore(tableKey);
|
||||
|
||||
export function NamespaceAccessDatatable({
|
||||
dataset,
|
||||
onRemove,
|
||||
}: {
|
||||
dataset?: Array<Access>;
|
||||
onRemove(items: Array<Access>): void;
|
||||
}) {
|
||||
const tableState = useTableState(store, tableKey);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
data-cy="kube-namespace-access-datatable"
|
||||
title="Namespace Access"
|
||||
titleIcon={UserX}
|
||||
dataset={dataset || []}
|
||||
isLoading={!dataset}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<RemoveAccessButton items={selectedItems} onClick={onRemove} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
import { UserX } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||
import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants';
|
||||
import { useConfigMap } from '@/react/kubernetes/configs/queries/useConfigMap';
|
||||
import { useUpdateK8sConfigMapMutation } from '@/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||
|
||||
import { parseNamespaceAccesses } from '../parseNamespaceAccesses';
|
||||
import { NamespaceAccess } from '../types';
|
||||
import { createUnauthorizeAccessConfigMapPayload } from '../createAccessConfigMapPayload';
|
||||
|
||||
import { entityType } from './columns/type';
|
||||
import { name } from './columns/name';
|
||||
|
||||
const tableKey = 'kubernetes_resourcepool_access';
|
||||
const columns = [name, entityType];
|
||||
const store = createPersistedStore(tableKey);
|
||||
|
||||
export function AccessDatatable() {
|
||||
const {
|
||||
params: { id: namespaceName },
|
||||
} = useCurrentStateAndParams();
|
||||
const router = useRouter();
|
||||
const environmentId = useEnvironmentId();
|
||||
const tableState = useTableState(store, tableKey);
|
||||
const usersQuery = useUsers(false, environmentId);
|
||||
const teamsQuery = useTeams(false, environmentId);
|
||||
const accessConfigMapQuery = useConfigMap(
|
||||
environmentId,
|
||||
PortainerNamespaceAccessesConfigMap.namespace,
|
||||
PortainerNamespaceAccessesConfigMap.configMapName
|
||||
);
|
||||
const namespaceAccesses = useMemo(
|
||||
() =>
|
||||
parseNamespaceAccesses(
|
||||
accessConfigMapQuery.data ?? null,
|
||||
namespaceName,
|
||||
usersQuery.data ?? [],
|
||||
teamsQuery.data ?? []
|
||||
),
|
||||
[accessConfigMapQuery.data, usersQuery.data, teamsQuery.data, namespaceName]
|
||||
);
|
||||
const configMap = accessConfigMapQuery.data;
|
||||
|
||||
const updateConfigMapMutation = useUpdateK8sConfigMapMutation(
|
||||
environmentId,
|
||||
PortainerNamespaceAccessesConfigMap.namespace
|
||||
);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
data-cy="kube-namespace-access-datatable"
|
||||
title="Namespace access"
|
||||
titleIcon={UserX}
|
||||
dataset={namespaceAccesses}
|
||||
isLoading={accessConfigMapQuery.isLoading}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
// the user id and team id can be the same, so add the type to the id
|
||||
getRowId={(row) => `${row.type}-${row.id}`}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<DeleteButton
|
||||
isLoading={updateConfigMapMutation.isLoading}
|
||||
loadingText="Removing..."
|
||||
confirmMessage="Are you sure you want to unauthorized the selected users or teams?"
|
||||
onConfirmed={() => handleUpdate(selectedItems)}
|
||||
disabled={
|
||||
selectedItems.length === 0 ||
|
||||
usersQuery.isLoading ||
|
||||
teamsQuery.isLoading ||
|
||||
accessConfigMapQuery.isLoading
|
||||
}
|
||||
data-cy="remove-access-button"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
async function handleUpdate(selectedItemsToRemove: Array<NamespaceAccess>) {
|
||||
try {
|
||||
const configMapPayload = createUnauthorizeAccessConfigMapPayload(
|
||||
namespaceAccesses,
|
||||
selectedItemsToRemove,
|
||||
namespaceName,
|
||||
configMap
|
||||
);
|
||||
await updateConfigMapMutation.mutateAsync({
|
||||
data: configMapPayload,
|
||||
configMapName: PortainerNamespaceAccessesConfigMap.configMapName,
|
||||
});
|
||||
notifySuccess('Success', 'Namespace access updated');
|
||||
router.stateService.reload();
|
||||
} catch (error) {
|
||||
notifyError('Failed to update namespace access', error as Error);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { NamespaceAccess } from '../../types';
|
||||
|
||||
export const helper = createColumnHelper<NamespaceAccess>();
|
|
@ -0,0 +1,5 @@
|
|||
import { helper } from './helper';
|
||||
|
||||
export const name = helper.accessor('name', {
|
||||
header: 'Name',
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
import { helper } from './helper';
|
||||
|
||||
export const entityType = helper.accessor('type', {
|
||||
header: 'Type',
|
||||
});
|
39
app/react/kubernetes/namespaces/AccessView/AccessView.tsx
Normal file
39
app/react/kubernetes/namespaces/AccessView/AccessView.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { NamespaceDetailsWidget } from './NamespaceDetailsWidget';
|
||||
import { AccessDatatable } from './AccessDatatable/AccessDatatable';
|
||||
import { CreateAccessWidget } from './CreateAccessWidget/CreateAccessWidget';
|
||||
|
||||
export function AccessView() {
|
||||
const {
|
||||
params: { id: namespaceName },
|
||||
} = useCurrentStateAndParams();
|
||||
useUnauthorizedRedirect(
|
||||
{ authorizations: ['K8sResourcePoolDetailsW'] },
|
||||
{ to: 'kubernetes.resourcePools' }
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Namespace access management"
|
||||
breadcrumbs={[
|
||||
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
|
||||
{
|
||||
label: namespaceName,
|
||||
link: 'kubernetes.resourcePools.resourcePool',
|
||||
linkParams: { id: namespaceName },
|
||||
},
|
||||
'Access management',
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
<NamespaceDetailsWidget />
|
||||
<CreateAccessWidget />
|
||||
<AccessDatatable />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
import { Form, FormikProps } from 'formik';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||
import { useGroup } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { useTeams } from '@/react/portainer/users/teams/queries/useTeams';
|
||||
import { User } from '@/portainer/users/types';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { Team } from '@/react/portainer/users/teams/types';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
|
||||
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { NamespaceAccessUsersSelector } from '../NamespaceAccessUsersSelector';
|
||||
import { EnvironmentAccess, NamespaceAccess } from '../types';
|
||||
|
||||
import { CreateAccessValues } from './types';
|
||||
|
||||
export function CreateAccessInnerForm({
|
||||
values,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
dirty,
|
||||
namespaceAccessesGranted,
|
||||
}: FormikProps<CreateAccessValues> & {
|
||||
namespaceAccessesGranted: NamespaceAccess[];
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const environmentQuery = useEnvironment(environmentId);
|
||||
const groupQuery = useGroup(environmentQuery.data?.GroupId);
|
||||
const usersQuery = useUsers(false, environmentId);
|
||||
const teamsQuery = useTeams();
|
||||
const availableTeamOrUserOptions: EnvironmentAccess[] =
|
||||
useAvailableTeamOrUserOptions(
|
||||
values.selectedUsersAndTeams,
|
||||
namespaceAccessesGranted,
|
||||
environmentQuery.data,
|
||||
groupQuery.data,
|
||||
usersQuery.data,
|
||||
teamsQuery.data
|
||||
);
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
return (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
|
||||
<FormControl label="Select user(s) and/or team(s)">
|
||||
{availableTeamOrUserOptions.length > 0 ||
|
||||
values.selectedUsersAndTeams.length > 0 ? (
|
||||
<NamespaceAccessUsersSelector
|
||||
inputId="users-selector"
|
||||
options={availableTeamOrUserOptions}
|
||||
onChange={(opts) => setFieldValue('selectedUsersAndTeams', opts)}
|
||||
value={values.selectedUsersAndTeams}
|
||||
dataCy="namespaceAccess-usersSelector"
|
||||
/>
|
||||
) : (
|
||||
<span className="small text-muted pt-2">
|
||||
No user or team access has been set on the environment.
|
||||
{isAdminQuery.isAdmin && (
|
||||
<>
|
||||
{' '}
|
||||
Head over to the{' '}
|
||||
<Link
|
||||
to="portainer.endpoints"
|
||||
data-cy="namespaceAccess-environmentsLink"
|
||||
>
|
||||
Environments view
|
||||
</Link>{' '}
|
||||
to manage them.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
<div className="form-group mt-5">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid || !dirty}
|
||||
data-cy="namespaceAccess-createAccessButton"
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Creating access..."
|
||||
icon={Plus}
|
||||
className="!ml-0"
|
||||
>
|
||||
Create access
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the team and user options that can be added to the namespace, excluding the ones that already have access.
|
||||
*/
|
||||
function useAvailableTeamOrUserOptions(
|
||||
selectedAccesses: EnvironmentAccess[],
|
||||
namespaceAccessesGranted: NamespaceAccess[],
|
||||
environment?: Environment,
|
||||
group?: EnvironmentGroup,
|
||||
users?: User[],
|
||||
teams?: Team[]
|
||||
) {
|
||||
return useMemo(() => {
|
||||
// get unique users and teams from environment accesses (the keys are the IDs)
|
||||
const environmentAccessPolicies = environment?.UserAccessPolicies ?? {};
|
||||
const environmentTeamAccessPolicies = environment?.TeamAccessPolicies ?? {};
|
||||
const environmentGroupAccessPolicies = group?.UserAccessPolicies ?? {};
|
||||
const environmentGroupTeamAccessPolicies = group?.TeamAccessPolicies ?? {};
|
||||
|
||||
// get all users that have access to the environment
|
||||
const userAccessPolicies = {
|
||||
...environmentAccessPolicies,
|
||||
...environmentGroupAccessPolicies,
|
||||
};
|
||||
const uniqueUserIds = new Set(Object.keys(userAccessPolicies));
|
||||
const userAccessOptions: EnvironmentAccess[] = Array.from(uniqueUserIds)
|
||||
.map((id) => {
|
||||
const userId = parseInt(id, 10);
|
||||
const user = users?.find((u) => u.Id === userId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
// role from the userAccessPolicies is used by default, if not found, role from the environmentTeamAccessPolicies is used
|
||||
const userAccessPolicy =
|
||||
environmentAccessPolicies[userId] ??
|
||||
environmentGroupAccessPolicies[userId];
|
||||
const userAccess: EnvironmentAccess = {
|
||||
id: user?.Id,
|
||||
name: user?.Username,
|
||||
type: 'user',
|
||||
role: {
|
||||
name: 'Standard user',
|
||||
id: userAccessPolicy?.RoleId,
|
||||
},
|
||||
};
|
||||
return userAccess;
|
||||
})
|
||||
.filter((u) => u !== null);
|
||||
|
||||
// get all teams that have access to the environment
|
||||
const teamAccessPolicies = {
|
||||
...environmentTeamAccessPolicies,
|
||||
...environmentGroupTeamAccessPolicies,
|
||||
};
|
||||
const uniqueTeamIds = new Set(Object.keys(teamAccessPolicies));
|
||||
const teamAccessOptions: EnvironmentAccess[] = Array.from(uniqueTeamIds)
|
||||
.map((id) => {
|
||||
const teamId = parseInt(id, 10);
|
||||
const team = teams?.find((t) => t.Id === teamId);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
const teamAccessPolicy =
|
||||
environmentTeamAccessPolicies[teamId] ??
|
||||
environmentGroupTeamAccessPolicies[teamId];
|
||||
const teamAccess: EnvironmentAccess = {
|
||||
id: team?.Id,
|
||||
name: team?.Name,
|
||||
type: 'team',
|
||||
role: {
|
||||
name: 'Standard user',
|
||||
id: teamAccessPolicy?.RoleId,
|
||||
},
|
||||
};
|
||||
return teamAccess;
|
||||
})
|
||||
.filter((t) => t !== null);
|
||||
|
||||
// filter out users and teams that already have access to the namespace
|
||||
const userAndTeamEnvironmentAccesses = [
|
||||
...userAccessOptions,
|
||||
...teamAccessOptions,
|
||||
];
|
||||
const filteredAccessOptions = userAndTeamEnvironmentAccesses.filter(
|
||||
(t) =>
|
||||
!selectedAccesses.some((e) => e.id === t.id && e.type === t.type) &&
|
||||
!namespaceAccessesGranted.some(
|
||||
(e) => e.id === t.id && e.type === t.type
|
||||
)
|
||||
);
|
||||
|
||||
return filteredAccessOptions;
|
||||
}, [
|
||||
namespaceAccessesGranted,
|
||||
selectedAccesses,
|
||||
environment,
|
||||
group,
|
||||
users,
|
||||
teams,
|
||||
]);
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
import { UserPlusIcon } from 'lucide-react';
|
||||
import { Formik } from 'formik';
|
||||
import { useMemo } from 'react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { useIsRBACEnabled } from '@/react/kubernetes/cluster/useIsRBACEnabled';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { RBACAlert } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/RBACAlert';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants';
|
||||
import { useConfigMap } from '@/react/kubernetes/configs/queries/useConfigMap';
|
||||
import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||
import { useUpdateK8sConfigMapMutation } from '@/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { EnvironmentAccess } from '../types';
|
||||
import { createAuthorizeAccessConfigMapPayload } from '../createAccessConfigMapPayload';
|
||||
import { parseNamespaceAccesses } from '../parseNamespaceAccesses';
|
||||
|
||||
import { CreateAccessValues } from './types';
|
||||
import { CreateAccessInnerForm } from './CreateAccessInnerForm';
|
||||
import { validationSchema } from './createAccess.validation';
|
||||
|
||||
export function CreateAccessWidget() {
|
||||
const {
|
||||
params: { id: namespaceName },
|
||||
} = useCurrentStateAndParams();
|
||||
const environmentId = useEnvironmentId();
|
||||
const isRBACEnabledQuery = useIsRBACEnabled(environmentId);
|
||||
const initialValues: {
|
||||
selectedUsersAndTeams: EnvironmentAccess[];
|
||||
} = {
|
||||
selectedUsersAndTeams: [],
|
||||
};
|
||||
const usersQuery = useUsers(false, environmentId);
|
||||
const teamsQuery = useTeams(false, environmentId);
|
||||
const accessConfigMapQuery = useConfigMap(
|
||||
environmentId,
|
||||
PortainerNamespaceAccessesConfigMap.namespace,
|
||||
PortainerNamespaceAccessesConfigMap.configMapName
|
||||
);
|
||||
const namespaceAccesses = useMemo(
|
||||
() =>
|
||||
parseNamespaceAccesses(
|
||||
accessConfigMapQuery.data ?? null,
|
||||
namespaceName,
|
||||
usersQuery.data ?? [],
|
||||
teamsQuery.data ?? []
|
||||
),
|
||||
[accessConfigMapQuery.data, usersQuery.data, teamsQuery.data, namespaceName]
|
||||
);
|
||||
const configMap = accessConfigMapQuery.data;
|
||||
|
||||
const updateConfigMapMutation = useUpdateK8sConfigMapMutation(
|
||||
environmentId,
|
||||
PortainerNamespaceAccessesConfigMap.namespace
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget aria-label="Create access">
|
||||
<WidgetTitle icon={UserPlusIcon} title="Create access" />
|
||||
<WidgetBody>
|
||||
{isRBACEnabledQuery.data === false && <RBACAlert />}
|
||||
<TextTip className="mb-2" childrenWrapperClassName="text-warning">
|
||||
Adding user access will require the affected user(s) to logout and
|
||||
login for the changes to be taken into account.
|
||||
</TextTip>
|
||||
{isRBACEnabledQuery.data !== false && (
|
||||
<Formik<CreateAccessValues>
|
||||
initialValues={initialValues}
|
||||
enableReinitialize
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={onSubmit}
|
||||
validateOnMount
|
||||
>
|
||||
{(formikProps) => (
|
||||
<CreateAccessInnerForm
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...formikProps}
|
||||
namespaceAccessesGranted={namespaceAccesses}
|
||||
/>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function onSubmit(
|
||||
values: {
|
||||
selectedUsersAndTeams: EnvironmentAccess[];
|
||||
},
|
||||
{ resetForm }: { resetForm: () => void }
|
||||
) {
|
||||
try {
|
||||
const configMapPayload = createAuthorizeAccessConfigMapPayload(
|
||||
namespaceAccesses,
|
||||
values.selectedUsersAndTeams,
|
||||
namespaceName,
|
||||
configMap
|
||||
);
|
||||
await updateConfigMapMutation.mutateAsync({
|
||||
data: configMapPayload,
|
||||
configMapName: PortainerNamespaceAccessesConfigMap.configMapName,
|
||||
});
|
||||
notifySuccess('Success', 'Namespace access updated');
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
notifyError('Failed to update namespace access', error as Error);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { object, array, string, number, SchemaOf, mixed } from 'yup';
|
||||
|
||||
import { CreateAccessValues } from './types';
|
||||
|
||||
export function validationSchema(): SchemaOf<CreateAccessValues> {
|
||||
return object().shape({
|
||||
selectedUsersAndTeams: array(
|
||||
object().shape({
|
||||
type: mixed().oneOf(['team', 'user']).required(),
|
||||
name: string().required(),
|
||||
id: number().required(),
|
||||
role: object().shape({
|
||||
id: number().required(),
|
||||
name: string().required(),
|
||||
}),
|
||||
})
|
||||
).min(1),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { EnvironmentAccess } from '../types';
|
||||
|
||||
export type CreateAccessValues = {
|
||||
selectedUsersAndTeams: EnvironmentAccess[];
|
||||
};
|
|
@ -3,14 +3,13 @@ import { OptionProps, components, MultiValueGenericProps } from 'react-select';
|
|||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
type Role = { Name: string };
|
||||
type Option = { Type: 'user' | 'team'; Id: number; Name: string; Role: Role };
|
||||
import { EnvironmentAccess } from './types';
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
value: Option[];
|
||||
onChange(value: readonly Option[]): void;
|
||||
options: Option[];
|
||||
value: EnvironmentAccess[];
|
||||
onChange(value: readonly EnvironmentAccess[]): void;
|
||||
options: EnvironmentAccess[];
|
||||
dataCy: string;
|
||||
inputId?: string;
|
||||
placeholder?: string;
|
||||
|
@ -29,8 +28,8 @@ export function NamespaceAccessUsersSelector({
|
|||
<Select
|
||||
isMulti
|
||||
name={name}
|
||||
getOptionLabel={(option) => option.Name}
|
||||
getOptionValue={(option) => `${option.Id}-${option.Type}`}
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => `${option.id}-${option.type}`}
|
||||
options={options}
|
||||
value={value}
|
||||
closeMenuOnSelect={false}
|
||||
|
@ -43,11 +42,14 @@ export function NamespaceAccessUsersSelector({
|
|||
);
|
||||
}
|
||||
|
||||
function isOption(option: unknown): option is Option {
|
||||
return !!option && typeof option === 'object' && 'Type' in option;
|
||||
function isOption(option: unknown): option is EnvironmentAccess {
|
||||
return !!option && typeof option === 'object' && 'type' in option;
|
||||
}
|
||||
|
||||
function OptionComponent({ data, ...props }: OptionProps<Option, true>) {
|
||||
function OptionComponent({
|
||||
data,
|
||||
...props
|
||||
}: OptionProps<EnvironmentAccess, true>) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<components.Option data={data} {...props}>
|
||||
|
@ -59,7 +61,7 @@ function OptionComponent({ data, ...props }: OptionProps<Option, true>) {
|
|||
function MultiValueLabel({
|
||||
data,
|
||||
...props
|
||||
}: MultiValueGenericProps<Option, true>) {
|
||||
}: MultiValueGenericProps<EnvironmentAccess, true>) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<components.MultiValueLabel data={data} {...props}>
|
||||
|
@ -68,15 +70,15 @@ function MultiValueLabel({
|
|||
);
|
||||
}
|
||||
|
||||
function Label({ option }: { option: Option }) {
|
||||
const Icon = option.Type === 'user' ? UserIcon : TeamIcon;
|
||||
function Label({ option }: { option: EnvironmentAccess }) {
|
||||
const Icon = option.type === 'user' ? UserIcon : TeamIcon;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon />
|
||||
<span>{option.Name}</span>
|
||||
<span>{option.name}</span>
|
||||
<span>|</span>
|
||||
<span>{option.Role.Name}</span>
|
||||
<span>{option.role.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { Layers } from 'lucide-react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { WidgetTitle, WidgetBody, Widget } from '@@/Widget';
|
||||
|
||||
export function NamespaceDetailsWidget() {
|
||||
const {
|
||||
params: { id: namespaceName },
|
||||
} = useCurrentStateAndParams();
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget aria-label="Namespace details">
|
||||
<WidgetTitle icon={Layers} title="Namespace" />
|
||||
<WidgetBody>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{namespaceName}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import { ConfigMap } from 'kubernetes-types/core/v1';
|
||||
import { concat, without } from 'lodash';
|
||||
|
||||
import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants';
|
||||
import { Configuration } from '@/react/kubernetes/configs/types';
|
||||
|
||||
import { NamespaceAccess } from './types';
|
||||
|
||||
export function createAuthorizeAccessConfigMapPayload(
|
||||
namespaceAccesses: NamespaceAccess[],
|
||||
selectedItems: NamespaceAccess[],
|
||||
namespaceName: string,
|
||||
configMap?: Configuration
|
||||
): ConfigMap {
|
||||
const newRemainingAccesses = concat(namespaceAccesses, ...selectedItems);
|
||||
return createAccessConfigMapPayload(
|
||||
newRemainingAccesses,
|
||||
namespaceName,
|
||||
configMap
|
||||
);
|
||||
}
|
||||
|
||||
export function createUnauthorizeAccessConfigMapPayload(
|
||||
namespaceAccesses: NamespaceAccess[],
|
||||
selectedItems: NamespaceAccess[],
|
||||
namespaceName: string,
|
||||
configMap?: Configuration
|
||||
): ConfigMap {
|
||||
const newRemainingAccesses = without(namespaceAccesses, ...selectedItems);
|
||||
return createAccessConfigMapPayload(
|
||||
newRemainingAccesses,
|
||||
namespaceName,
|
||||
configMap
|
||||
);
|
||||
}
|
||||
|
||||
function createAccessConfigMapPayload(
|
||||
newRemainingAccesses: NamespaceAccess[],
|
||||
namespaceName: string,
|
||||
configMap?: Configuration
|
||||
): ConfigMap {
|
||||
const configMapAccessesValue = JSON.parse(
|
||||
configMap?.Data?.[PortainerNamespaceAccessesConfigMap.accessKey] || '{}'
|
||||
);
|
||||
const newNamespaceAccesses = newRemainingAccesses.reduce(
|
||||
(namespaceAccesses, accessItem) => {
|
||||
if (accessItem.type === 'user') {
|
||||
return {
|
||||
...namespaceAccesses,
|
||||
UserAccessPolicies: {
|
||||
...namespaceAccesses.UserAccessPolicies,
|
||||
// hardcode to 0, as they use their environment role
|
||||
[`${accessItem.id}`]: { RoleId: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...namespaceAccesses,
|
||||
TeamAccessPolicies: {
|
||||
...namespaceAccesses.TeamAccessPolicies,
|
||||
// hardcode to 0, as they use their environment role
|
||||
[`${accessItem.id}`]: { RoleId: 0 },
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
UserAccessPolicies: {},
|
||||
TeamAccessPolicies: {},
|
||||
}
|
||||
);
|
||||
const newConfigMapAccessesValue = {
|
||||
...configMapAccessesValue,
|
||||
[namespaceName]: newNamespaceAccesses,
|
||||
};
|
||||
const updatedConfigMap: ConfigMap = {
|
||||
metadata: {
|
||||
name: PortainerNamespaceAccessesConfigMap.configMapName,
|
||||
namespace: PortainerNamespaceAccessesConfigMap.namespace,
|
||||
uid: configMap?.UID,
|
||||
},
|
||||
data: {
|
||||
...configMap?.Data,
|
||||
[PortainerNamespaceAccessesConfigMap.accessKey]: JSON.stringify(
|
||||
newConfigMapAccessesValue
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return updatedConfigMap;
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants';
|
||||
import { User } from '@/portainer/users/types';
|
||||
import { Team } from '@/react/portainer/users/teams/types';
|
||||
import { Configuration } from '@/react/kubernetes/configs/types';
|
||||
|
||||
import { NamespaceAccess, NamespaceAccessesMap } from './types';
|
||||
|
||||
export function parseNamespaceAccesses(
|
||||
data: Configuration | null,
|
||||
namespaceName: string,
|
||||
users: User[],
|
||||
teams: Team[]
|
||||
) {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const namespacesAccesses: NamespaceAccessesMap = JSON.parse(
|
||||
data?.Data?.[PortainerNamespaceAccessesConfigMap.accessKey] ?? '{}'
|
||||
);
|
||||
const userAccessesIds = Object.keys(
|
||||
namespacesAccesses[namespaceName]?.UserAccessPolicies ?? {}
|
||||
);
|
||||
const userAccesses: NamespaceAccess[] = users
|
||||
.filter((user) => userAccessesIds.includes(`${user.Id}`))
|
||||
.map((user) => ({
|
||||
id: user.Id,
|
||||
name: user.Username,
|
||||
type: 'user',
|
||||
}));
|
||||
const teamAccessesIds = Object.keys(
|
||||
namespacesAccesses[namespaceName]?.TeamAccessPolicies ?? {}
|
||||
);
|
||||
const teamAccesses: NamespaceAccess[] = teams
|
||||
.filter((team) => teamAccessesIds.includes(`${team.Id}`))
|
||||
.map((team) => ({
|
||||
id: team.Id,
|
||||
name: team.Name,
|
||||
type: 'team',
|
||||
}));
|
||||
return [...userAccesses, ...teamAccesses];
|
||||
}
|
23
app/react/kubernetes/namespaces/AccessView/types.ts
Normal file
23
app/react/kubernetes/namespaces/AccessView/types.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import {
|
||||
TeamAccessPolicies,
|
||||
UserAccessPolicies,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
export type NamespaceAccess = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'user' | 'team';
|
||||
};
|
||||
|
||||
export type EnvironmentAccess = NamespaceAccess & {
|
||||
role: { name: string; id: number };
|
||||
};
|
||||
|
||||
export interface NamespaceAccesses {
|
||||
UserAccessPolicies?: UserAccessPolicies;
|
||||
TeamAccessPolicies?: TeamAccessPolicies;
|
||||
}
|
||||
|
||||
export interface NamespaceAccessesMap {
|
||||
[key: string]: NamespaceAccesses;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue