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:
parent
6ff4fd3db2
commit
e9ebef15a0
20 changed files with 514 additions and 228 deletions
|
@ -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} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Access } from '../types';
|
||||
|
||||
export const helper = createColumnHelper<Access>();
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
import { helper } from './helper';
|
||||
|
||||
export const name = helper.accessor('Name', {});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import { helper } from './helper';
|
||||
|
||||
export const type = helper.accessor('Type', {});
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
25
app/react/portainer/users/RolesView/useRbacRoles.ts
Normal file
25
app/react/portainer/users/RolesView/useRbacRoles.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue