Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,393 @@
'use client';

import { Form, Space, Button, Modal, Collapse, Flex } from 'antd';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { ResourceActionType, ResourceType } from '@/lib/ability/caslAbility';
import { FC, use, useEffect, useMemo, useState } from 'react';
import {
ResourcePermissionInputs,
formDataToPermissions,
permissionsToFormData,
switchChecked,
} from './role-permissions-helper';
import { addRole, handleFolderRoleChanges } from '@/lib/data/roles';
import { Role, RoleWithChildren } from '@/lib/data/role-schema';
import { useEnvironment } from '@/components/auth-can';
import { EnvVarsContext } from '@/components/env-vars-context';
import { Folder } from '@/lib/data/folder-schema';
import { FolderTree } from '@/components/FolderTree';
import { useRouter } from 'next/navigation';
import { truthyFilter } from '@/lib/typescript-utils';

type SelectionFolder = { id: string; name: string; type: 'folder' };

type PermissionCategory = {
key: string;
title: string;
resource: ResourceType;
permissions: {
key: string;
title: string;
description: string;
permission: ResourceActionType;
}[];
};

const FolderSelection: React.FC<{
defaultFolders: SelectionFolder[];
onSubmit: (selected: SelectionFolder[]) => void;
notSelectable: string[];
}> = ({ defaultFolders, onSubmit, notSelectable }) => {
const [selectedFolders, setSelectedFolders] = useState<SelectionFolder[]>(defaultFolders);

return (
<>
<Modal
title="Choose a folder"
open={true}
onOk={() => onSubmit(selectedFolders)}
onCancel={() => onSubmit([])}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not quite sure what will happen on code level if we submit it with empty array here in this case. Can you please specify what are we trying to achieve with this. Because normally, what I would have done here is to just onCancel={() => onSubmit(initialFolders)} i.e., restoring the initial state

cancelText={null}
closeIcon={null}
>
<Space orientation="vertical" style={{ maxWidth: '100%' }}>
<Button
onClick={() => {
setSelectedFolders([]);
}}
type="default"
danger
>
Clear Folders
</Button>
<FolderTree<SelectionFolder>
newChildrenHook={({ nodes }) => nodes.filter((node) => node.element.type === 'folder')}
onMultiSelect={(elements) => {
setSelectedFolders(elements || []);
}}
selectedKeys={selectedFolders.map((f) => f.id)}
showRootAsFolder
notSelectableKeys={notSelectable}
/>
</Space>
</Modal>
</>
);
};

const basePermissionOptions: PermissionCategory[] = [
{
key: 'process',
title: 'PROCESSES',
resource: 'Process',
permissions: [
{
key: 'process_view',
title: 'View Processes',
description: 'Allows a user to view processes. (Enables the Processes view.)',
permission: 'view',
},
{
key: 'process_manage',
title: 'Manage Processes',
description: 'Allows a user to create, modify and delete processes.',
permission: 'manage',
},
],
},
{
key: 'folder',
title: 'Folders',
resource: 'Folder',
permissions: [
{
key: 'folder_view',
title: 'View Folders',
description: 'Allows a user to view folders.',
permission: 'view',
},
{
key: 'folder_manage',
title: 'Manage Folders',
description: 'Allows a user to create, modify and delete folders.',
permission: 'manage',
},
],
},
{
key: 'executions',
title: 'EXECUTIONS',
resource: 'Execution',
permissions: [
{
key: 'View Executions',
title: 'View Executions',
description: 'Allows a user to view all executions. (Enables the Executions view.)',
permission: 'view',
},
{
key: 'Manage Executions',
title: 'Manage Executions',
description: 'Allows a user to to start, modify and delete process executions.',
permission: 'manage',
},
],
},
];

const groupFolders = (role: RoleWithChildren, options: PermissionCategory[], folders: Folder[]) => {
const groups: Record<string, RoleWithChildren[]> = {};

for (const r of role.children) {
let groupId = 0;

options.forEach((o) => {
o.permissions.forEach((p) => {
groupId += switchChecked(r.permissions, o.resource, p.permission) ? 1 : 0;
groupId <<= 1;
});
});

if (groups[groupId]) {
groups[groupId].push(r);
} else {
groups[groupId] = [r];
}
}

return Object.values(groups).map((group) => ({
folders: group.map((r) => {
const folder = folders.find((f) => r.parentId === f.id) || {
id: r.parentId!,
name: '',
type: 'folder',
};

if ('parentId' in folder && !folder.parentId) folder.name = '< root >';

return { ...folder, type: 'folder' as const };
}),
permissions: permissionsToFormData(options, group[0].permissions),
}));
};

const FolderPermissions: FC<{ role: RoleWithChildren; folders: Folder[] }> = ({
role,
folders,
}) => {
const environment = useEnvironment();
const envVars = use(EnvVarsContext);

const router = useRouter();

const [form] = Form.useForm();

const [loading, setLoading] = useState(false);

const options = basePermissionOptions.filter((permissionCategory) => {
return (
envVars.PROCEED_PUBLIC_PROCESS_AUTOMATION_ACTIVE ||
permissionCategory.resource !== 'Execution'
);
});

const [groups, setGroups] = useState(groupFolders(role, options, folders));

useEffect(() => {
const values = Object.fromEntries(
groups.map((group, index) => [index.toString(), group.permissions]),
);

form.setFieldsValue(values);
}, [groups, form]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think including form in the dependency array could cause circular updates and I guess it doesn't need to be a dependency. This could also lead to unnecessary re-renders or infinite loops in edge cases. May be try to remove form from the dependencies.


const alreadySelectedFolders = groups
.map(({ folders }) => folders)
.flat()
.map((f) => f.id);

const [initialFolders, setInitialFolders] = useState<SelectionFolder[] | undefined>();
const [groupInEditing, setGroupInEditing] = useState<number | undefined>();
const [notSelectable, setNotSelectable] = useState<string[]>([]);

const items = groups.map((g, index) => ({
key: index.toString(),
label: (
<Flex justify="space-between">
<div>{g.folders.map((f) => f.name || f.id).join(', ')}</div>
<Space.Compact>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
setInitialFolders(g.folders);
setNotSelectable(
alreadySelectedFolders.filter((id) => !g.folders.some((f) => f.id === id)),
);
setGroupInEditing(index);
}}
/>
<Button
size="small"
type="text"
icon={<DeleteOutlined />}
onClick={(e) => {
Modal.confirm({
title: 'Are you sure you want to delete the permissions for these folders?',
content:
'All users associated with the role will lose access rights to the specified folders',
onOk: () => setGroups([...groups.slice(0, index), ...groups.slice(index + 1)]),
});
e.stopPropagation();
}}
/>
</Space.Compact>
</Flex>
),
children: (
<ResourcePermissionInputs
pathPrefix={[index]}
options={options}
permissions={g.permissions}
/>
),
forceRender: true,
}));

async function updateRoles() {
setLoading(true);

const values = (await form.validateFields()) as Record<
string,
Record<ResourceType, Record<ResourceActionType, boolean>>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If validateFields() or handleFolderRoleChanges() fails, then loading will never be set to false and users will not see any error message. Wrapping in try-catch with user feedback and a finally block for loading state might be a better option.

>;

const existingFolderRoles = Object.fromEntries(
role.children.map(({ id, parentId }) => [parentId, id]),
);

const updates: { roleId: string; permissions: Role['permissions'] }[] = [];
const additions: Parameters<typeof addRole>[1][] = [];

Object.entries(values).forEach(([indexString, resource]) => {
const index = parseInt(indexString);
const permissions = formDataToPermissions(resource);

groups[index].folders.forEach((folder) => {
if (existingFolderRoles[folder.id]) {
updates.push({ roleId: existingFolderRoles[folder.id], permissions });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would happen if a folder is moved from one group to another. I mean, if I have understood it correctly, when a folder X moves from group A to group B, it will be in additions for group B, but the old role from group A won't be in removals unless group A is completely empty. This could create duplicate child roles for the same folder.

} else {
additions.push({
name: `${role.name}-${folder.name || folder.id}`,
environmentId: environment.spaceId,
permissions,
parentRoleId: role.id,
parentId: folder.id,
});
}
});
});

// remove all child roles that refer to folders that are not mapped to rules anymore
const removals = role.children
.filter(
(child) =>
!groups.some((group) => group.folders.some((folder) => folder.id === child.parentId)),
)
.map((role) => role.id);

await handleFolderRoleChanges(environment.spaceId, role.id, additions, updates, removals);

router.refresh();

setLoading(false);
}

// this is needed to ensure that the form only resets to the values from the last save and not
// to the initial values from when the page was opened
const initialFormValues = useMemo(() => {
const initialGroups = groupFolders(role, options, folders);

return Object.fromEntries(
initialGroups.map((group, index) => [
index.toString(),
permissionsToFormData(options, group.permissions),
]),
);
}, [role, options, folders]);

return (
<Form form={form} onFinish={updateRoles} initialValues={initialFormValues}>
{!!items.length && <Collapse items={items} accordion />}
<Button
style={{ marginTop: '10px' }}
block
onClick={() => {
setInitialFolders([]);
setNotSelectable(alreadySelectedFolders);
setGroupInEditing(undefined);
}}
>
New Folder(s) Selection
</Button>
{!!initialFolders && (
<FolderSelection
notSelectable={notSelectable}
defaultFolders={initialFolders}
onSubmit={(selected) => {
const addingNewGroup = groupInEditing === undefined;
const index = addingNewGroup ? groups.length : groupInEditing;
const newPermissions = addingNewGroup ? {} : groups[index].permissions;

let newGroup;
if (selected.length) {
newGroup = { folders: selected, permissions: newPermissions };
}

const values = form.getFieldsValue();

// make sure to update the existing groups so the form is not overwritten with the
// initial values by the useEffect above
const updateGroup = (group: (typeof groups)[number], index: number) => {
return { ...group, permissions: values[index.toString()] };
};

// add a new group or update an existing group with different folders
setGroups(
[
...groups.slice(0, index).map(updateGroup),
newGroup,
...groups.slice(index + 1).map(updateGroup),
].filter(truthyFilter),
);

setInitialFolders(undefined);
setGroupInEditing(undefined);
}}
/>
)}

<Flex justify="end" gap={5} style={{ marginTop: '20px', position: 'sticky', bottom: 0 }}>
<Button
loading={loading}
onClick={() => {
Modal.confirm({
title: 'Undo Changes',
content: 'Are you sure that you want to undo all unsaved changes?',
onOk: () => setGroups(groupFolders(role, options, folders)),
});
}}
>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
Save
</Button>
</Flex>
</Form>
);
};

export default FolderPermissions;
Loading
Loading