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

[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 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 v145 -> 2023-10-30: https://github.com/inventree/InvenTree/pull/5786
- Allow printing labels via POST including printing options in the body - 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 django.urls import include, path, reverse
from allauth.account.models import EmailAddress
from allauth.socialaccount import providers from allauth.socialaccount import providers
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers.keycloak.views import \ from allauth.socialaccount.providers.keycloak.views import \
KeycloakOAuth2Adapter KeycloakOAuth2Adapter
from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter, from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
OAuth2LoginView) OAuth2LoginView)
from rest_framework.generics import ListAPIView from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.permissions import AllowAny from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import InvenTreeModelSerializer
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -96,7 +100,7 @@ for provider in providers.registry.get_list():
social_auth_urlpatterns += provider_urlpatterns social_auth_urlpatterns += provider_urlpatterns
class SocialProviderListView(ListAPIView): class SocialProviderListView(ListAPI):
"""List of available social providers.""" """List of available social providers."""
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@ -109,9 +113,12 @@ class SocialProviderListView(ListAPIView):
'name': provider.name, 'name': provider.name,
'login': request.build_absolute_uri(reverse(f'{provider.id}_api_login')), 'login': request.build_absolute_uri(reverse(f'{provider.id}_api_login')),
'connect': request.build_absolute_uri(reverse(f'{provider.id}_api_connect')), 'connect': request.build_absolute_uri(reverse(f'{provider.id}_api_connect')),
'configured': False
} }
try: 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: except SocialApp.DoesNotExist:
provider_data['display_name'] = provider.name provider_data['display_name'] = provider.name
@ -124,3 +131,81 @@ class SocialProviderListView(ListAPIView):
'providers': provider_list 'providers': provider_list
} }
return Response(data) 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 .api import APISearchView, InfoView, NotFoundView, VersionView
from .magic_login import GetSimpleLoginView 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, from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
CustomEmailView, CustomLoginView, CustomEmailView, CustomLoginView,
CustomPasswordResetFromKeyView, CustomPasswordResetFromKeyView,
@ -85,6 +87,12 @@ apipatterns = [
re_path(r'^registration/account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(), name='account_confirm_email'), 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('registration/', include('dj_rest_auth.registration.urls')),
path('providers/', SocialProviderListView.as_view(), name='social_providers'), 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/', include(social_auth_urlpatterns)),
path('social/', SocialAccountListView.as_view(), name='social_account_list'), path('social/', SocialAccountListView.as_view(), name='social_account_list'),
path('social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'), path('social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),

View File

@ -5,7 +5,6 @@ import {
IconLogout, IconLogout,
IconPlugConnected, IconPlugConnected,
IconSettings, IconSettings,
IconUserCircle,
IconUserCog IconUserCog
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -13,7 +12,6 @@ import { Link } from 'react-router-dom';
import { doClassicLogout } from '../../functions/auth'; import { doClassicLogout } from '../../functions/auth';
import { InvenTreeStyle } from '../../globalStyle'; import { InvenTreeStyle } from '../../globalStyle';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { PlaceholderPill } from '../items/Placeholder';
export function MainMenu() { export function MainMenu() {
const { classes, theme } = InvenTreeStyle(); const { classes, theme } = InvenTreeStyle();
@ -36,21 +34,18 @@ export function MainMenu() {
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item icon={<IconUserCircle />}>
<Trans>Profile</Trans> <PlaceholderPill />
</Menu.Item>
<Menu.Label> <Menu.Label>
<Trans>Settings</Trans> <Trans>Settings</Trans>
</Menu.Label> </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"> <Menu.Item icon={<IconUserCog />} component={Link} to="/settings/user">
<Trans>Account settings</Trans> <Trans>Account settings</Trans>
</Menu.Item> </Menu.Item>
{userState.user?.is_staff && ( {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> <Trans>System Settings</Trans>
</Menu.Item> </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, Loader,
Select, Select,
Slider, Slider,
Space,
Table, Table,
Title Title
} from '@mantine/core'; } from '@mantine/core';
import { LoaderType } from '@mantine/styles/lib/theme/types/MantineTheme'; import { LoaderType } from '@mantine/styles/lib/theme/types/MantineTheme';
import { useState } from 'react'; import { useState } from 'react';
import { PlaceholderPill } from '../../../components/items/Placeholder'; import { SizeMarks } from '../../../../defaults/defaults';
import { SizeMarks } from '../../../defaults/defaults'; import { InvenTreeStyle } from '../../../../globalStyle';
import { InvenTreeStyle } from '../../../globalStyle'; import { useLocalState } from '../../../../states/LocalState';
import { useLocalState } from '../../../states/LocalState';
function getLkp(color: string) { function getLkp(color: string) {
return { [DEFAULT_THEME.colors[color][6]]: color }; return { [DEFAULT_THEME.colors[color][6]]: color };
@ -80,9 +78,7 @@ export function UserTheme({ height }: { height: number }) {
return ( return (
<Container w="100%" mih={height} p={0}> <Container w="100%" mih={height} p={0}>
<Title order={3}> <Title order={3}>
<Trans> <Trans>Theme</Trans>
Design <PlaceholderPill />
</Trans>
</Title> </Title>
<Table> <Table>
<tbody> <tbody>
@ -137,13 +133,12 @@ export function UserTheme({ height }: { height: number }) {
</td> </td>
<td> <td>
<Group align="center"> <Group align="center">
<Loader type={loader} mah={18} />
<Space w={10} />
<Select <Select
data={loaderDate} data={loaderDate}
value={loader} value={loader}
onChange={changeLoader} onChange={changeLoader}
/> />
<Loader type={loader} mah={18} />
</Group> </Group>
</td> </td>
</tr> </tr>

View File

@ -3,11 +3,11 @@ import { LoadingOverlay, Stack } from '@mantine/core';
import { IconPlugConnected } from '@tabler/icons-react'; import { IconPlugConnected } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { PluginListTable } from '../../components/tables/plugin/PluginListTable'; import { PluginListTable } from '../../../components/tables/plugin/PluginListTable';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../../hooks/UseInstance';
import { ApiPaths } from '../../states/ApiState'; import { ApiPaths } from '../../../states/ApiState';
/** /**
* Plugins settings page * 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 { Divider, Stack } from '@mantine/core';
import { import {
IconBellCog, IconBellCog,
@ -21,11 +21,12 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { GlobalSettingList } from '../../components/settings/SettingList'; import { GlobalSettingList } from '../../../components/settings/SettingList';
import { CustomUnitsTable } from '../../components/tables/settings/CustomUnitsTable'; import { CustomUnitsTable } from '../../../components/tables/settings/CustomUnitsTable';
import { ProjectCodeTable } from '../../components/tables/settings/ProjectCodeTable'; import { ProjectCodeTable } from '../../../components/tables/settings/ProjectCodeTable';
import { useServerApiState } from '../../../states/ApiState';
/** /**
* System settings page * System settings page
@ -249,11 +250,17 @@ export default function SystemSettings() {
} }
]; ];
}, []); }, []);
const [server] = useServerApiState((state) => [state.server]);
return ( return (
<> <>
<Stack spacing="xs"> <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} /> <PanelGroup pageKey="system-settings" panels={systemSettingsPanels} />
</Stack> </Stack>
</> </>

View File

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

View File

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

View File

@ -60,6 +60,12 @@ export enum ApiPaths {
user_simple_login = 'api-user-simple-login', user_simple_login = 'api-user-simple-login',
user_reset = 'api-user-reset', user_reset = 'api-user-reset',
user_reset_set = 'api-user-reset-set', 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_global_list = 'api-settings-global-list',
settings_user_list = 'api-settings-user-list', settings_user_list = 'api-settings-user-list',
@ -69,6 +75,7 @@ export enum ApiPaths {
news = 'news', news = 'news',
global_status = 'api-global-status', global_status = 'api-global-status',
version = 'api-version', version = 'api-version',
sso_providers = 'api-sso-providers',
// Build order URLs // Build order URLs
build_order_list = 'api-build-list', build_order_list = 'api-build-list',
@ -141,10 +148,22 @@ export function apiEndpoint(path: ApiPaths): string {
return 'email/generate/'; return 'email/generate/';
case ApiPaths.user_reset: case ApiPaths.user_reset:
// Note leading prefix here // Note leading prefix here
return '/auth/password/reset/'; return 'auth/password/reset/';
case ApiPaths.user_reset_set: case ApiPaths.user_reset_set:
// Note leading prefix here // 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: case ApiPaths.api_search:
return 'search/'; return 'search/';
case ApiPaths.settings_global_list: case ApiPaths.settings_global_list:
@ -161,6 +180,8 @@ export function apiEndpoint(path: ApiPaths): string {
return 'generic/status/'; return 'generic/status/';
case ApiPaths.version: case ApiPaths.version:
return 'version/'; return 'version/';
case ApiPaths.sso_providers:
return 'auth/providers/';
case ApiPaths.build_order_list: case ApiPaths.build_order_list:
return 'build/'; return 'build/';
case ApiPaths.build_order_attachment_list: case ApiPaths.build_order_attachment_list: