1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

refactor(rbac): migrate access table to react [EE-4710] (#10823)

This commit is contained in:
Chaim Lev-Ari 2024-04-11 09:49:38 +03:00 committed by GitHub
parent 6ff4fd3db2
commit e9ebef15a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 514 additions and 228 deletions

View file

@ -0,0 +1,39 @@
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} />
)}
/>
);
}

View file

@ -0,0 +1,197 @@
import { Check, UserX } from 'lucide-react';
import { useMemo, useState } from 'react';
import _ from 'lodash';
import {
TeamAccessViewModel,
UserAccessViewModel,
} from '@/portainer/models/access';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Datatable } from '@@/datatables';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { withMeta } from '@@/datatables/extend-options/withMeta';
import { Button } from '@@/buttons';
import { TextTip } from '@@/Tip/TextTip';
import { useColumns } from './columns/useColumns';
import { Access } from './types';
import { RemoveAccessButton } from './RemoveAccessButton';
export function AccessDatatable({
dataset,
tableKey,
onRemove,
onUpdate,
showWarning = false,
isUpdateEnabled = false,
showRoles = false,
inheritFrom = false,
}: {
tableKey: string;
dataset?: Array<Access>;
onRemove(items: Array<Access>): void;
onUpdate(
users: Array<UserAccessViewModel>,
teams: Array<TeamAccessViewModel>
): void;
showWarning?: boolean;
isUpdateEnabled?: boolean;
showRoles?: boolean;
inheritFrom?: boolean;
}) {
const columns = useColumns({ showRoles, inheritFrom });
const [store] = useState(() => createPersistedStore(tableKey));
const tableState = useTableState(store, tableKey);
const rolesState = useRolesState();
return (
<Datatable
data-cy="access-datatable"
title="Access"
titleIcon={UserX}
dataset={dataset || []}
isLoading={!dataset}
columns={columns}
settingsManager={tableState}
extendTableOptions={withMeta({
table: 'access-table',
roles: rolesState,
})}
isRowSelectable={({ original: item }) => !inheritFrom || !item.Inherited}
emptyContentLabel="No authorized users or teams."
renderTableActions={(selectedItems) => (
<>
<RemoveAccessButton items={selectedItems} onClick={onRemove} />
{isBE && isUpdateEnabled && (
<Button
data-cy="update-access-button"
icon={Check}
disabled={rolesState.count === 0}
onClick={handleUpdate}
>
Update
</Button>
)}
</>
)}
description={
<div className="small text-muted mx-4 mb-4">
{inheritFrom && (
<>
<div>
Access tagged as <code>inherited</code> are inherited from the
group access. They cannot be removed or modified at the
environment level but they can be overridden.
</div>
<div>
Access tagged as <code>override</code> are overriding the group
</div>
</>
)}
{isBE && showWarning && isUpdateEnabled && (
<TextTip>
<div className="text-warning-9 th-highcontrast:text-warning-1 th-dark:text-warning-7">
Updating user access will require the affected user(s) to logout
and login for the changes to be taken into account.
</div>
</TextTip>
)}
</div>
}
/>
);
function handleUpdate() {
const update = rolesState.getUpdate();
const teamsAccess = getAccess(update.teams, 'team');
const usersAccess = getAccess(update.users, 'user');
onUpdate(usersAccess, teamsAccess);
function getAccess(
accesses: Record<number, number | undefined>,
type: 'team' | 'user'
) {
return _.compact(
Object.entries(accesses).map(([strId, role]) => {
if (!strId || !role) {
return undefined;
}
const id = parseInt(strId, 10);
const entity = dataset?.find(
(item) => item.Type === type && item.Id === id
);
if (!entity) {
return undefined;
}
return {
...entity,
Role: {
Id: role,
Name: '',
},
};
})
);
}
}
}
function useRolesState() {
const [teamRoles, setTeamRoles] = useState<
Record<number, number | undefined>
>({});
const [userRoles, setUserRoles] = useState<
Record<number, number | undefined>
>({});
const count = useMemo(
() => Object.keys(teamRoles).length + Object.keys(userRoles).length,
[teamRoles, userRoles]
);
return { getRoleValue, setRolesValue, getUpdate, count };
function getRoleValue(id: number, entity: 'user' | 'team') {
if (entity === 'team') {
return teamRoles[id];
}
return userRoles[id];
}
function setRolesValue(
id: number,
entity: 'user' | 'team',
value: number | undefined
) {
if (entity === 'team') {
setTeamRoles(updater);
return;
}
setUserRoles(updater);
function updater(roles: Record<number, number | undefined>) {
const newRoles = { ...roles };
if (typeof value === 'undefined') {
delete newRoles[id];
} else {
newRoles[id] = value;
}
return newRoles;
}
}
function getUpdate() {
return {
users: userRoles,
teams: teamRoles,
};
}
}

View file

