2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 20:15:44 +00:00

feat(backend): Add user profile (#9116)

* Add user profile

* fix choice set

* ensure primary_group is valid

* add missing migrations

* fix tests

* merge migrations

* add migration test

* add new model to ruleset

* ensure changed to the m2m conenction also validate primary grups

* move signals

* fix import?

* patch user language through

* use set methods correctly

* bump api

* refactoring to make debugging and extending easier

* fix dum recurrsion problem

* fix user pk lookup

* rename migration

* add user and group page

* cleanup

* add hoverCard for user / owner / group render

* include owner_model in owner responses

* move user serializers to users

* add profile to list

* add brief serializer for profiles

* ensure profile is present in most apis

* extend rendered data

* store and observe langauge in profile

* reduce unneeded complexity

* enable access to full profle (including internal fields) in me serializer

* move theme to a single object

* persist theme settings

* fix radius lookup

* remove debug message

* fix filter

* remove unused field

* remove image fields

* add setting to control showing profiles

* fix settings

* update test

* fix theme reload

* Add contact UI

* Add profile edit screen

* fix test

* Add testing for user theme panel

* fix var name

* complete coverage of theme

* Add test for new pages

* make test more reliable in strict mode

* remove step

* fix ref

* add verbose names

* fix used setting

* extend tests

* fix permissions

* fix lookup

* use lookup to enuse ursls stay valid

* update migrations

* Add position field

* fix permissions
This commit is contained in:
Matthias Mair
2025-03-04 12:57:20 +01:00
committed by GitHub
parent 8bca48dbdd
commit 0d1ab4e75a
42 changed files with 1648 additions and 355 deletions

View File

@ -1,8 +1,10 @@
import { t } from '@lingui/macro';
import {
Anchor,
Avatar,
Badge,
Group,
HoverCard,
Paper,
Skeleton,
Stack,
@ -17,7 +19,7 @@ import { useNavigate } from 'react-router-dom';
import { useApi } from '../../contexts/ApiContext';
import { formatDate } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import type { ModelType } from '../../enums/ModelType';
import { ModelType } from '../../enums/ModelType';
import { InvenTreeIcon, type InvenTreeIconType } from '../../functions/icons';
import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls';
@ -92,6 +94,68 @@ type FieldProps = {
unit?: string | null;
};
function HoverNameBadge(data: any, type: BadgeType) {
function lines(data: any) {
switch (type) {
case 'owner':
return [
`${data.label}: ${data.name}`,
data.name,
getDetailUrl(data.owner_model, data.pk, true),
undefined,
undefined
];
case 'user':
return [
`${data.first_name} ${data.last_name}`,
data.username,
getDetailUrl(ModelType.user, data.pk, true),
data?.image,
<>
{data.is_superuser && <Badge color='red'>{t`Superuser`}</Badge>}
{data.is_staff && <Badge color='blue'>{t`Staff`}</Badge>}
{data.email && t`Email: ` + data.email}
</>
];
case 'group':
return [
data.name,
data.name,
getDetailUrl(ModelType.group, data.pk, true),
data?.image,
undefined
];
default:
return 'dd';
}
}
const line_data = lines(data);
return (
<HoverCard.Dropdown>
<Group>
<Avatar src={line_data[3]} radius='xl' />
<Stack gap={5}>
<Text size='sm' fw={700} style={{ lineHeight: 1 }}>
{line_data[0]}
</Text>
<Anchor
href={line_data[2]}
c='dimmed'
size='xs'
style={{ lineHeight: 1 }}
>
{line_data[1]}
</Anchor>
</Stack>
</Group>
<Text size='sm' mt='md'>
{line_data[4]}
</Text>
</HoverCard.Dropdown>
);
}
/**
* Fetches user or group info from backend and formats into a badge.
* Badge shows username, full name, or group name depending on server settings.
@ -141,6 +205,10 @@ function NameBadge({
});
const settings = useGlobalSettingsState();
const nameComp = useMemo(() => {
if (!data) return <Skeleton height={12} radius='md' />;
return HoverNameBadge(data, type);
}, [data]);
if (!data || data.isLoading || data.isFetching) {
return <Skeleton height={12} radius='md' />;
@ -170,7 +238,18 @@ function NameBadge({
variant='filled'
style={{ display: 'flex', alignItems: 'center' }}
>
{data?.name ?? _render_name()}
<HoverCard
width={320}
shadow='md'
withArrow
openDelay={200}
closeDelay={400}
>
<HoverCard.Target>
<p>{data?.name ?? _render_name()}</p>
</HoverCard.Target>
{nameComp}
</HoverCard>
</Badge>
<InvenTreeIcon icon={type === 'user' ? type : data.label} />
</Group>

View File

@ -39,7 +39,7 @@ import { StylishText } from '../items/StylishText';
* Props for detail image
*/
export type DetailImageProps = {
appRole: UserRoles;
appRole?: UserRoles;
src: string;
apiPath: string;
refresh?: () => void;
@ -437,7 +437,8 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
maw={IMAGE_DIMENSION}
onClick={expandImage}
/>
{permissions.hasChangeRole(props.appRole) &&
{props.appRole &&
permissions.hasChangeRole(props.appRole) &&
hasOverlay &&
hovered && (
<Overlay color='black' opacity={0.8} onClick={expandImage}>

View File

@ -106,6 +106,18 @@ function DrawerContent({ closeFunc }: Readonly<{ closeFunc?: () => void }>) {
link: '/sales/',
hidden: !user.hasViewRole(UserRoles.sales_order),
icon: 'sales_orders'
},
{
id: 'users',
title: t`Users`,
link: '/core/index/users',
icon: 'user'
},
{
id: 'groups',
title: t`Groups`,
link: '/core/index/groups',
icon: 'group'
}
];
}, [user]);

View File

@ -213,14 +213,14 @@ export const ModelInformationDict: ModelDict = {
user: {
label: () => t`User`,
label_multiple: () => t`Users`,
url_detail: '/user/:pk/',
url_detail: '/core/user/:pk/',
api_endpoint: ApiEndpoints.user_list,
icon: 'user'
},
group: {
label: () => t`Group`,
label_multiple: () => t`Groups`,
url_detail: '/user/group-:pk',
url_detail: '/core/group/:pk/',
api_endpoint: ApiEndpoints.group_list,
admin_url: '/auth/group/',
icon: 'group'

View File

@ -15,21 +15,14 @@ import { colorSchema } from './colorSchema';
export function ThemeContext({
children
}: Readonly<{ children: JSX.Element }>) {
const [primaryColor, whiteColor, blackColor, radius] = useLocalState(
(state) => [
state.primaryColor,
state.whiteColor,
state.blackColor,
state.radius
]
);
const [usertheme] = useLocalState((state) => [state.usertheme]);
// Theme
const myTheme = createTheme({
primaryColor: primaryColor,
white: whiteColor,
black: blackColor,
defaultRadius: radius,
primaryColor: usertheme.primaryColor,
white: usertheme.whiteColor,
black: usertheme.blackColor,
defaultRadius: usertheme.radius,
breakpoints: {
xs: '30em',
sm: '48em',

View File

@ -12,6 +12,7 @@ export enum ApiEndpoints {
// User API endpoints
user_list = 'user/',
user_me = 'user/me/',
user_profile = 'user/profile/',
user_roles = 'user/roles/',
user_token = 'user/token/',
user_tokens = 'user/tokens/',

View File

@ -123,6 +123,7 @@ export const doBasicLogin = async (
await fetchUserState();
// see if mfa registration is required
await fetchGlobalStates(navigate);
observeProfile();
} else if (!success) {
clearUserState();
}
@ -173,6 +174,46 @@ export const doSimpleLogin = async (email: string) => {
return mail;
};
function observeProfile() {
// overwrite language and theme info in session with profile info
const user = useUserState.getState().getUser();
const { language, setLanguage, usertheme, setTheme } =
useLocalState.getState();
if (user) {
if (user.profile?.language && language != user.profile.language) {
showNotification({
title: t`Language changed`,
message: t`Your active language has been changed to the one set in your profile`,
color: 'blue',
icon: 'language'
});
setLanguage(user.profile.language, true);
}
if (user.profile?.theme) {
// extract keys of usertheme and set them to the values of user.profile.theme
const newTheme = Object.keys(usertheme).map((key) => {
return {
key: key as keyof typeof usertheme,
value: user.profile.theme[key] as string
};
});
const diff = newTheme.filter(
(item) => usertheme[item.key] !== item.value
);
if (diff.length > 0) {
showNotification({
title: t`Theme changed`,
message: t`Your active theme has been changed to the one set in your profile`,
color: 'blue'
});
setTheme(newTheme);
}
}
}
}
export async function ensureCsrf() {
const cookie = getCsrfCookie();
if (cookie == undefined) {
@ -210,8 +251,9 @@ export function handleMfaLogin(
values: { code: string },
setError: (message: string | undefined) => void
) {
const { setToken } = useUserState.getState();
const { setToken, fetchUserState } = useUserState.getState();
const { setAuthContext } = useServerApiState.getState();
authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', {
code: values.code
})
@ -219,7 +261,11 @@ export function handleMfaLogin(
setError(undefined);
setAuthContext(response.data?.data);
setToken(response.data.meta.access_token);
followRedirect(navigate, location?.state);
fetchUserState().finally(() => {
observeProfile();
followRedirect(navigate, location?.state);
});
})
.catch((err) => {
if (err?.response?.status == 409) {
@ -268,18 +314,12 @@ export const checkLoginState = async (
message: t`Successfully logged in`
});
observeProfile();
fetchGlobalStates(navigate);
followRedirect(navigate, redirect);
};
// Callback function when login fails
const loginFailure = () => {
if (!no_redirect) {
navigate('/login', { state: redirect });
}
};
if (isLoggedIn()) {
// Already logged in
loginSuccess();
@ -292,8 +332,8 @@ export const checkLoginState = async (
if (isLoggedIn()) {
loginSuccess();
} else {
loginFailure();
} else if (!no_redirect) {
navigate('/login', { state: redirect });
}
};

View File

@ -1,9 +1,10 @@
import { Trans, t } from '@lingui/macro';
import { Group, Stack, Table, Title } from '@mantine/core';
import { IconKey, IconUser } from '@tabler/icons-react';
import { Badge, Group, Stack, Table, Title } from '@mantine/core';
import { IconEdit, IconKey, IconUser } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '../../../../components/buttons/ActionButton';
import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton';
import type { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField';
import { ActionDropdown } from '../../../../components/items/ActionDropdown';
@ -26,31 +27,89 @@ export function AccountDetailPanel() {
};
}, []);
const editUser = useEditApiFormModal({
title: t`Edit User Information`,
const editAccount = useEditApiFormModal({
title: t`Edit Account Information`,
url: ApiEndpoints.user_me,
onFormSuccess: fetchUserState,
fields: userFields,
successMessage: t`User details updated`
successMessage: t`Account details updated`
});
const profileFields: ApiFormFieldSet = useMemo(() => {
return {
displayname: {},
position: {},
status: {},
location: {},
active: {},
contact: {},
type: {},
organisation: {},
primary_group: {}
};
}, []);
const editProfile = useEditApiFormModal({
title: t`Edit Profile Information`,
url: ApiEndpoints.user_profile,
onFormSuccess: fetchUserState,
fields: profileFields,
successMessage: t`Profile details updated`
});
const accountDetailFields = useMemo(
() => [
{ label: t`Username`, value: user?.username },
{ label: t`First Name`, value: user?.first_name },
{ label: t`Last Name`, value: user?.last_name },
{
label: t`Staff Access`,
value: <YesNoUndefinedButton value={user?.is_staff} />
},
{
label: t`Superuser`,
value: <YesNoUndefinedButton value={user?.is_superuser} />
}
],
[user]
);
const profileDetailFields = useMemo(
() => [
{ label: t`Display Name`, value: user?.profile?.displayname },
{ label: t`Position`, value: user?.profile?.position },
{ label: t`Status`, value: user?.profile?.status },
{ label: t`Location`, value: user?.profile?.location },
{
label: t`Active`,
value: <YesNoUndefinedButton value={user?.profile?.active} />
},
{ label: t`Contact`, value: user?.profile?.contact },
{ label: t`Type`, value: <Badge>{user?.profile?.type}</Badge> },
{ label: t`Organisation`, value: user?.profile?.organisation },
{ label: t`Primary Group`, value: user?.profile?.primary_group }
],
[user]
);
return (
<>
{editUser.modal}
{editAccount.modal}
{editProfile.modal}
<Stack gap='xs'>
<Group justify='space-between'>
<Title order={3}>
<Trans>User Details</Trans>
<Trans>Account Details</Trans>
</Title>
<ActionDropdown
tooltip={t`User Actions`}
tooltip={t`Account Actions`}
icon={<IconUser />}
actions={[
{
name: t`Edit User`,
icon: <IconUser />,
tooltip: t`Edit User Information`,
onClick: editUser.open
name: t`Edit Account`,
icon: <IconEdit />,
tooltip: t`Edit Account Information`,
onClick: editAccount.open
},
{
name: t`Change Password`,
@ -63,46 +122,39 @@ export function AccountDetailPanel() {
]}
/>
</Group>
{renderDetailTable(accountDetailFields)}
<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>
<Group justify='space-between'>
<Title order={3}>
<Trans>Profile Details</Trans>
</Title>
<ActionButton
text={t`Edit Profile`}
icon={<IconEdit />}
tooltip={t`Edit Profile Information`}
onClick={editProfile.open}
variant='light'
/>
</Group>
{renderDetailTable(profileDetailFields)}
</Stack>
</>
);
function renderDetailTable(data: { label: string; value: any }[]) {
return (
<Table>
<Table.Tbody>
{data.map((item) => (
<Table.Tr key={item.label}>
<Table.Td>
<Trans>{item.label}</Trans>
</Table.Td>
<Table.Td>{item.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
}

View File

@ -33,66 +33,26 @@ const LOOKUP = Object.assign(
export function UserTheme({ height }: Readonly<{ height: number }>) {
const theme = useMantineTheme();
const [themeLoader, setThemeLoader] = useLocalState((state) => [
state.loader,
state.setLoader
const [usertheme, setTheme, setLanguage] = useLocalState((state) => [
state.usertheme,
state.setTheme,
state.setLanguage
]);
// 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);
}
// radius
function getMark(value: number) {
const obj = SizeMarks.find((mark) => mark.value === value);
if (obj) return obj;
return SizeMarks[0];
}
function getDefaultRadius() {
const obj = SizeMarks.find(
(mark) => mark.label === useLocalState.getState().radius
);
if (obj) return obj.value;
return 50;
const value = Number.parseInt(usertheme.radius.toString());
return SizeMarks.some((mark) => mark.value === value) ? value : 50;
}
const [radius, setRadius] = useState(getDefaultRadius());
function changeRadius(value: number) {
setRadius(value);
useLocalState.setState({ radius: getMark(value).label });
}
// 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` }
];
function changeLoader(value: string | null) {
if (value === null) return;
setThemeLoader(value);
setTheme([{ key: 'radius', value: value.toString() }]);
}
return (
@ -111,7 +71,10 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
</Table.Td>
<Table.Td>
{IS_DEV && (
<Button onClick={enablePseudoLang} variant='light'>
<Button
onClick={() => setLanguage('pseudo-LOCALE', true)}
variant='light'
>
<Trans>Use pseudo language</Trans>
</Button>
)}
@ -135,7 +98,9 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Table.Td>
<ColorPicker
format='hex'
onChange={changePrimary}
onChange={(v) =>
setTheme([{ key: 'primaryColor', value: LOOKUP[v] }])
}
withPicker={false}
swatches={Object.keys(LOOKUP)}
/>
@ -151,12 +116,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Trans>White color</Trans>
</Table.Td>
<Table.Td>
<ColorInput value={whiteColor} onChange={changeWhite} />
<ColorInput
aria-label='Color Picker White'
value={usertheme.whiteColor}
onChange={(v) => setTheme([{ key: 'whiteColor', value: v }])}
/>
</Table.Td>
<Table.Td>
<ActionIcon
variant='default'
onClick={() => changeWhite('#FFFFFF')}
aria-label='Reset White Color'
onClick={() =>
setTheme([{ key: 'whiteColor', value: '#FFFFFF' }])
}
>
<IconRestore />
</ActionIcon>
@ -167,12 +139,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Trans>Black color</Trans>
</Table.Td>
<Table.Td>
<ColorInput value={blackColor} onChange={changeBlack} />
<ColorInput
aria-label='Color Picker Black'
value={usertheme.blackColor}
onChange={(v) => setTheme([{ key: 'blackColor', value: v }])}
/>
</Table.Td>
<Table.Td>
<ActionIcon
variant='default'
onClick={() => changeBlack('#000000')}
aria-label='Reset Black Color'
onClick={() =>
setTheme([{ key: 'blackColor', value: '#000000' }])
}
>
<IconRestore />
</ActionIcon>
@ -201,15 +180,22 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Table.Td>
<Group justify='left'>
<Select
data={loaderDate}
value={themeLoader}
onChange={changeLoader}
aria-label='Loader Type Selector'
data={[
{ value: 'bars', label: t`Bars` },
{ value: 'oval', label: t`Oval` },
{ value: 'dots', label: t`Dots` }
]}
value={usertheme.loader}
onChange={(v) => {
if (v != null) setTheme([{ key: 'loader', value: v }]);
}}
/>
</Group>
</Table.Td>
<Table.Td>
<Group justify='left'>
<Loader type={themeLoader} mah={16} size='sm' />
<Loader type={usertheme.loader} mah={16} size='sm' />
</Group>
</Table.Td>
</Table.Tr>

View File

@ -48,6 +48,7 @@ export default function SystemSettings() {
'INVENTREE_INSTANCE_TITLE',
'INVENTREE_RESTRICT_ABOUT',
'DISPLAY_FULL_NAMES',
'DISPLAY_PROFILE_INFO',
'INVENTREE_UPDATE_CHECK_INTERVAL',
'INVENTREE_DOWNLOAD_FROM_URL',
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE',

View File

@ -0,0 +1,50 @@
import { t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import { IconUser, IconUsersGroup } from '@tabler/icons-react';
import { useMemo } from 'react';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { useUserState } from '../../states/UserState';
import { ContactTable } from '../../tables/company/ContactTable';
import { UserTable } from '../../tables/core/UserTable';
import { GroupTable } from '../../tables/settings/GroupTable';
export default function CoreIndex() {
const user = useUserState();
const panels = useMemo(() => {
return [
{
name: 'users',
label: t`Users`,
icon: <IconUser />,
content: <UserTable />
},
{
name: 'groups',
label: t`Groups`,
icon: <IconUsersGroup />,
content: <GroupTable directLink />
},
{
name: 'contacts',
label: t`Contacts`,
icon: <IconUsersGroup />,
content: <ContactTable />
}
];
}, []);
if (!user.isLoggedIn()) {
return <PermissionDenied />;
}
return (
<Stack>
<PageDetail title={t`System Overview`} />
<PanelGroup pageKey='core-index' panels={panels} id={null} />
</Stack>
);
}

View File

@ -0,0 +1,97 @@
import { t } from '@lingui/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import {} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
/**
* Detail page for a single group
*/
export default function GroupDetail() {
const { id } = useParams();
const { instance, instanceQuery, requestStatus } = useInstance({
endpoint: ApiEndpoints.group_list,
pk: id
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Group Name`,
copy: true
}
];
return (
<ItemDetailsGrid>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
</ItemDetailsGrid>
);
}, [instance, instanceQuery]);
const groupPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Group Details`,
icon: <IconInfoCircle />,
content: detailsPanel
}
];
}, [instance, id]);
const groupBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading ? [] : ['group info'];
}, [instance, instanceQuery]);
return (
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap='xs'>
<PageDetail
title={`${t`Group`}: ${instance.name}`}
imageUrl={instance?.image}
badges={groupBadges}
breadcrumbs={[
{ name: t`System Overview`, url: '/core/' },
{ name: t`Groups`, url: '/core/index/groups/' }
]}
lastCrumb={[
{ name: instance.name, url: `/core/group/${instance.pk}/` }
]}
/>
<PanelGroup
pageKey='group'
panels={groupPanels}
model={ModelType.group}
id={instance.pk}
instance={instance}
/>
</Stack>
</InstanceDetail>
);
}

View File

@ -0,0 +1,193 @@
import { t } from '@lingui/macro';
import { Badge, Grid, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import {} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
/**
* Detail page for a single user
*/
export default function UserDetail() {
const { id } = useParams();
const user = useUserState();
const settings = useGlobalSettingsState();
const { instance, instanceQuery, requestStatus } = useInstance({
endpoint: ApiEndpoints.user_list,
pk: id
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'email',
label: t`Email`,
copy: true
},
{
type: 'text',
name: 'username',
label: t`Username`,
icon: 'info',
copy: true
},
{
type: 'text',
name: 'first_name',
label: t`First Name`,
icon: 'info',
copy: true
},
{
type: 'text',
name: 'last_name',
label: t`Last Name`,
icon: 'info',
copy: true
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'displayname',
label: t`Display Name`,
icon: 'user',
copy: true
},
{
type: 'text',
name: 'position',
label: t`Position`,
icon: 'info'
},
{
type: 'boolean',
name: 'active',
label: t`Active`,
icon: 'info'
},
{
type: 'text',
name: 'contact',
label: t`Contact`,
icon: 'email',
copy: true
},
{
type: 'text',
name: 'organisation',
label: t`Organisation`,
icon: 'info',
copy: true
},
{
type: 'text',
name: 'status',
label: t`Status`,
icon: 'note'
},
{
type: 'text',
name: 'location',
label: t`Location`,
icon: 'location',
copy: true
}
];
return (
<ItemDetailsGrid>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
{settings.isSet('DISPLAY_PROFILE_INFO') && (
<DetailsTable fields={tr} item={instance} />
)}
</ItemDetailsGrid>
);
}, [instance, instanceQuery]);
const userPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`User Details`,
icon: <IconInfoCircle />,
content: detailsPanel
}
];
}, [instance, id, user]);
const userBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading
? []
: [
instance.is_staff && (
<Badge key='is_staff' color='blue'>{t`Staff`}</Badge>
),
instance.is_superuser && (
<Badge key='is_superuser' color='red'>{t`Superuser`}</Badge>
),
!instance.is_staff && !instance.is_superuser && (
<Badge key='is_normal' color='yellow'>{t`Basic user`}</Badge>
),
instance.is_active ? (
<Badge key='is_active' color='green'>{t`Active`}</Badge>
) : (
<Badge key='is_inactive' color='red'>{t`Inactive`}</Badge>
)
];
}, [instance, instanceQuery]);
return (
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap='xs'>
<PageDetail
title={`${t`User`}: ${instance.username}`}
imageUrl={instance?.image}
badges={userBadges}
breadcrumbs={[
{ name: t`System Overview`, url: '/core/' },
{ name: t`Users`, url: '/core/index/users/' }
]}
lastCrumb={[
{ name: instance.username, url: `/core/user/${instance.pk}/` }
]}
/>
<PanelGroup
pageKey='user'
panels={userPanels}
model={ModelType.user}
id={instance.pk}
instance={instance}
/>
</Stack>
</InstanceDetail>
);
}

View File

@ -104,6 +104,15 @@ export const AdminCenter = Loadable(
lazy(() => import('./pages/Index/Settings/AdminCenter/Index'))
);
// Core object
export const CoreIndex = Loadable(lazy(() => import('./pages/core/CoreIndex')));
export const UserDetail = Loadable(
lazy(() => import('./pages/core/UserDetail'))
);
export const GroupDetail = Loadable(
lazy(() => import('./pages/core/GroupDetail'))
);
export const NotFound = Loadable(
lazy(() => import('./components/errors/NotFound'))
);
@ -115,7 +124,6 @@ export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Register = Loadable(lazy(() => import('./pages/Auth/Register')));
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup')));
export const ChangePassword = Loadable(
lazy(() => import('./pages/Auth/ChangePassword'))
);
@ -178,6 +186,12 @@ export const routes = (
<Route path='return-order/:id/*' element={<ReturnOrderDetail />} />
<Route path='customer/:id/*' element={<CustomerDetail />} />
</Route>
<Route path='core/'>
<Route index element={<Navigate to='index/' />} />
<Route path='index/*' element={<CoreIndex />} />
<Route path='user/:id/*' element={<UserDetail />} />
<Route path='group/:id/*' element={<GroupDetail />} />
</Route>
</Route>
<Route
path='/'

View File

@ -2,9 +2,21 @@ import type { DataTableSortStatus } from 'mantine-datatable';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { api } from '../App';
import type { UiSizeType } from '../defaults/formatters';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from './ApiState';
import { useUserState } from './UserState';
import type { HostList } from './states';
interface Theme {
primaryColor: string;
whiteColor: string;
blackColor: string;
radius: UiSizeType;
loader: string;
}
interface LocalStateProps {
autoupdate: boolean;
toggleAutoupdate: () => void;
@ -14,14 +26,17 @@ interface LocalStateProps {
hostList: HostList;
setHostList: (newHostList: HostList) => void;
language: string;
setLanguage: (newLanguage: string) => void;
setLanguage: (newLanguage: string, noPatch?: boolean) => void;
// theme
primaryColor: string;
whiteColor: string;
blackColor: string;
radius: UiSizeType;
loader: string;
setLoader: (value: string) => void;
usertheme: Theme;
setTheme: (
newValues: {
key: keyof Theme;
value: string;
}[],
noPatch?: boolean
) => void;
// panels
lastUsedPanels: Record<string, string>;
setLastUsedPanel: (panelKey: string) => (value: string) => void;
tableColumnNames: Record<string, Record<string, string>>;
@ -56,15 +71,26 @@ export const useLocalState = create<LocalStateProps>()(
hostList: {},
setHostList: (newHostList) => set({ hostList: newHostList }),
language: 'en',
setLanguage: (newLanguage) => set({ language: newLanguage }),
setLanguage: (newLanguage, noPatch = false) => {
set({ language: newLanguage });
if (!noPatch) patchUser('language', newLanguage);
},
//theme
primaryColor: 'indigo',
whiteColor: '#fff',
blackColor: '#000',
radius: 'xs',
loader: 'oval',
setLoader(value) {
set({ loader: value });
usertheme: {
primaryColor: 'indigo',
whiteColor: '#fff',
blackColor: '#000',
radius: 'xs',
loader: 'oval'
},
setTheme: (newValues, noPatch = false) => {
const newTheme = { ...get().usertheme };
newValues.forEach((val) => {
newTheme[val.key] = val.value;
});
// console.log('setting theme, changed val',newValues.map(a => a.key).join(','), newTheme);
set({ usertheme: newTheme });
if (!noPatch) patchUser('theme', newTheme);
},
// panels
lastUsedPanels: {},
@ -129,3 +155,15 @@ export const useLocalState = create<LocalStateProps>()(
}
)
);
/*
pushes changes in user profile to backend
*/
function patchUser(key: 'language' | 'theme', val: any) {
const uid = useUserState.getState().userId();
if (uid) {
api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val });
} else {
console.log('user not logged in, not patching');
}
}

View File

@ -13,11 +13,12 @@ export interface UserStateProps {
token: string | undefined;
userId: () => number | undefined;
username: () => string;
setUser: (newUser: UserProps) => void;
setToken: (newToken: string) => void;
setUser: (newUser: UserProps | undefined) => void;
getUser: () => UserProps | undefined;
setToken: (newToken: string | undefined) => void;
clearToken: () => void;
fetchUserToken: () => void;
fetchUserState: () => void;
fetchUserState: () => Promise<void>;
clearUserState: () => void;
checkUserRole: (role: UserRoles, permission: UserPermissions) => boolean;
hasDeleteRole: (role: UserRoles) => boolean;
@ -43,17 +44,17 @@ export interface UserStateProps {
export const useUserState = create<UserStateProps>((set, get) => ({
user: undefined,
token: undefined,
setToken: (newToken: string) => {
setToken: (newToken: string | undefined) => {
set({ token: newToken });
setApiDefaults();
},
clearToken: () => {
set({ token: undefined });
get().setToken(undefined);
setApiDefaults();
},
userId: () => {
const user: UserProps = get().user as UserProps;
return user.pk;
return user?.pk;
},
username: () => {
const user: UserProps = get().user as UserProps;
@ -64,10 +65,11 @@ export const useUserState = create<UserStateProps>((set, get) => ({
return user?.username ?? '';
}
},
setUser: (newUser: UserProps) => set({ user: newUser }),
setUser: (newUser: UserProps | undefined) => set({ user: newUser }),
getUser: () => get().user,
clearUserState: () => {
set({ user: undefined });
set({ token: undefined });
get().setUser(undefined);
get().setToken(undefined);
clearCsrfCookie();
setApiDefaults();
},
@ -117,9 +119,12 @@ export const useUserState = create<UserStateProps>((set, get) => ({
first_name: response.data?.first_name ?? '',
last_name: response.data?.last_name ?? '',
email: response.data.email,
username: response.data.username
username: response.data.username,
groups: response.data.groups,
profile: response.data.profile
};
set({ user: user });
get().setUser(user);
// profile info
} else {
get().clearUserState();
}
@ -145,7 +150,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
user.permissions = response.data?.permissions ?? {};
user.is_staff = response.data?.is_staff ?? false;
user.is_superuser = response.data?.is_superuser ?? false;
set({ user: user });
get().setUser(user);
}
} else {
get().clearUserState();

View File

@ -26,6 +26,23 @@ export interface UserProps {
is_superuser?: boolean;
roles?: Record<string, string[]>;
permissions?: Record<string, string[]>;
groups: any[] | null;
profile: Profile;
}
interface Profile {
language: string;
theme: any;
widgets: any;
displayname: string | null;
position: string | null;
status: string | null;
location: string | null;
active: boolean;
contact: string | null;
type: string;
organisation: string | null;
primary_group: number | null;
}
// Type interface fully defining the current server

View File

@ -134,7 +134,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
setTableColumnNames,
getTableSorting,
setTableSorting,
loader
usertheme
} = useLocalState();
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
@ -711,7 +711,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
withColumnBorders
striped
highlightOnHover
loaderType={loader}
loaderType={usertheme.loader}
pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableState.idAccessor ?? 'pk'}
minHeight={tableProps.minHeight ?? 300}

View File

@ -1,10 +1,14 @@
import { t } from '@lingui/macro';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import type { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { RenderInlineModel } from '../../components/render/Instance';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { getDetailUrl } from '../../functions/urls';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@ -21,15 +25,16 @@ export function ContactTable({
companyId,
params
}: Readonly<{
companyId: number;
companyId?: number;
params?: any;
}>) {
const user = useUserState();
const navigate = useNavigate();
const table = useTable('contact');
const columns: TableColumn[] = useMemo(() => {
return [
const corecols: TableColumn[] = [
{
accessor: 'name',
sortable: true,
@ -51,6 +56,25 @@ export function ContactTable({
sortable: false
}
];
if (companyId === undefined) {
// Add company column if not in company detail view
corecols.unshift({
accessor: 'company_name',
title: t`Company`,
sortable: false,
switchable: true,
render: (record: any) => {
return (
<RenderInlineModel
primary={record.company_name}
url={getDetailUrl(ModelType.company, record.company)}
navigate={navigate}
/>
);
}
});
}
return corecols;
}, []);
const contactFields: ApiFormFieldSet = useMemo(() => {

View File

@ -0,0 +1,88 @@
import { t } from '@lingui/macro';
import { useMemo } from 'react';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import {} from '../../hooks/UseFilter';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { BooleanColumn } from '../ColumnRenderers';
import type { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
export function UserTable() {
const table = useTable('users-index');
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
{
name: 'is_active',
label: t`Active`,
description: t`Show active users`
},
{
name: 'is_staff',
label: t`Staff`,
description: t`Show staff users`
},
{
name: 'is_superuser',
label: t`Superuser`,
description: t`Show superusers`
}
];
return filters;
}, []);
const tableColumns = useMemo(() => {
return [
{
accessor: 'username',
sortable: true,
switchable: false
},
{
accessor: 'first_name',
sortable: true
},
{
accessor: 'last_name',
sortable: true
},
{
accessor: 'email',
sortable: true
},
{
accessor: 'groups',
title: t`Groups`,
sortable: true,
switchable: true,
render: (record: any) => {
return record.groups.length;
}
},
BooleanColumn({
accessor: 'is_staff'
}),
BooleanColumn({
accessor: 'is_superuser'
}),
BooleanColumn({
accessor: 'is_active'
})
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.user_list)}
tableState={table}
columns={tableColumns}
props={{
tableFilters: tableFilters,
modelType: ModelType.user
}}
/>
);
}

View File

@ -125,7 +125,9 @@ export function GroupDrawer({
/**
* Table for displaying list of groups
*/
export function GroupTable() {
export function GroupTable({
directLink = false
}: Readonly<{ directLink?: boolean }>) {
const table = useTable('groups');
const navigate = useNavigate();
const user = useUserState();
@ -223,9 +225,13 @@ export function GroupTable() {
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
rowActions: directLink ? undefined : rowActions,
tableActions: tableActions,
onRowClick: (record) => openDetailDrawer(record.pk)
onRowClick: directLink
? undefined
: (record) => openDetailDrawer(record.pk),
modelType: ModelType.group
}}
/>
</>

View File

@ -0,0 +1,26 @@
import { test } from '../baseFixtures.js';
import { loadTab, navigate } from '../helpers.js';
import { doQuickLogin } from '../login.js';
test('Core User/Group/Contact', async ({ page }) => {
await doQuickLogin(page);
// groups
await navigate(page, '/core');
await page.getByText('System Overview', { exact: true }).click();
await loadTab(page, 'Groups');
await page.getByRole('cell', { name: 'all access' }).click();
await page.getByText('Group: all access', { exact: true }).click();
await page.getByLabel('breadcrumb-1-groups').click();
// users
await loadTab(page, 'Users');
await page.getByRole('cell', { name: 'admin' }).click();
await page.getByText('User: admin', { exact: true }).waitFor();
await page.getByLabel('User Details').waitFor();
await page.getByLabel('breadcrumb-1-users').click();
// contacts
await loadTab(page, 'Contacts');
await page.getByRole('cell', { name: 'Adrian Briggs' }).waitFor();
});

View File

@ -83,8 +83,8 @@ test('Login - Change Password', async ({ page }) => {
// Navigate to the 'change password' page
await navigate(page, 'settings/user/account');
await page.getByLabel('action-menu-user-actions').click();
await page.getByLabel('action-menu-user-actions-change-password').click();
await page.getByLabel('action-menu-account-actions').click();
await page.getByLabel('action-menu-account-actions-change-password').click();
// First attempt with some errors
await page.getByLabel('password', { exact: true }).fill('youshallnotpass');

View File

@ -42,6 +42,51 @@ test('Settings - Language / Color', async ({ page }) => {
await page.waitForURL('**/platform/home');
});
test('Settings - User theme', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('button', { name: 'Ally Access' }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
// loader
await page.getByRole('textbox', { name: 'Loader Type Selector' }).click();
await page.getByRole('option', { name: 'Oval' }).click();
await page.getByRole('textbox', { name: 'Loader Type Selector' }).click();
await page.getByRole('option', { name: 'Bars' }).click();
// dark / light mode
await page
.getByRole('row', { name: 'Color Mode' })
.getByRole('button')
.click();
await page
.getByRole('row', { name: 'Color Mode' })
.getByRole('button')
.click();
// colors
await testColorPicker(page, 'Color Picker White');
await testColorPicker(page, 'Color Picker Black');
await page.waitForTimeout(500);
await page.getByLabel('Reset Black Color').click();
await page.getByLabel('Reset White Color').click();
// radius
await page
.locator('div')
.filter({ hasText: /^xssmmdlgxl$/ })
.nth(2)
.click();
// primary
await page.getByLabel('#fab005').click();
await page.getByLabel('#228be6').click();
// language
await page.getByRole('button', { name: 'Use pseudo language' }).click();
});
test('Settings - Admin', async ({ page }) => {
// Note here we login with admin access
await doQuickLogin(page, 'admin', 'inventree');
@ -227,3 +272,10 @@ test('Settings - Auth - Email', async ({ page }) => {
await page.waitForTimeout(2500);
});
async function testColorPicker(page, ref: string) {
const element = page.getByLabel(ref);
await element.click();
const box = (await element.boundingBox())!;
await page.mouse.click(box.x + box.width / 2, box.y + box.height + 25);
await page.getByText('Color Mode').click();
}