mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
7b9c618658
commit
92336f6b32
@ -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
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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'),
|
||||
|
@ -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>
|
||||
)}
|
||||
|
41
src/frontend/src/components/nav/SettingsHeader.tsx
Normal file
41
src/frontend/src/components/nav/SettingsHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
@ -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
|
@ -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>
|
||||
</>
|
@ -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>
|
@ -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 />} />,
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user