@ -0,0 +1,20 @@
import { DeleteButton } from '@@/buttons/DeleteButton';
import { Access } from './types';
export function RemoveAccessButton({
onClick,
items,
}: {
onClick(items: Array<Access>): void;
items: Array<Access>;
}) {
return (
<DeleteButton
confirmMessage="Are you sure you want to unauthorized the selected users or teams?"
onConfirmed={() => onClick(items)}
disabled={items.length === 0}
data-cy="remove-access-button"
/>
);
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Access } from '../types';
export const helper = createColumnHelper<Access>();

View file

@ -0,0 +1,22 @@
import { helper } from './helper';
export const inheritedName = helper.accessor('Name', {
cell({ row: { original: item }, getValue }) {
const name = getValue();
return (
<>
{name}
{item.Inherited && (
<span className="text-muted small">
<code className="text-sm">inherited</code>
</span>
)}
{item.Override && (
<span className="text-muted small">
<code className="text-sm">override</code>
</span>
)}
</>
);
},
});

View file

@ -0,0 +1,3 @@
import { helper } from './helper';
export const name = helper.accessor('Name', {});

View file

@ -0,0 +1,89 @@
import { CellContext } from '@tanstack/react-table';
import { Edit, X } from 'lucide-react';
import { useRbacRoles } from '@/react/portainer/users/RolesView/useRbacRoles';
import { Button } from '@@/buttons';
import { Select } from '@@/form-components/Input';
import { Access, getTableMeta } from '../types';
import { helper } from './helper';
export const role = helper.accessor('Role.Name', {
cell: RoleCell,
meta: {
width: 320,
},
});
function RoleCell({
row: { original: item, getCanSelect },
table,
getValue,
}: CellContext<Access, string>) {
const meta = getTableMeta(table.options.meta);
const type = item.Type as 'team' | 'user';
const updateValue = meta.roles.getRoleValue(item.Id, type);
const role = getValue();
if (!getCanSelect()) {
return <>{role}</>;
}
if (typeof updateValue === 'undefined') {
return (
<>
{role}
<Button
color="none"
icon={Edit}
onClick={() => meta.roles.setRolesValue(item.Id, type, item.Role.Id)}
data-cy="edit-role-button"
>
Edit
</Button>
</>
);
}
return (
<RollEdit
value={updateValue}
onChange={(value) => meta.roles.setRolesValue(item.Id, type, value)}
/>
);
}
function RollEdit({
value,
onChange,
}: {
value: number;
onChange(value?: number): void;
}) {
const rolesQuery = useRbacRoles({
select: (roles) => roles.map((r) => ({ label: r.Name, value: r.Id })),
});
if (!rolesQuery.data) {
return null;
}
return (
<div className="flex items-center gap-3 max-w-xs">
<Select
aria-label="Role"
data-cy="role-select"
value={value}
options={rolesQuery.data}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
/>
<Button
color="none"
icon={X}
onClick={() => onChange()}
data-cy="cancel-role-button"
/>
</div>
);
}

View file

@ -0,0 +1,3 @@
import { helper } from './helper';
export const type = helper.accessor('Type', {});

View file

@ -0,0 +1,27 @@
import _ from 'lodash';
import { useMemo } from 'react';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { inheritedName } from './inheritedName';
import { name } from './name';
import { type } from './type';
import { role } from './role';
export function useColumns({
showRoles,
inheritFrom,
}: {
showRoles: boolean;
inheritFrom: boolean;
}) {
return useMemo(
() =>
_.compact([
inheritFrom ? inheritedName : name,
type,
isBE && showRoles && role,
]),
[inheritFrom, showRoles]
);
}

View file

@ -0,0 +1,34 @@
import {
TeamAccessViewModel,
UserAccessViewModel,
} from '@/portainer/models/access';
export type Access = UserAccessViewModel | TeamAccessViewModel;
export interface TableMeta {
table: 'access-table';
roles: {
getRoleValue(id: number, entity: 'user' | 'team'): number | undefined;
setRolesValue(
id: number,
entity: 'user' | 'team',
value: number | undefined
): void;
};
}
function isTableMeta(meta?: unknown): meta is TableMeta {
return (
!!meta &&
typeof meta === 'object' &&
'table' in meta &&
meta.table === 'access-table'
);
}
export function getTableMeta(meta: unknown) {
if (!isTableMeta(meta)) {
throw new Error('missing table meta');
}
return meta;
}

View file

@ -0,0 +1,25 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { RbacRole } from './types';
export function useRbacRoles<T = Array<RbacRole>>({
select,
}: {
select: (roles: Array<RbacRole>) => T;
}) {
return useQuery({
select,
queryKey: ['roles'],
queryFn: async () => {
try {
const { data } = await axios.get<Array<RbacRole>>('/roles');
return data;
} catch (e) {
throw parseAxiosError(e, 'Failed to fetch roles');
}
},
});
}