[PUI] Settings simplification and restructure (#5822)

* unify file structure for settings

* fixed router

* clean up menu

* refactored user settings
mergerd profile and user setttings

* removed profile page entirely

* cleaned up account panels

* use more detailed link

* refactored settings page header

* fixed user settings save

* simplified user data handling

* fixed UserState invalidation after form submition

* removed username from account change
this can currently not be done safely

* Added basic security section

* Added way to remove SSO account

* Only show providers that are not in use

* Changed API to contain configuration change

* removed unused var

* Added email section to PUI

* Switched rending to vertical

* Added things for adding a new email

* removed sessions as we are not using that in PUI

* made rendering logic easier to understand

* alligned colums horizontally

* allign action buttons for email
This commit is contained in:
Matthias Mair 2023-11-03 01:23:45 +01:00 committed by GitHub
parent 7b9c618658
commit 92336f6b32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 686 additions and 249 deletions

View File

@ -7,6 +7,10 @@ INVENTREE_API_VERSION = 145
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v146 -> 2023-11-02: https://github.com/inventree/InvenTree/pull/5822
- Extended SSO Provider endpoint to contain if a provider is configured
- Adds API endpoints for Email Address model
v145 -> 2023-10-30: https://github.com/inventree/InvenTree/pull/5786
- Allow printing labels via POST including printing options in the body

View File

@ -4,17 +4,21 @@ from importlib import import_module
from django.urls import include, path, reverse
from allauth.account.models import EmailAddress
from allauth.socialaccount import providers
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers.keycloak.views import \
KeycloakOAuth2Adapter
from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
OAuth2LoginView)
from rest_framework.generics import ListAPIView
from rest_framework.permissions import AllowAny
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from common.models import InvenTreeSetting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import InvenTreeModelSerializer
logger = logging.getLogger('inventree')
@ -96,7 +100,7 @@ for provider in providers.registry.get_list():
social_auth_urlpatterns += provider_urlpatterns
class SocialProviderListView(ListAPIView):
class SocialProviderListView(ListAPI):
"""List of available social providers."""
permission_classes = (AllowAny,)
@ -109,9 +113,12 @@ class SocialProviderListView(ListAPIView):
'name': provider.name,
'login': request.build_absolute_uri(reverse(f'{provider.id}_api_login')),
'connect': request.build_absolute_uri(reverse(f'{provider.id}_api_connect')),
'configured': False
}
try:
provider_data['display_name'] = provider.get_app(request).name
provider_app = provider.get_app(request)
provider_data['display_name'] = provider_app.name
provider_data['configured'] = True
except SocialApp.DoesNotExist:
provider_data['display_name'] = provider.name
@ -124,3 +131,81 @@ class SocialProviderListView(ListAPIView):
'providers': provider_list
}
return Response(data)
class EmailAddressSerializer(InvenTreeModelSerializer):
"""Serializer for the EmailAddress model."""
class Meta:
"""Meta options for EmailAddressSerializer."""
model = EmailAddress
fields = '__all__'
class EmptyEmailAddressSerializer(InvenTreeModelSerializer):
"""Empty Serializer for the EmailAddress model."""
class Meta:
"""Meta options for EmailAddressSerializer."""
model = EmailAddress
fields = []
class EmailListView(ListCreateAPI):
"""List of registered email addresses for current users."""
permission_classes = (IsAuthenticated,)
serializer_class = EmailAddressSerializer
def get_queryset(self):
"""Only return data for current user."""
return EmailAddress.objects.filter(user=self.request.user)
class EmailActionMixin(CreateAPI):
"""Mixin to modify email addresses for current users."""
serializer_class = EmptyEmailAddressSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
"""Filter queryset for current user."""
return EmailAddress.objects.filter(user=self.request.user, pk=self.kwargs['pk']).first()
@extend_schema(responses={200: OpenApiResponse(response=EmailAddressSerializer)})
def post(self, request, *args, **kwargs):
"""Filter item, run action and return data."""
email = self.get_queryset()
if not email:
raise NotFound
self.special_action(email, request, *args, **kwargs)
return Response(EmailAddressSerializer(email).data)
class EmailVerifyView(EmailActionMixin):
"""Re-verify an email for a currently logged in user."""
def special_action(self, email, request, *args, **kwargs):
"""Send confirmation."""
if email.verified:
return
email.send_confirmation(request)
class EmailPrimaryView(EmailActionMixin):
"""Make an email for a currently logged in user primary."""
def special_action(self, email, *args, **kwargs):
"""Mark email as primary."""
if email.primary:
return
email.set_as_primary()
class EmailRemoveView(EmailActionMixin):
"""Remove an email for a currently logged in user."""
def special_action(self, email, *args, **kwargs):
"""Delete email."""
email.delete()

