Skip to content

Commit

Permalink
Ms2/iam interface (#118)
Browse files Browse the repository at this point in the history
* Fixed and Updated OpenApi and changed users endpoint

* Data hooks: added MutationOptions and changed keys

* Added Users page and Comming Soon page for roles

* Added useFuzySearch hook

* Typo

* Removed submodule files

* Removed 204 response for roles

* Fix: cannot delete default roles

* Moved toCaslResources to ms-v2

* Removed debugger statement

* PUT /role now uses only fields that changed for auth

* Updated authorization rules

* Fixed API endpoints in OpenAPI description

* Refactor and implemented useInvalidateAsseta

* Updated subject type to accept objects

* Moved AuthCallbackListener to be inside of AntDesign context

* Updated active segment logic

* Moved user list to a separate component

* Implemented role pages
  • Loading branch information
FelipeTrost authored Oct 16, 2023
1 parent 5a5f77f commit 213da0f
Show file tree
Hide file tree
Showing 38 changed files with 1,329 additions and 273 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client';

import Content from '@/components/content';
import Auth from '@/lib/AuthCanWrapper';
import { useGetAsset } from '@/lib/fetch-data';
import { Button, Result, Skeleton, Space, Tabs } from 'antd';
import { LeftOutlined } from '@ant-design/icons';
import { ComponentProps } from 'react';
import RoleGeneralData from './roleGeneralData';
import { useRouter } from 'next/navigation';
import RolePermissions from './rolePermissions';
import RoleMembers from './role-members';

type Items = ComponentProps<typeof Tabs>['items'];

function RolePage({ params: { roleId } }: { params: { roleId: string } }) {
const router = useRouter();
const {
data: role,
isLoading,
error,
} = useGetAsset('/roles/{id}', {
params: { path: { id: roleId } },
});

const items: Items = role
? [
{
key: 'members',
label: 'Manage Members',
children: <RoleMembers role={role} isLoadingRole={isLoading} />,
},
{ key: 'permissions', label: 'Permissions', children: <RolePermissions role={role} /> },
{
key: 'generalData',
label: 'General Data',
children: <RoleGeneralData roleId={roleId} />,
},
]
: [];

if (error) return;
<Result
status="error"
title="Failed to fetch role"
subTitle="An error ocurred while fetching role, please try again."
/>;

return (
<Content
title={
<Space>
<Button icon={<LeftOutlined />} onClick={() => router.push('/iam/roles')} type="text">
Back
</Button>
{role?.name}
</Space>
}
>
<Skeleton loading={isLoading}>
<Tabs items={items} />
</Skeleton>
</Content>
);
}

export default Auth(
{
action: ['view', 'manage'],
resource: 'Role',
fallbackRedirect: '/',
},
RolePage,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
'use client';

import { FC, useMemo, useState } from 'react';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import {
ApiData,
useDeleteAsset,
useGetAsset,
useInvalidateAsset,
usePostAsset,
} from '@/lib/fetch-data';
import UserList, { UserListProps } from '@/components/user-list';
import { Button, Modal, Popconfirm, Tooltip } from 'antd';

type Role = ApiData<'/roles', 'get'>[number];

const AddUserModal: FC<{ role: Role; open: boolean; close: () => void }> = ({
role,
open,
close,
}) => {
const { data: users, isLoading: isLoadingUsers } = useGetAsset('/users', {});
const invalidateRole = useInvalidateAsset('/roles/{id}', { params: { path: { id: role.id } } });
const { mutateAsync, isLoading: isLoadingMutation } = usePostAsset('/role-mappings', {
onSuccess: invalidateRole,
});

type AddUserParams = Parameters<NonNullable<UserListProps['selectedRowActions']>>;
const addUsers = async (users: AddUserParams[2], clearIds?: AddUserParams[1]) => {
if (clearIds) clearIds();
await mutateAsync({
body: users.map((user) => ({
userId: user.id,
roleId: role.id,
email: user.email,
lastName: user.lastName,
firstName: user.firstName,
username: user.username,
})),
parseAs: 'text',
});
};

const usersNotInRole = useMemo(() => {
if (!users) return [];

const usersInRole = new Set(role.members.map((member) => member.userId));

return users.filter((user) => !usersInRole.has(user.id));
}, [users, role.members]);

return (
<Modal
open={open}
onCancel={close}
footer={null}
width={800}
title={`Add members to ${role.name}`}
>
<UserList
users={usersNotInRole}
loading={isLoadingUsers || isLoadingMutation}
columns={(clearSelected) => [
{
dataIndex: 'id',
render: (_, user) => (
<Tooltip placement="top" title="Add to role">
<Button
icon={<PlusOutlined />}
type="text"
onClick={() => addUsers([user], clearSelected)}
/>
</Tooltip>
),
},
]}
selectedRowActions={(_, clearIds, users) => (
<Tooltip placement="top" title="Add to role">
<Button icon={<PlusOutlined />} type="text" onClick={() => addUsers(users, clearIds)} />
</Tooltip>
)}
/>
</Modal>
);
};

const RoleMembers: FC<{ role: Role; isLoadingRole?: boolean }> = ({ role, isLoadingRole }) => {
const [addUserModalOpen, setAddUserModalOpen] = useState(false);

const refetchRole = useInvalidateAsset('/roles/{id}', { params: { path: { id: role.id } } });
const { mutateAsync: deleteUser, isLoading: isLoadingDelete } = useDeleteAsset(
'/role-mappings/users/{userId}/roles/{roleId}',
{ onSuccess: refetchRole },
);

async function deleteMembers(userIds: string[], clearIds?: () => void) {
if (clearIds) clearIds();

await Promise.allSettled(
userIds.map((userId) =>
deleteUser({
params: { path: { roleId: role.id, userId: userId } },
}),
),
);
}

return (
<>
<AddUserModal role={role} open={addUserModalOpen} close={() => setAddUserModalOpen(false)} />
<UserList
users={role.members.map((member) => ({ ...member, id: member.userId }))}
loading={isLoadingDelete || isLoadingRole}
columns={(clearSelected) => [
{
dataIndex: 'id',
key: 'remove',
title: '',
width: 100,
render: (id: string) => (
<Tooltip placement="top" title="Remove member">
<Popconfirm
title="Remove member"
description="Are you sure you want to remove this member?"
onConfirm={() => deleteMembers([id], clearSelected)}
>
<Button icon={<DeleteOutlined />} type="text" />
</Popconfirm>
</Tooltip>
),
},
]}
selectedRowActions={(ids, clearIds) => (
<Tooltip placement="top" title="Remove members">
<Popconfirm
title="Remove member"
description="Are you sure you want to remove this member?"
onConfirm={() => deleteMembers(ids, clearIds)}
>
<Button icon={<DeleteOutlined />} type="text" />
</Popconfirm>
</Tooltip>
)}
searchBarRightNode={
<Button type="primary" onClick={() => setAddUserModalOpen(true)}>
Add member
</Button>
}
/>
</>
);
};

export default RoleMembers;
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import Auth from '@/lib/AuthCanWrapper';
import { toCaslResource } from '@/lib/ability/caslAbility';
import { useGetAsset, usePutAsset } from '@/lib/fetch-data';
import { useAuthStore } from '@/lib/iam';
import { Alert, App, Button, DatePicker, Form, Input, Spin } from 'antd';
import { FC } from 'react';
import dayjs from 'dayjs';
import germanLocale from 'antd/es/date-picker/locale/de_DE';

const RoleGeneralData: FC<{ roleId: string }> = ({ roleId }) => {
const { message } = App.useApp();
const ability = useAuthStore((store) => store.ability);
const [form] = Form.useForm();

const { data, isLoading, error } = useGetAsset('/roles/{id}', {
params: { path: { id: roleId } },
});

const { mutateAsync: updateRole, isLoading: putLoading } = usePutAsset('/roles/{id}', {
onError: () => message.open({ type: 'error', content: 'Something went wrong' }),
});

if (isLoading || error || !data) return <Spin />;

const role = toCaslResource('Role', data);

async function submitChanges(values: Record<string, any>) {
if (typeof values.expirationDayJs === 'object') {
values.expiration = (values.expirationDayJs as dayjs.Dayjs).toISOString();
delete values.expirationDayJs;
}

try {
await updateRole({
params: { path: { id: roleId } },
body: values,
});

// success message has to go here, or else the mutation will stop loading when the message
// disappears
message.open({ type: 'success', content: 'Role updated' });
} catch (e) {}
}

return (
<Form form={form} layout="vertical" onFinish={submitChanges} initialValues={role}>
{role.note && (
<>
<Alert type="warning" message={role.note} />
<br />
</>
)}
<Form.Item label="Name" name="name">
<Input placeholder="input placeholder" disabled={!ability.can('update', role, 'name')} />
</Form.Item>

<Form.Item label="Description" name="description">
<Input.TextArea
placeholder="input placeholder"
disabled={!ability.can('update', role, 'description')}
/>
</Form.Item>

<Form.Item label="Expiration" name="expirationDayJs">
<DatePicker
// Note german locale hard coded
locale={germanLocale}
allowClear={true}
disabled={!ability.can('update', role, 'expiration')}
defaultValue={role.expiration ? dayjs(new Date(role.expiration)) : undefined}
/>
</Form.Item>

<Form.Item>
<Button type="primary" htmlType="submit" loading={putLoading}>
Submit
</Button>
</Form.Item>
</Form>
);
};

export default Auth(
{
action: ['view', 'manage'],
resource: 'Role',
fallbackRedirect: '/',
},
RoleGeneralData,
);
Loading

0 comments on commit 213da0f

Please sign in to comment.