mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 12:05:53 +00:00
Added first UI components for user managment (#5875)
* Added first UI components for user managment Ref #4962 * Add user roles to table and serializer * added key to AddItem actions * added ordering to group * style text * do not show unnecessary options * fix admi / superuser usage * switched to use BooleanColumn * Added active column * added user role change action * added user active change action * Added api change log * fixed logical error * added admin center to navigation * added groups to user serializer * added groups to the uI * Added user drawer * fixed active state * remove actions as they are not usable after refactor * move functions to drawer * added drawer lock state * added edit toggle * merge fix * renamed values * remove empty roles section * fix settings header * make title shorter to reducelayout shift when switching to server settings
This commit is contained in:
@ -1,18 +1,8 @@
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Space,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { Anchor, Group, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconSwitch } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
/**
|
||||
* Construct a settings page header with interlinks to one other settings page
|
||||
*/
|
||||
@ -24,35 +14,28 @@ export function SettingsHeader({
|
||||
switch_text,
|
||||
switch_link
|
||||
}: {
|
||||
title: string;
|
||||
title: string | ReactNode;
|
||||
shorthand?: string;
|
||||
subtitle?: string;
|
||||
subtitle?: string | ReactNode;
|
||||
switch_condition?: boolean;
|
||||
switch_text?: string | ReactNode;
|
||||
switch_link?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper shadow="xs" radius="xs" p="xs">
|
||||
<Group position="apart">
|
||||
<Stack spacing="xs">
|
||||
<Group position="left" spacing="xs">
|
||||
<StylishText size="xl">{title}</StylishText>
|
||||
<Text size="sm">{shorthand}</Text>
|
||||
</Group>
|
||||
<Text italic>{subtitle}</Text>
|
||||
</Stack>
|
||||
<Space />
|
||||
<Stack spacing="0" ml={'sm'}>
|
||||
<Group>
|
||||
<Title order={3}>{title}</Title>
|
||||
{shorthand && <Text c="dimmed">({shorthand})</Text>}
|
||||
</Group>
|
||||
<Group>
|
||||
<Text c="dimmed">{subtitle}</Text>
|
||||
{switch_text && switch_link && switch_condition && (
|
||||
<Anchor component={Link} to={switch_link}>
|
||||
<Button variant="outline">
|
||||
<Group spacing="sm">
|
||||
<IconSwitch size={18} />
|
||||
<Text>{switch_text}</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
<IconSwitch size={14} />
|
||||
{switch_text}
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
102
src/frontend/src/components/tables/settings/GroupTable.tsx
Normal file
102
src/frontend/src/components/tables/settings/GroupTable.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||
import {
|
||||
openCreateApiForm,
|
||||
openDeleteApiForm,
|
||||
openEditApiForm
|
||||
} from '../../../functions/forms';
|
||||
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||
import { TableColumn } from '../Column';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
|
||||
/**
|
||||
* Table for displaying list of groups
|
||||
*/
|
||||
export function GroupTable() {
|
||||
const { tableKey, refreshTable } = useTableRefresh('groups');
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
title: t`Name`
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const rowActions = useCallback((record: any): RowAction[] => {
|
||||
return [
|
||||
RowEditAction({
|
||||
onClick: () => {
|
||||
openEditApiForm({
|
||||
url: ApiPaths.group_list,
|
||||
pk: record.pk,
|
||||
title: t`Edit group`,
|
||||
fields: {
|
||||
name: {}
|
||||
},
|
||||
onFormSuccess: refreshTable,
|
||||
successMessage: t`Group updated`
|
||||
});
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
onClick: () => {
|
||||
openDeleteApiForm({
|
||||
url: ApiPaths.group_list,
|
||||
pk: record.pk,
|
||||
title: t`Delete group`,
|
||||
successMessage: t`Group deleted`,
|
||||
onFormSuccess: refreshTable,
|
||||
preFormContent: (
|
||||
<Text>{t`Are you sure you want to delete this group?`}</Text>
|
||||
)
|
||||
});
|
||||
}
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const addGroup = useCallback(() => {
|
||||
openCreateApiForm({
|
||||
url: ApiPaths.group_list,
|
||||
title: t`Add group`,
|
||||
fields: { name: {} },
|
||||
onFormSuccess: refreshTable,
|
||||
successMessage: t`Added group`
|
||||
});
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
let actions = [];
|
||||
|
||||
actions.push(
|
||||
<AddItemButton
|
||||
key={'add-group'}
|
||||
onClick={addGroup}
|
||||
tooltip={t`Add group`}
|
||||
/>
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiPaths.group_list)}
|
||||
tableKey={tableKey}
|
||||
columns={columns}
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
customActionGroups: tableActions
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
217
src/frontend/src/components/tables/settings/UserDrawer.tsx
Normal file
217
src/frontend/src/components/tables/settings/UserDrawer.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
Chip,
|
||||
Drawer,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useToggle } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '../../../App';
|
||||
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||
import {
|
||||
invalidResponse,
|
||||
permissionDenied
|
||||
} from '../../../functions/notifications';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { EditButton } from '../../items/EditButton';
|
||||
import { UserDetailI } from './UserTable';
|
||||
|
||||
export function UserDrawer({
|
||||
opened,
|
||||
close,
|
||||
refreshTable,
|
||||
userDetail
|
||||
}: {
|
||||
opened: boolean;
|
||||
close: () => void;
|
||||
refreshTable: () => void;
|
||||
userDetail: UserDetailI | undefined;
|
||||
}) {
|
||||
const [user] = useUserState((state) => [state.user]);
|
||||
const [rightsValue, setRightsValue] = useState(['']);
|
||||
const [locked, setLocked] = useState<boolean>(false);
|
||||
const [userEditing, setUserEditing] = useToggle([false, true] as const);
|
||||
|
||||
// Set initial values
|
||||
useEffect(() => {
|
||||
if (!userDetail) return;
|
||||
|
||||
setLocked(true);
|
||||
// rights
|
||||
let new_rights = [];
|
||||
if (userDetail.is_staff) {
|
||||
new_rights.push('is_staff');
|
||||
}
|
||||
if (userDetail.is_active) {
|
||||
new_rights.push('is_active');
|
||||
}
|
||||
if (userDetail.is_superuser) {
|
||||
new_rights.push('is_superuser');
|
||||
}
|
||||
setRightsValue(new_rights);
|
||||
|
||||
setLocked(false);
|
||||
}, [userDetail]);
|
||||
|
||||
// actions on role change
|
||||
function changeRights(roles: [string]) {
|
||||
if (!userDetail) return;
|
||||
|
||||
let data = {
|
||||
is_staff: roles.includes('is_staff'),
|
||||
is_superuser: roles.includes('is_superuser')
|
||||
};
|
||||
if (
|
||||
data.is_staff != userDetail.is_staff ||
|
||||
data.is_superuser != userDetail.is_superuser
|
||||
) {
|
||||
setPermission(userDetail.pk, data);
|
||||
}
|
||||
if (userDetail.is_active != roles.includes('is_active')) {
|
||||
setActive(userDetail.pk, roles.includes('is_active'));
|
||||
}
|
||||
setRightsValue(roles);
|
||||
}
|
||||
|
||||
function setPermission(pk: number, data: any) {
|
||||
setLocked(true);
|
||||
api
|
||||
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, data)
|
||||
.then(() => {
|
||||
notifications.show({
|
||||
title: t`User permission changed successfully`,
|
||||
message: t`Some changes might only take effect after the user refreshes their login.`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size="1rem" />
|
||||
});
|
||||
refreshTable();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.status === 403) {
|
||||
permissionDenied();
|
||||
} else {
|
||||
console.log(error);
|
||||
invalidResponse(error.response.status);
|
||||
}
|
||||
})
|
||||
.finally(() => setLocked(false));
|
||||
}
|
||||
|
||||
function setActive(pk: number, active: boolean) {
|
||||
setLocked(true);
|
||||
api
|
||||
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, {
|
||||
is_active: active
|
||||
})
|
||||
.then(() => {
|
||||
notifications.show({
|
||||
title: t`Changed user active status successfully`,
|
||||
message: t`Set to ${active}`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size="1rem" />
|
||||
});
|
||||
refreshTable();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.status === 403) {
|
||||
permissionDenied();
|
||||
} else {
|
||||
console.log(error);
|
||||
invalidResponse(error.response.status);
|
||||
}
|
||||
})
|
||||
.finally(() => setLocked(false));
|
||||
}
|
||||
|
||||
const userEditable = locked || !userEditing;
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
position="right"
|
||||
title={userDetail ? t`User details for ${userDetail.username}` : ''}
|
||||
overlayProps={{ opacity: 0.5, blur: 4 }}
|
||||
>
|
||||
<Stack spacing={'xs'}>
|
||||
<Group>
|
||||
<Title order={5}>
|
||||
<Trans>Details</Trans>
|
||||
</Title>
|
||||
<EditButton
|
||||
editing={userEditing}
|
||||
setEditing={setUserEditing}
|
||||
disabled
|
||||
/>
|
||||
</Group>
|
||||
{userDetail ? (
|
||||
<Stack spacing={0} ml={'md'}>
|
||||
<TextInput
|
||||
label={t`Username`}
|
||||
value={userDetail.username}
|
||||
disabled={userEditable}
|
||||
/>
|
||||
<TextInput label={t`Email`} value={userDetail.email} disabled />
|
||||
<TextInput
|
||||
label={t`First Name`}
|
||||
value={userDetail.first_name}
|
||||
disabled={userEditable}
|
||||
/>
|
||||
<TextInput
|
||||
label={t`Last Name`}
|
||||
value={userDetail.last_name}
|
||||
disabled={userEditable}
|
||||
/>
|
||||
|
||||
<Text>
|
||||
<Trans>Rights</Trans>
|
||||
</Text>
|
||||
<Chip.Group multiple value={rightsValue} onChange={changeRights}>
|
||||
<Group spacing={0}>
|
||||
<Chip value="is_active" disabled={locked || !user?.is_staff}>
|
||||
<Trans>Active</Trans>
|
||||
</Chip>
|
||||
<Chip value="is_staff" disabled={locked || !user?.is_staff}>
|
||||
<Trans>Staff</Trans>
|
||||
</Chip>
|
||||
<Chip
|
||||
value="is_superuser"
|
||||
disabled={locked || !(user?.is_staff && user?.is_superuser)}
|
||||
>
|
||||
<Trans>Superuser</Trans>
|
||||
</Chip>
|
||||
</Group>
|
||||
</Chip.Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
|
||||
<Title order={5}>
|
||||
<Trans>Groups</Trans>
|
||||
</Title>
|
||||
<Text ml={'md'}>
|
||||
{userDetail && userDetail.groups.length == 0 ? (
|
||||
<Trans>No groups</Trans>
|
||||
) : (
|
||||
<List>
|
||||
{userDetail &&
|
||||
userDetail.groups.map((message) => (
|
||||
<List.Item key={message.name}>{message.name}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
176
src/frontend/src/components/tables/settings/UserTable.tsx
Normal file
176
src/frontend/src/components/tables/settings/UserTable.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||
import {
|
||||
openCreateApiForm,
|
||||
openDeleteApiForm,
|
||||
openEditApiForm
|
||||
} from '../../../functions/forms';
|
||||
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||
import { TableColumn } from '../Column';
|
||||
import { BooleanColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
import { UserDrawer } from './UserDrawer';
|
||||
|
||||
interface GroupDetailI {
|
||||
pk: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserDetailI {
|
||||
pk: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
groups: GroupDetailI[];
|
||||
is_active: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table for displaying list of users
|
||||
*/
|
||||
export function UserTable() {
|
||||
const { tableKey, refreshTable } = useTableRefresh('users');
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [userDetail, setUserDetail] = useState<UserDetailI>();
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'email',
|
||||
sortable: true,
|
||||
title: t`Email`
|
||||
},
|
||||
{
|
||||
accessor: 'username',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
title: t`Username`
|
||||
},
|
||||
{
|
||||
accessor: 'first_name',
|
||||
sortable: true,
|
||||
title: t`First Name`
|
||||
},
|
||||
{
|
||||
accessor: 'last_name',
|
||||
sortable: true,
|
||||
title: t`Last Name`
|
||||
},
|
||||
{
|
||||
accessor: 'groups',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
title: t`Groups`,
|
||||
render: (record: any) => {
|
||||
return record.groups.length;
|
||||
}
|
||||
},
|
||||
BooleanColumn({
|
||||
accessor: 'is_staff',
|
||||
title: t`Staff`
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'is_superuser',
|
||||
title: t`Superuser`
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'is_active',
|
||||
title: t`Active`
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
|
||||
return [
|
||||
RowEditAction({
|
||||
onClick: () => {
|
||||
openEditApiForm({
|
||||
url: ApiPaths.user_list,
|
||||
pk: record.pk,
|
||||
title: t`Edit user`,
|
||||
fields: {
|
||||
email: {},
|
||||
first_name: {},
|
||||
last_name: {}
|
||||
},
|
||||
onFormSuccess: refreshTable,
|
||||
successMessage: t`User updated`
|
||||
});
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
onClick: () => {
|
||||
openDeleteApiForm({
|
||||
url: ApiPaths.user_list,
|
||||
pk: record.pk,
|
||||
title: t`Delete user`,
|
||||
successMessage: t`user deleted`,
|
||||
onFormSuccess: refreshTable,
|
||||
preFormContent: (
|
||||
<Text>{t`Are you sure you want to delete this user?`}</Text>
|
||||
)
|
||||
});
|
||||
}
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const addUser = useCallback(() => {
|
||||
openCreateApiForm({
|
||||
url: ApiPaths.user_list,
|
||||
title: t`Add user`,
|
||||
fields: {
|
||||
username: {},
|
||||
email: {},
|
||||
first_name: {},
|
||||
last_name: {}
|
||||
},
|
||||
onFormSuccess: refreshTable,
|
||||
successMessage: t`Added user`
|
||||
});
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
let actions = [];
|
||||
|
||||
actions.push(
|
||||
<AddItemButton key="add-user" onClick={addUser} tooltip={t`Add user`} />
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserDrawer
|
||||
opened={opened}
|
||||
close={close}
|
||||
refreshTable={refreshTable}
|
||||
userDetail={userDetail}
|
||||
/>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiPaths.user_list)}
|
||||
tableKey={tableKey}
|
||||
columns={columns}
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
customActionGroups: tableActions,
|
||||
onRowClick: (record: any) => {
|
||||
setUserDetail(record);
|
||||
open();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -57,6 +57,11 @@ export const menuItems: MenuLinkItem[] = [
|
||||
id: 'settings-system',
|
||||
text: <Trans>System Settings</Trans>,
|
||||
link: '/settings/system'
|
||||
},
|
||||
{
|
||||
id: 'settings-admin',
|
||||
text: <Trans>Admin Center</Trans>,
|
||||
link: '/settings/admin'
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -21,6 +21,7 @@ export enum ApiPaths {
|
||||
user_email_remove = 'api-user-email-remove',
|
||||
|
||||
user_list = 'api-user-list',
|
||||
group_list = 'api-group-list',
|
||||
owner_list = 'api-owner-list',
|
||||
|
||||
settings_global_list = 'api-settings-global-list',
|
||||
|
@ -16,6 +16,8 @@ import { PlaceholderPill } from '../../../components/items/Placeholder';
|
||||
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||
import { GroupTable } from '../../../components/tables/settings/GroupTable';
|
||||
import { UserTable } from '../../../components/tables/settings/UserTable';
|
||||
|
||||
/**
|
||||
* System settings page
|
||||
@ -28,7 +30,14 @@ export default function AdminCenter() {
|
||||
label: t`User Management`,
|
||||
content: (
|
||||
<Stack spacing="xs">
|
||||
<PlaceholderPill />
|
||||
<Title order={5}>
|
||||
<Trans>Users</Trans>
|
||||
</Title>
|
||||
<UserTable />
|
||||
<Title order={5}>
|
||||
<Trans>Groups</Trans>
|
||||
</Title>
|
||||
<GroupTable />
|
||||
<Divider />
|
||||
<Stack spacing={0}>
|
||||
<Text>
|
||||
@ -87,7 +96,7 @@ export default function AdminCenter() {
|
||||
<Stack spacing="xs">
|
||||
<SettingsHeader
|
||||
title={t`Admin Center`}
|
||||
subtitle={t`Advanced Amininistrative Options for InvenTree`}
|
||||
subtitle={t`Advanced Options`}
|
||||
switch_link="/settings/system"
|
||||
switch_text="System Settings"
|
||||
/>
|
||||
|
@ -116,6 +116,12 @@ export function apiEndpoint(path: ApiPaths): string {
|
||||
return 'version/';
|
||||
case ApiPaths.sso_providers:
|
||||
return 'auth/providers/';
|
||||
case ApiPaths.user_list:
|
||||
return 'user/';
|
||||
case ApiPaths.group_list:
|
||||
return 'user/group/';
|
||||
case ApiPaths.owner_list:
|
||||
return 'user/owner/';
|
||||
case ApiPaths.build_order_list:
|
||||
return 'build/';
|
||||
case ApiPaths.build_order_attachment_list:
|
||||
|
Reference in New Issue
Block a user