View File

@ -38,7 +38,9 @@ from web.urls import urlpatterns as platform_urls
from .api import APISearchView, InfoView, NotFoundView, VersionView
from .magic_login import GetSimpleLoginView
from .social_auth_urls import SocialProviderListView, social_auth_urlpatterns
from .social_auth_urls import (EmailListView, EmailPrimaryView,
EmailRemoveView, EmailVerifyView,
SocialProviderListView, social_auth_urlpatterns)
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
CustomEmailView, CustomLoginView,
CustomPasswordResetFromKeyView,
@ -85,6 +87,12 @@ apipatterns = [
re_path(r'^registration/account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(), name='account_confirm_email'),
path('registration/', include('dj_rest_auth.registration.urls')),
path('providers/', SocialProviderListView.as_view(), name='social_providers'),
path('emails/', include([path('<int:pk>/', include([
path('primary/', EmailPrimaryView.as_view(), name='email-primary'),
path('verify/', EmailVerifyView.as_view(), name='email-verify'),
path('remove/', EmailRemoveView().as_view(), name='email-remove'),])),
path('', EmailListView.as_view(), name='email-list')
])),
path('social/', include(social_auth_urlpatterns)),
path('social/', SocialAccountListView.as_view(), name='social_account_list'),
path('social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),

View File

@ -5,7 +5,6 @@ import {
IconLogout,
IconPlugConnected,
IconSettings,
IconUserCircle,
IconUserCog
} from '@tabler/icons-react';
import { Link } from 'react-router-dom';
@ -13,7 +12,6 @@ import { Link } from 'react-router-dom';
import { doClassicLogout } from '../../functions/auth';
import { InvenTreeStyle } from '../../globalStyle';
import { useUserState } from '../../states/UserState';
import { PlaceholderPill } from '../items/Placeholder';
export function MainMenu() {
const { classes, theme } = InvenTreeStyle();
@ -36,21 +34,18 @@ export function MainMenu() {
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item icon={<IconUserCircle />}>
<Trans>Profile</Trans> <PlaceholderPill />
</Menu.Item>
<Menu.Label>
<Trans>Settings</Trans>
</Menu.Label>
<Menu.Item icon={<IconSettings />} component={Link} to="/profile/user">
<Trans>Account settings</Trans>
</Menu.Item>
<Menu.Item icon={<IconUserCog />} component={Link} to="/settings/user">
<Trans>Account settings</Trans>
</Menu.Item>
{userState.user?.is_staff && (
<Menu.Item icon={<IconSettings />} component={Link} to="/settings/">
<Menu.Item
icon={<IconSettings />}
component={Link}
to="/settings/system"
>
<Trans>System Settings</Trans>
</Menu.Item>
)}

View File

@ -0,0 +1,41 @@
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';
/**
* Construct a settings page header with interlinks to one other settings page
*/
export function SettingsHeader({
title,
shorthand,
subtitle,
switch_condition = true,
switch_text,
switch_link
}: {
title: string;
shorthand?: string;
subtitle: string | ReactNode;
switch_condition?: boolean;
switch_text: string | ReactNode;
switch_link: string;
}) {
return (
<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_condition && (
<Anchor component={Link} to={switch_link}>
<IconSwitch size={14} />
{switch_text}
</Anchor>
)}
</Group>
</Stack>
);
}

View File

@ -1,33 +0,0 @@
import { Trans } from '@lingui/macro';
import { Tabs } from '@mantine/core';
import { useNavigate, useParams } from 'react-router-dom';
import { StylishText } from '../../../components/items/StylishText';
import { UserPanel } from './UserPanel';
export default function Profile() {
const navigate = useNavigate();
const { tabValue } = useParams();
return (
<>
<StylishText>
<Trans>Profile</Trans>
</StylishText>
<Tabs
value={tabValue}
onTabChange={(value) => navigate(`/profile/${value}`)}
>
<Tabs.List>
<Tabs.Tab value="user">
<Trans>User</Trans>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="user">
<UserPanel />
</Tabs.Panel>
</Tabs>
</>
);
}

View File

@ -1,160 +0,0 @@
import { Trans } from '@lingui/macro';
import {
Button,
Container,
Grid,
Group,
SimpleGrid,
Skeleton,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useToggle } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { api, queryClient } from '../../../App';
import { ColorToggle } from '../../../components/items/ColorToggle';
import { EditButton } from '../../../components/items/EditButton';
import { LanguageSelect } from '../../../components/items/LanguageSelect';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { useLocalState } from '../../../states/LocalState';
import { UserTheme } from './UserTheme';
export function UserPanel() {
// view
const PRIMARY_COL_HEIGHT = 300;
const SECONDARY_COL_HEIGHT = PRIMARY_COL_HEIGHT / 2 - 8;
// data
function fetchData() {
// TODO: Replace this call with the global user state, perhaps?
return api.get(apiUrl(ApiPaths.user_me)).then((res) => res.data);
}
const { isLoading, data } = useQuery({
queryKey: ['user-me'],
queryFn: fetchData
});
return (
<div>
<SimpleGrid cols={2} spacing="md">
<Container w="100%">
{isLoading ? (
<Skeleton height={SECONDARY_COL_HEIGHT} />
) : (
<UserInfo data={data} />
)}
</Container>
<Grid gutter="md">
<Grid.Col>
<UserTheme height={SECONDARY_COL_HEIGHT} />
</Grid.Col>
<Grid.Col span={6}>
<DisplaySettings height={SECONDARY_COL_HEIGHT} />
</Grid.Col>
<Grid.Col span={6}>
<Skeleton height={SECONDARY_COL_HEIGHT} />
</Grid.Col>
</Grid>
</SimpleGrid>
</div>
);
}
export function UserInfo({ data }: { data: any }) {
if (!data) return <Skeleton />;
const form = useForm({ initialValues: data });
const [editing, setEditing] = useToggle([false, true] as const);
function SaveData(values: any) {
api.put(apiUrl(ApiPaths.user_me)).then((res) => {
if (res.status === 200) {
setEditing();
queryClient.invalidateQueries({ queryKey: ['user-me'] });
}
});
}
return (
<form onSubmit={form.onSubmit((values) => SaveData(values))}>
<Group>
<Title order={3}>
<Trans>Userinfo</Trans>
</Title>
<EditButton setEditing={setEditing} editing={editing} />
</Group>
<Group>
{editing ? (
<Stack spacing="xs">
<TextInput
label="First name"
placeholder="First name"
{...form.getInputProps('first_name')}
/>
<TextInput
label="Last name"
placeholder="Last name"
{...form.getInputProps('last_name')}
/>
<TextInput
label="Username"
placeholder="Username"
{...form.getInputProps('username')}
/>
<Group position="right" mt="md">
<Button type="submit">
<Trans>Submit</Trans>
</Button>
</Group>
</Stack>
) : (
<Stack spacing="xs">
<Text>
<Trans>First name: {form.values.first_name}</Trans>
</Text>
<Text>
<Trans>Last name: {form.values.last_name}</Trans>
</Text>
<Text>
<Trans>Username: {form.values.username}</Trans>
</Text>
</Stack>
)}
</Group>
</form>
);
}
function DisplaySettings({ 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>
<Group>
<Text>
<Trans>Color Mode</Trans>
</Text>
<ColorToggle />
</Group>
<Group align="top">
<Text>
<Trans>Language</Trans>
</Text>
<Stack>
<LanguageSelect width={200} />
<Button onClick={enablePseudoLang} variant="light">
<Trans>Use pseudo language</Trans>
</Button>
</Stack>
</Group>
</Container>
);
}

View File

@ -0,0 +1,67 @@
import { Trans } from '@lingui/macro';
import { Button, Group, Stack, Text, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useToggle } from '@mantine/hooks';
import { api } from '../../../../App';
import { EditButton } from '../../../../components/items/EditButton';
import { ApiPaths, apiUrl } from '../../../../states/ApiState';
import { useUserState } from '../../../../states/UserState';
export function AccountDetailPanel() {
const [user, fetchUserState] = useUserState((state) => [
state.user,
state.fetchUserState
]);
const form = useForm({ initialValues: user });
const [editing, setEditing] = useToggle([false, true] as const);
function SaveData(values: any) {
api.put(apiUrl(ApiPaths.user_me), values).then((res) => {
if (res.status === 200) {
setEditing();
fetchUserState();
}
});
}
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 spacing="xs">
<TextInput
label="First name"
placeholder="First name"
{...form.getInputProps('first_name')}
/>
<TextInput
label="Last name"
placeholder="Last name"
{...form.getInputProps('last_name')}
/>
<Group position="right" mt="md">
<Button type="submit">
<Trans>Submit</Trans>
</Button>
</Group>
</Stack>
) : (
<Stack spacing="0">
<Text>
<Trans>First name: {form.values.first_name}</Trans>
</Text>
<Text>
<Trans>Last name: {form.values.last_name}</Trans>
</Text>
</Stack>
)}
</Group>
</form>
);
}

View File

@ -0,0 +1,48 @@
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 { 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} />
<Button onClick={enablePseudoLang} variant="light">
<Trans>Use pseudo language</Trans>
</Button>
</Group>
</td>
</tr>
</tbody>
</Table>
</Container>
);
}

