mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-23 23:30:54 +00:00
[PUI] User settings panel updates (#7944)
* Simplify user theme settings * Cleanup * Fix permission on user list endpoint * Update AccountDetailPanel to use modal form * Update components * UI updates * Implement default colors * Display more user details (read only) * Add specific "MeUserSerializer" - Prevent certain attributes from being adjusted * Add <YesNoUndefinedButton> * Allow role checks to be bypassed for a given view - Override the 'get_permission_model' attribute with None * Enable 'GET' metadata - Required for extracting field information even if we only have 'read' permissions - e.g. getting table columns for users without write perms - use 'GET' action when reading table cols * Add info on new user account * Fix boolean expression wrapper * Ruff fixes * Adjust icon * Update unit test * Bummp API version * Table layout fix
This commit is contained in:
docs/docs
src
backend
InvenTree
frontend
src
components
buttons
pages
Index
Settings
tables
@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Badge } from '@mantine/core';
|
||||
import { Badge, Skeleton } from '@mantine/core';
|
||||
|
||||
import { isTrue } from '../../functions/conversion';
|
||||
|
||||
@ -32,3 +32,11 @@ export function PassFailButton({
|
||||
export function YesNoButton({ value }: { value: any }) {
|
||||
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
|
||||
}
|
||||
|
||||
export function YesNoUndefinedButton({ value }: { value?: boolean }) {
|
||||
if (value === undefined) {
|
||||
return <Skeleton height={15} width={32} />;
|
||||
} else {
|
||||
return <YesNoButton value={value} />;
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Button, Group, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useToggle } from '@mantine/hooks';
|
||||
import { Group, Stack, Table, Title } from '@mantine/core';
|
||||
import { IconKey, IconUser } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { api } from '../../../../App';
|
||||
import { EditButton } from '../../../../components/buttons/EditButton';
|
||||
import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton';
|
||||
import { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField';
|
||||
import { ActionDropdown } from '../../../../components/items/ActionDropdown';
|
||||
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../../../../states/ApiState';
|
||||
import { notYetImplemented } from '../../../../functions/notifications';
|
||||
import { useEditApiFormModal } from '../../../../hooks/UseForm';
|
||||
import { useUserState } from '../../../../states/UserState';
|
||||
|
||||
export function AccountDetailPanel() {
|
||||
@ -14,66 +16,89 @@ export function AccountDetailPanel() {
|
||||
state.user,
|
||||
state.fetchUserState
|
||||
]);
|
||||
const form = useForm({ initialValues: user });
|
||||
const [editing, setEditing] = useToggle([false, true] as const);
|
||||
function SaveData(values: any) {
|
||||
// copy values over to break form rendering link
|
||||
const urlVals = { ...values };
|
||||
urlVals.is_active = true;
|
||||
// send
|
||||
api
|
||||
.put(apiUrl(ApiEndpoints.user_me), urlVals)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
setEditing();
|
||||
fetchUserState();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.error('ERR: Error saving user data');
|
||||
});
|
||||
}
|
||||
|
||||
const userFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
first_name: {},
|
||||
last_name: {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const editUser = useEditApiFormModal({
|
||||
title: t`Edit User Information`,
|
||||
url: ApiEndpoints.user_me,
|
||||
onFormSuccess: fetchUserState,
|
||||
fields: userFields,
|
||||
successMessage: t`User details updated`
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => SaveData(values))}>
|
||||
<Group>
|
||||
<Title order={3}>
|
||||
<Trans>Account Details</Trans>
|
||||
</Title>
|
||||
<EditButton setEditing={setEditing} editing={editing} />
|
||||
</Group>
|
||||
<Group>
|
||||
{editing ? (
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label="first name"
|
||||
placeholder={t`First name`}
|
||||
{...form.getInputProps('first_name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last name"
|
||||
placeholder={t`Last name`}
|
||||
{...form.getInputProps('last_name')}
|
||||
/>
|
||||
<Group justify="right" mt="md">
|
||||
<Button type="submit">
|
||||
<Trans>Submit</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="0">
|
||||
<Text>
|
||||
<Trans>First name: </Trans>
|
||||
{form.values.first_name}
|
||||
</Text>
|
||||
<Text>
|
||||
<Trans>Last name: </Trans>
|
||||
{form.values.last_name}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
</form>
|
||||
<>
|
||||
{editUser.modal}
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>
|
||||
<Trans>User Details</Trans>
|
||||
</Title>
|
||||
<ActionDropdown
|
||||
tooltip={t`User Actions`}
|
||||
icon={<IconUser />}
|
||||
actions={[
|
||||
{
|
||||
name: t`Edit User`,
|
||||
icon: <IconUser />,
|
||||
tooltip: t`Edit User Information`,
|
||||
onClick: editUser.open
|
||||
},
|
||||
{
|
||||
name: t`Set Password`,
|
||||
icon: <IconKey />,
|
||||
tooltip: t`Set User Password`,
|
||||
onClick: notYetImplemented
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Username</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>{user?.username}</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>First Name</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>{user?.first_name}</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Last Name</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>{user?.last_name}</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Staff Access</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<YesNoUndefinedButton value={user?.is_staff} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Superuser</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<YesNoUndefinedButton value={user?.is_superuser} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,51 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Button, Container, Group, Table, Title } from '@mantine/core';
|
||||
|
||||
import { ColorToggle } from '../../../../components/items/ColorToggle';
|
||||
import { LanguageSelect } from '../../../../components/items/LanguageSelect';
|
||||
import { IS_DEV } from '../../../../main';
|
||||
import { useLocalState } from '../../../../states/LocalState';
|
||||
|
||||
export function DisplaySettingsPanel({ height }: { height: number }) {
|
||||
function enablePseudoLang(): void {
|
||||
useLocalState.setState({ language: 'pseudo-LOCALE' });
|
||||
}
|
||||
|
||||
return (
|
||||
<Container w="100%" mih={height} p={0}>
|
||||
<Title order={3}>
|
||||
<Trans>Display Settings</Trans>
|
||||
</Title>
|
||||
<Table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Trans>Color Mode</Trans>
|
||||
</td>
|
||||
<td>
|
||||
<Group>
|
||||
<ColorToggle />
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Trans>Language</Trans>
|
||||
</td>
|
||||
<td>
|
||||
{' '}
|
||||
<Group>
|
||||
<LanguageSelect width={200} />
|
||||
{IS_DEV && (
|
||||
<Button onClick={enablePseudoLang} variant="light">
|
||||
<Trans>Use pseudo language</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import { Container, Grid, SimpleGrid } from '@mantine/core';
|
||||
|
||||
import { AccountDetailPanel } from './AccountDetailPanel';
|
||||
import { DisplaySettingsPanel } from './DisplaySettingsPanel';
|
||||
import { UserTheme } from './UserThemePanel';
|
||||
|
||||
export function AccountContent() {
|
||||
@ -18,9 +17,6 @@ export function AccountContent() {
|
||||
<Grid.Col>
|
||||
<UserTheme height={SECONDARY_COL_HEIGHT} />
|
||||
</Grid.Col>
|
||||
<Grid.Col>
|
||||
<DisplaySettingsPanel height={SECONDARY_COL_HEIGHT} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
ColorInput,
|
||||
ColorPicker,
|
||||
Container,
|
||||
@ -9,13 +11,18 @@ import {
|
||||
Select,
|
||||
Slider,
|
||||
Table,
|
||||
Title
|
||||
Title,
|
||||
useMantineTheme
|
||||
} from '@mantine/core';
|
||||
import { IconReload, IconRestore } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ColorToggle } from '../../../../components/items/ColorToggle';
|
||||
import { LanguageSelect } from '../../../../components/items/LanguageSelect';
|
||||
import { SizeMarks } from '../../../../defaults/defaults';
|
||||
import { notYetImplemented } from '../../../../functions/notifications';
|
||||
import { IS_DEV } from '../../../../main';
|
||||
import { useLocalState } from '../../../../states/LocalState';
|
||||
import { theme } from '../../../../theme';
|
||||
|
||||
function getLkp(color: string) {
|
||||
return { [DEFAULT_THEME.colors[color][6]]: color };
|
||||
@ -26,18 +33,24 @@ const LOOKUP = Object.assign(
|
||||
);
|
||||
|
||||
export function UserTheme({ height }: { height: number }) {
|
||||
// primary color
|
||||
function changePrimary(color: string) {
|
||||
useLocalState.setState({ primaryColor: LOOKUP[color] });
|
||||
}
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const [themeLoader, setThemeLoader] = useLocalState((state) => [
|
||||
state.loader,
|
||||
state.setLoader
|
||||
]);
|
||||
|
||||
// white color
|
||||
const [whiteColor, setWhiteColor] = useState(theme.white);
|
||||
|
||||
function changeWhite(color: string) {
|
||||
useLocalState.setState({ whiteColor: color });
|
||||
setWhiteColor(color);
|
||||
}
|
||||
|
||||
// black color
|
||||
const [blackColor, setBlackColor] = useState(theme.black);
|
||||
|
||||
function changeBlack(color: string) {
|
||||
useLocalState.setState({ blackColor: color });
|
||||
setBlackColor(color);
|
||||
@ -48,6 +61,7 @@ export function UserTheme({ height }: { height: number }) {
|
||||
if (obj) return obj;
|
||||
return SizeMarks[0];
|
||||
}
|
||||
|
||||
function getDefaultRadius() {
|
||||
const obj = SizeMarks.find(
|
||||
(mark) => mark.label === useLocalState.getState().radius
|
||||
@ -60,16 +74,23 @@ export function UserTheme({ height }: { height: number }) {
|
||||
setRadius(value);
|
||||
useLocalState.setState({ radius: getMark(value).label });
|
||||
}
|
||||
// loader
|
||||
|
||||
// Set theme primary color
|
||||
function changePrimary(color: string) {
|
||||
useLocalState.setState({ primaryColor: LOOKUP[color] });
|
||||
}
|
||||
|
||||
function enablePseudoLang(): void {
|
||||
useLocalState.setState({ language: 'pseudo-LOCALE' });
|
||||
}
|
||||
|
||||
// Custom loading indicator
|
||||
const loaderDate = [
|
||||
{ value: 'bars', label: t`bars` },
|
||||
{ value: 'oval', label: t`oval` },
|
||||
{ value: 'dots', label: t`dots` }
|
||||
{ value: 'bars', label: t`Bars` },
|
||||
{ value: 'oval', label: t`Oval` },
|
||||
{ value: 'dots', label: t`Dots` }
|
||||
];
|
||||
const [themeLoader, setThemeLoader] = useLocalState((state) => [
|
||||
state.loader,
|
||||
state.setLoader
|
||||
]);
|
||||
|
||||
function changeLoader(value: string | null) {
|
||||
if (value === null) return;
|
||||
setThemeLoader(value);
|
||||
@ -78,13 +99,39 @@ export function UserTheme({ height }: { height: number }) {
|
||||
return (
|
||||
<Container w="100%" mih={height} p={0}>
|
||||
<Title order={3}>
|
||||
<Trans>Theme</Trans>
|
||||
<Trans>Display Settings</Trans>
|
||||
</Title>
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Primary color</Trans>
|
||||
<Trans>Language</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<LanguageSelect width={200} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{IS_DEV && (
|
||||
<Button onClick={enablePseudoLang} variant="light">
|
||||
<Trans>Use pseudo language</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Color Mode</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="left">
|
||||
<ColorToggle />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td></Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Highlight color</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ColorPicker
|
||||
@ -94,6 +141,11 @@ export function UserTheme({ height }: { height: number }) {
|
||||
swatches={Object.keys(LOOKUP)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button color={theme.primaryColor} variant="light">
|
||||
<Trans>Example</Trans>
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
@ -102,6 +154,14 @@ export function UserTheme({ height }: { height: number }) {
|
||||
<Table.Td>
|
||||
<ColorInput value={whiteColor} onChange={changeWhite} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
onClick={() => changeWhite('#FFFFFF')}
|
||||
>
|
||||
<IconRestore />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
@ -110,6 +170,14 @@ export function UserTheme({ height }: { height: number }) {
|
||||
<Table.Td>
|
||||
<ColorInput value={blackColor} onChange={changeBlack} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
onClick={() => changeBlack('#000000')}
|
||||
>
|
||||
<IconRestore />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
@ -132,13 +200,17 @@ export function UserTheme({ height }: { height: number }) {
|
||||
<Trans>Loader</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group align="center">
|
||||
<Group justify="left">
|
||||
<Select
|
||||
data={loaderDate}
|
||||
value={themeLoader}
|
||||
onChange={changeLoader}
|
||||
/>
|
||||
<Loader type={themeLoader} mah={18} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="left">
|
||||
<Loader type={themeLoader} mah={16} size="sm" />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
@ -153,7 +153,8 @@ export function InvenTreeTable<T = any>({
|
||||
getTableColumnNames,
|
||||
setTableColumnNames,
|
||||
getTableSorting,
|
||||
setTableSorting
|
||||
setTableSorting,
|
||||
loader
|
||||
} = useLocalState();
|
||||
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
|
||||
|
||||
@ -192,8 +193,9 @@ export function InvenTreeTable<T = any>({
|
||||
// Extract field information from the API
|
||||
|
||||
let names: Record<string, string> = {};
|
||||
|
||||
let fields: ApiFormFieldSet =
|
||||
extractAvailableFields(response, 'POST', true) || {};
|
||||
extractAvailableFields(response, 'GET', true) || {};
|
||||
|
||||
// Extract flattened map of fields
|
||||
mapFields(fields, (path, field) => {
|
||||
@ -720,7 +722,7 @@ export function InvenTreeTable<T = any>({
|
||||
withColumnBorders
|
||||
striped
|
||||
highlightOnHover
|
||||
loaderType="dots"
|
||||
loaderType={loader}
|
||||
pinLastColumn={tableProps.rowActions != undefined}
|
||||
idAccessor={tableProps.idAccessor}
|
||||
minHeight={300}
|
||||
|
Reference in New Issue
Block a user