View File

@ -0,0 +1,319 @@
import { Trans, t } from '@lingui/macro';
import {
Alert,
Badge,
Button,
Grid,
Group,
Loader,
Radio,
Stack,
Text,
TextInput,
Title,
Tooltip
} from '@mantine/core';
import { IconAlertCircle, IconAt } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api, queryClient } from '../../../../App';
import { PlaceholderPill } from '../../../../components/items/Placeholder';
import { ApiPaths, apiUrl } from '../../../../states/ApiState';
export function SecurityContent() {
const [isSsoEnabled, setIsSsoEnabled] = useState<boolean>(false);
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(false);
const { isLoading: isLoadingProvider, data: dataProvider } = useQuery({
queryKey: ['sso-providers'],
queryFn: () =>
api.get(apiUrl(ApiPaths.sso_providers)).then((res) => res.data)
});
// evaluate if security options are enabled
useEffect(() => {
if (dataProvider === undefined) return;
// check if SSO is enabled on the server
setIsSsoEnabled(dataProvider.sso_enabled || false);
// check if MFa is enabled
setIsMfaEnabled(dataProvider.mfa_required || false);
}, [dataProvider]);
return (
<Stack>
<Title order={5}>
<Trans>Email</Trans>
</Title>
<EmailContent />
<Title order={5}>
<Trans>Single Sign On Accounts</Trans>
</Title>
{isSsoEnabled ? (
<SsoContent dataProvider={dataProvider} />
) : (
<Alert
icon={<IconAlertCircle size="1rem" />}
title={t`Not enabled`}
color="yellow"
>
<Trans>Single Sign On is not enabled for this server </Trans>
</Alert>
)}
<Title order={5}>
<Trans>Multifactor</Trans>
</Title>
{isLoadingProvider ? (
<Loader />
) : (
<>
{isMfaEnabled ? (
<MfaContent />
) : (
<Alert
icon={<IconAlertCircle size="1rem" />}
title={t`Not enabled`}
color="yellow"
>
<Trans>
Multifactor authentication is not configured for your account{' '}
</Trans>
</Alert>
)}
</>
)}
</Stack>
);
}
function EmailContent({}: {}) {
const [value, setValue] = useState<string>('');
const [newEmailValue, setNewEmailValue] = useState('');
const { isLoading, data, refetch } = useQuery({
queryKey: ['emails'],
queryFn: () => api.get(apiUrl(ApiPaths.user_emails)).then((res) => res.data)
});
function runServerAction(url: ApiPaths) {
api
.post(apiUrl(url).replace('$id', value), {})
.then(() => {
refetch();
})
.catch((res) => console.log(res.data));
}
function addEmail() {
api
.post(apiUrl(ApiPaths.user_emails), {
email: newEmailValue
})
.then(() => {
refetch();
})
.catch((res) => console.log(res.data));
}
if (isLoading) return <Loader />;
return (
<Grid>
<Grid.Col span={6}>
<Radio.Group
value={value}
onChange={setValue}
name="email_accounts"
label={t`The following email addresses are associated with your account:`}
>
<Stack mt="xs">
{data.map((link: any) => (
<Radio
key={link.id}
value={String(link.id)}
label={
<Group position="apart">
{link.email}
{link.primary && (
<Badge color="blue">
<Trans>Primary</Trans>
</Badge>
)}
{link.verified ? (
<Badge color="green">
<Trans>Verified</Trans>
</Badge>
) : (
<Badge color="yellow">
<Trans>Unverified</Trans>
</Badge>
)}
</Group>
}
/>
))}
</Stack>
</Radio.Group>
</Grid.Col>
<Grid.Col span={6}>
<Stack>
<Text>
<Trans>Add Email Address</Trans>
</Text>
<TextInput
label={t`E-Mail`}
placeholder={t`E-Mail address`}
icon={<IconAt />}
value={newEmailValue}
onChange={(event) => setNewEmailValue(event.currentTarget.value)}
/>
</Stack>
</Grid.Col>
<Grid.Col span={6}>
<Group>
<Button onClick={() => runServerAction(ApiPaths.user_email_primary)}>
<Trans>Make Primary</Trans>
</Button>
<Button onClick={() => runServerAction(ApiPaths.user_email_verify)}>
<Trans>Re-send Verification</Trans>
</Button>
<Button onClick={() => runServerAction(ApiPaths.user_email_remove)}>
<Trans>Remove</Trans>
</Button>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Button onClick={addEmail}>
<Trans>Add Email</Trans>
</Button>
</Grid.Col>
</Grid>
);
}
function SsoContent({ dataProvider }: { dataProvider: any | undefined }) {
const [value, setValue] = useState<string>('');
const [currentProviders, setcurrentProviders] = useState<[]>();
const { isLoading, data } = useQuery({
queryKey: ['sso-list'],
queryFn: () => api.get(apiUrl(ApiPaths.user_sso)).then((res) => res.data)
});
useEffect(() => {
if (dataProvider === undefined) return;
if (data === undefined) return;
const configuredProviders = data.map((item: any) => {
return item.provider;
});
function isAlreadyInUse(value: any) {
return !configuredProviders.includes(value.id);
}
// remove providers that are used currently
let newData = dataProvider.providers;
newData = newData.filter(isAlreadyInUse);
setcurrentProviders(newData);
}, [dataProvider, data]);
function removeProvider() {
api
.post(apiUrl(ApiPaths.user_sso_remove).replace('$id', value))
.then(() => {
queryClient.removeQueries({
queryKey: ['sso-list']
});
})
.catch((res) => console.log(res.data));
}
/* renderer */
if (isLoading) return <Loader />;
function ProviderButton({ provider }: { provider: any }) {
const button = (
<Button
key={provider.id}
component="a"
href={provider.connect}
variant="outline"
disabled={!provider.configured}
>
<Group position="apart">
{provider.display_name}
{provider.configured == false && <IconAlertCircle />}
</Group>
</Button>
);
if (provider.configured) return button;
return (
<Tooltip label={t`Provider has not been configured`}>{button}</Tooltip>
);
}
return (
<Grid>
<Grid.Col span={6}>
{data.length == 0 ? (
<Alert
icon={<IconAlertCircle size="1rem" />}
title={t`Not configured`}
color="yellow"
>
<Trans>
There are no social network accounts connected to this account.{' '}
</Trans>
</Alert>
) : (
<Stack>
<Radio.Group
value={value}
onChange={setValue}
name="sso_accounts"
label={t`You can sign in to your account using any of the following third party accounts`}
>
<Stack mt="xs">
{data.map((link: any) => (
<Radio
key={link.id}
value={String(link.id)}
label={link.provider}
/>
))}
</Stack>
</Radio.Group>
<Button onClick={removeProvider}>
<Trans>Remove</Trans>
</Button>
</Stack>
)}
</Grid.Col>
<Grid.Col span={6}>
<Stack>
<Text>Add SSO Account</Text>
<Text>
{currentProviders === undefined ? (
<Trans>Loading</Trans>
) : (
<Stack spacing="xs">
{currentProviders.map((provider: any) => (
<ProviderButton key={provider.id} provider={provider} />
))}
</Stack>
)}
</Text>
</Stack>
</Grid.Col>
</Grid>
);
}
function MfaContent({}: {}) {
return (
<>
MFA Details
<PlaceholderPill />
</>
);
}

View File

@ -0,0 +1,28 @@
import { Container, Grid, SimpleGrid } from '@mantine/core';
import { AccountDetailPanel } from './AccountDetailPanel';
import { DisplaySettingsPanel } from './DisplaySettingsPanel';
import { UserTheme } from './UserThemePanel';
export function AccountContent() {
const PRIMARY_COL_HEIGHT = 300;
const SECONDARY_COL_HEIGHT = PRIMARY_COL_HEIGHT / 2 - 8;
return (
<div>
<SimpleGrid cols={2} spacing="md">
<Container w="100%">
<AccountDetailPanel />
</Container>
<Grid gutter="md">
<Grid.Col>
<UserTheme height={SECONDARY_COL_HEIGHT} />
</Grid.Col>
<Grid.Col>
<DisplaySettingsPanel height={SECONDARY_COL_HEIGHT} />
</Grid.Col>
</Grid>
</SimpleGrid>
</div>
);
}

View File

@ -8,17 +8,15 @@ import {
Loader,
Select,
Slider,
Space,
Table,
Title
} from '@mantine/core';
import { LoaderType } from '@mantine/styles/lib/theme/types/MantineTheme';
import { useState } from 'react';
import { PlaceholderPill } from '../../../components/items/Placeholder';
import { SizeMarks } from '../../../defaults/defaults';
import { InvenTreeStyle } from '../../../globalStyle';
import { useLocalState } from '../../../states/LocalState';
import { SizeMarks } from '../../../../defaults/defaults';
import { InvenTreeStyle } from '../../../../globalStyle';
import { useLocalState } from '../../../../states/LocalState';
function getLkp(color: string) {
return { [DEFAULT_THEME.colors[color][6]]: color };
@ -80,9 +78,7 @@ export function UserTheme({ height }: { height: number }) {
return (
<Container w="100%" mih={height} p={0}>
<Title order={3}>
<Trans>
Design <PlaceholderPill />
</Trans>
<Trans>Theme</Trans>
</Title>
<Table>
<tbody>
@ -137,13 +133,12 @@ export function UserTheme({ height }: { height: number }) {
</td>
<td>
<Group align="center">
<Loader type={loader} mah={18} />
<Space w={10} />
<Select
data={loaderDate}
value={loader}
onChange={changeLoader}
/>
<Loader type={loader} mah={18} />
</Group>
</td>
</tr>

View File

@ -3,11 +3,11 @@ import { LoadingOverlay, Stack } from '@mantine/core';
import { IconPlugConnected } from '@tabler/icons-react';
import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PluginListTable } from '../../components/tables/plugin/PluginListTable';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths } from '../../states/ApiState';
import { PageDetail } from '../../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { PluginListTable } from '../../../components/tables/plugin/PluginListTable';
import { useInstance } from '../../../hooks/UseInstance';
import { ApiPaths } from '../../../states/ApiState';
/**
* Plugins settings page

View File

@ -1,4 +1,4 @@
import { t } from '@lingui/macro';
import { Trans, t } from '@lingui/macro';
import { Divider, Stack } from '@mantine/core';
import {
IconBellCog,
@ -21,11 +21,12 @@ import {
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { GlobalSettingList } from '../../components/settings/SettingList';
import { CustomUnitsTable } from '../../components/tables/settings/CustomUnitsTable';
import { ProjectCodeTable } from '../../components/tables/settings/ProjectCodeTable';
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { GlobalSettingList } from '../../../components/settings/SettingList';
import { CustomUnitsTable } from '../../../components/tables/settings/CustomUnitsTable';
import { ProjectCodeTable } from '../../../components/tables/settings/ProjectCodeTable';
import { useServerApiState } from '../../../states/ApiState';
/**
* System settings page
@ -249,11 +250,17 @@ export default function SystemSettings() {
}
];
}, []);
const [server] = useServerApiState((state) => [state.server]);
return (
<>
<Stack spacing="xs">
<PageDetail title={t`System Settings`} />
<SettingsHeader
title={server.instance || ''}
subtitle={<Trans>System Settings</Trans>}
switch_link="/settings/user"
switch_text={<Trans>Switch to User Setting</Trans>}
/>
<PanelGroup pageKey="system-settings" panels={systemSettingsPanels} />
</Stack>
</>

View File

@ -1,18 +1,22 @@
import { t } from '@lingui/macro';
import { Stack, Text } from '@mantine/core';
import { Trans, t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import {
IconBellCog,
IconDeviceDesktop,
IconDeviceDesktopAnalytics,
IconFileAnalytics,
IconLock,
IconSearch,
IconUserCircle
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { UserSettingList } from '../../components/settings/SettingList';
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { UserSettingList } from '../../../components/settings/SettingList';
import { useUserState } from '../../../states/UserState';
import { SecurityContent } from './AccountSettings/SecurityContent';
import { AccountContent } from './AccountSettings/UserPanel';
/**
* User settings page
@ -23,7 +27,14 @@ export default function UserSettings() {
{
name: 'account',
label: t`Account`,
icon: <IconUserCircle />
icon: <IconUserCircle />,
content: <AccountContent />
},
{
name: 'security',
label: t`Security`,
icon: <IconLock />,
content: <SecurityContent />
},
{
name: 'dashboard',
@ -95,13 +106,18 @@ export default function UserSettings() {
}
];
}, []);
const [user] = useUserState((state) => [state.user]);
return (
<>
<Stack spacing="xs">
<PageDetail
title={t`User Settings`}
detail={<Text>TODO: Filler</Text>}
<SettingsHeader
title={`${user?.first_name} ${user?.last_name}`}
shorthand={user?.username || ''}
subtitle={<Trans>Account Settings</Trans>}
switch_link="/settings/system"
switch_text={<Trans>Switch to System Setting</Trans>}
switch_condition={user?.is_staff || false}
/>
<PanelGroup pageKey="user-settings" panels={userSettingsPanels} />
</Stack>

View File

@ -81,20 +81,16 @@ export const Notifications = Loadable(
lazy(() => import('./pages/Notifications'))
);
export const Profile = Loadable(
lazy(() => import('./pages/Index/Profile/Profile'))
);
export const UserSettings = Loadable(
lazy(() => import('./pages/Index/UserSettings'))
lazy(() => import('./pages/Index/Settings/UserSettings'))
);
export const SystemSettings = Loadable(
lazy(() => import('./pages/Index/SystemSettings'))
lazy(() => import('./pages/Index/Settings/SystemSettings'))
);
export const PluginSettings = Loadable(
lazy(() => import('./pages/Index/PluginSettings'))
lazy(() => import('./pages/Index/Settings/PluginSettings'))
);
export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
@ -118,6 +114,7 @@ export const routes = (
<Route path="scan/" element={<Scan />} />,
<Route path="settings/">
<Route index element={<SystemSettings />} />
<Route path="system/" element={<SystemSettings />} />
<Route path="user/" element={<UserSettings />} />
<Route path="plugin/" element={<PluginSettings />} />
</Route>
@ -148,7 +145,6 @@ export const routes = (
<Route path="return-order/:id/" element={<ReturnOrderDetail />} />
<Route path="customer/:id/" element={<CustomerDetail />} />
</Route>
<Route path="/profile/:tabValue" element={<Profile />} />
</Route>
<Route path="/" errorElement={<ErrorPage />}>
<Route path="/login" element={<Login />} />,

View File

@ -60,6 +60,12 @@ export enum ApiPaths {
user_simple_login = 'api-user-simple-login',
user_reset = 'api-user-reset',
user_reset_set = 'api-user-reset-set',
user_sso = 'api-user-sso',
user_sso_remove = 'api-user-sso-remove',
user_emails = 'api-user-emails',
user_email_verify = 'api-user-email-verify',
user_email_primary = 'api-user-email-primary',
user_email_remove = 'api-user-email-remove',
settings_global_list = 'api-settings-global-list',
settings_user_list = 'api-settings-user-list',
@ -69,6 +75,7 @@ export enum ApiPaths {
news = 'news',
global_status = 'api-global-status',
version = 'api-version',
sso_providers = 'api-sso-providers',
// Build order URLs
build_order_list = 'api-build-list',
@ -141,10 +148,22 @@ export function apiEndpoint(path: ApiPaths): string {
return 'email/generate/';
case ApiPaths.user_reset:
// Note leading prefix here
return '/auth/password/reset/';
return 'auth/password/reset/';
case ApiPaths.user_reset_set:
// Note leading prefix here
return '/auth/password/reset/confirm/';
return 'auth/password/reset/confirm/';
case ApiPaths.user_sso:
return 'auth/social/';
case ApiPaths.user_sso_remove:
return 'auth/social/$id/disconnect/';
case ApiPaths.user_emails:
return 'auth/emails/';
case ApiPaths.user_email_remove:
return 'auth/emails/$id/remove/';
case ApiPaths.user_email_verify:
return 'auth/emails/$id/verify/';
case ApiPaths.user_email_primary:
return 'auth/emails/$id/primary/';
case ApiPaths.api_search:
return 'search/';
case ApiPaths.settings_global_list:
@ -161,6 +180,8 @@ export function apiEndpoint(path: ApiPaths): string {
return 'generic/status/';
case ApiPaths.version:
return 'version/';
case ApiPaths.sso_providers:
return 'auth/providers/';
case ApiPaths.build_order_list:
return 'build/';
case ApiPaths.build_order_attachment_list: