mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 20:15:44 +00:00
Remove django-allauth-2fa, dj-rest-auth and django-user-sessions (#6293)
* Remove django-allauth-2fa
Fixes #6281
* fix req
* fix file again
* remove allauth_2fa flows
* reintroduce otp
* fix rq
* remove old ref
* remove otp things from settings
* reintroduce otp codes
* remove totp section
* bump version
* fix reqs
* add missing model
* ignore TOTP migration if the model is not laoded
* add model deps
* add extra migrations step for easier testing
* add migration testing
* remove old catch
* cover static devies too
* remove more old stuff
* fix import
* mrege migrations
* bump API version
* switch to allauth.usersessions
* add headless
* re-add saml/openid
* user sessions cleanup
* turn off normal allauth urls if CUI is not active
* disable tests that rely on old endpoints - to be replaced
* always track session changes
* remove old allauth templates
* remove old ref
* add missing model
* fix session lookup
* always logout when pwd is changed
* reimplement session ending
* fix merge
* upgrade reqs
* lower cryptography version
* clean allauth_2fa reference
* disable test temporarly
* fix migration check
* disable tests temporarly
* Re-implement auth flow using new APIs; adds MFA to PUI
* re-implement logoff
* stop failure message from appearing when in MFA flow
* remove jwt mention
* fix: email endpoints (to be cleaned TODO@matmair)
* remove unused endpoints
* ignore the now often-used 410 error
* fix auth for email actions in MFA scenarios
* add mfa listing use build-in forms
* add dummy entry for missing frontend urls; see TODO@matmair
* remove unneeded change of confirm url
* add mfa reg endpoint (not fully implemented)
* implement more provider stuff
* simplify calls
* make calls more robust
* switch to browser based sessions
* add todo's
* update api version
* remove x-session, not needed anymore
* remove old urls
* remove ui preference - there is no decision anymore
* fix login redirect logic
* change name to ensure 1p can detect field
* add mfa table
* fix remove sso provider account action; provider (user) admin stuff is done
* reduce templates to the raw basics
* fix tests
* more exclusions
* rewrite url structure
* move buildin token test
* re-enable registration tests
* re-implement registrations
* enable registration for now
* re-implement password change
* adjust tests
* fix asserts
* align names with allauth
* simplify
* refactor and rephrasing
* fix nesting issue
* clean up urls even more
* add mfa add and remove screens
* add type
* revert dep change
* fix api version
* re-add settings
* simplify urls
* Add timeout to login wait for
* fix url assertation
* remove unneded mfa_enabled
* add setting for configuring types
* bump api version
* fix password reset flow
* change settings order
* save auth context
* rename var to remove confusion
* make login/register seperate paths
* make info text better
* adjust urls
* add error message
* disable buttons if no email is set
* add custom adapters for MFA and headless authentication to use upstreamed features
* move auth settings to status
* respect more settings
* update settings
* bump api version
* remove depreceated docs part
* remove dj_rest_auth stuff
* fix api_version bump
* remove temp fix
* fix provider login
* remove unsupported option
* remove hash requirement for now
* simplify customisation
* implement email-verification
* remove auth from api docs
* fix override of get_frontend_url
details in https://codeberg.org/allauth/django-allauth/pulls/4248
* bump api again
* fix req
* Revert "remove hash requirement for now"
This reverts commit 00bb6c5274
.
* remove usage of git repo
* fix doc string
* extend schema generation to just patch in allauth
* patch allauth OAI ref names
* reduce types
* refactor code structure
* fix ref patching a bit more
* add param cleanup
* ensure strings, number, bools are handled correctly in cleanup
* move fnc
* shorten names
* bump allauth
* re-add auth doc section
* fix doc structure
* revert playwrigth change
* ckean up browser only path
* clean up parameters that we do not use
* re-add 2fa required middleware
* fix mail sending hook
* fix password set texts
* Add forced mfa setup
* remove type
* adjust api_version
* Remove debug prints
* Add error message for TOTP creation
* Handle failed TOTP login
* fix reqs
* Add error on 409 during login
* fix tested url
* fix api_version
* fix allauth version
* minimize req diff
* further minimize diff
---------
Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
@ -15,6 +15,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { ProviderLogin } from '../../functions/auth';
|
||||
import type { Provider } from '../../states/states';
|
||||
|
||||
const brandIcons: { [key: string]: JSX.Element } = {
|
||||
@ -32,26 +33,17 @@ const brandIcons: { [key: string]: JSX.Element } = {
|
||||
};
|
||||
|
||||
export function SsoButton({ provider }: Readonly<{ provider: Provider }>) {
|
||||
function login() {
|
||||
window.location.href = provider.login;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
provider.login
|
||||
? t`You will be redirected to the provider for further actions.`
|
||||
: t`This provider is not full set up.`
|
||||
}
|
||||
label={t`You will be redirected to the provider for further actions.`}
|
||||
>
|
||||
<Button
|
||||
leftSection={getBrandIcon(provider)}
|
||||
radius='xl'
|
||||
component='a'
|
||||
onClick={login}
|
||||
disabled={!provider.login}
|
||||
onClick={() => ProviderLogin(provider)}
|
||||
>
|
||||
{provider.display_name}
|
||||
{provider.name}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
@ -21,6 +21,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import {
|
||||
doBasicLogin,
|
||||
doSimpleLogin,
|
||||
ensureCsrf,
|
||||
followRedirect
|
||||
} from '../../functions/auth';
|
||||
import { showLoginNotification } from '../../functions/notifications';
|
||||
@ -34,7 +35,12 @@ export function AuthenticationForm() {
|
||||
});
|
||||
const simpleForm = useForm({ initialValues: { email: '' } });
|
||||
const [classicLoginMode, setMode] = useDisclosure(true);
|
||||
const [auth_settings] = useServerApiState((state) => [state.auth_settings]);
|
||||
const [auth_config, sso_enabled, password_forgotten_enabled] =
|
||||
useServerApiState((state) => [
|
||||
state.auth_config,
|
||||
state.sso_enabled,
|
||||
state.password_forgotten_enabled
|
||||
]);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isLoggedIn } = useUserState();
|
||||
@ -45,8 +51,12 @@ export function AuthenticationForm() {
|
||||
setIsLoggingIn(true);
|
||||
|
||||
if (classicLoginMode === true) {
|
||||
doBasicLogin(classicForm.values.username, classicForm.values.password)
|
||||
.then(() => {
|
||||
doBasicLogin(
|
||||
classicForm.values.username,
|
||||
classicForm.values.password,
|
||||
navigate
|
||||
)
|
||||
.then((success) => {
|
||||
setIsLoggingIn(false);
|
||||
|
||||
if (isLoggedIn()) {
|
||||
@ -55,6 +65,8 @@ export function AuthenticationForm() {
|
||||
message: t`Logged in successfully`
|
||||
});
|
||||
followRedirect(navigate, location?.state);
|
||||
} else if (success) {
|
||||
// MFA login
|
||||
} else {
|
||||
showLoginNotification({
|
||||
title: t`Login failed`,
|
||||
@ -92,10 +104,10 @@ export function AuthenticationForm() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{auth_settings?.sso_enabled === true ? (
|
||||
{sso_enabled() ? (
|
||||
<>
|
||||
<Group grow mb='md' mt='md'>
|
||||
{auth_settings.providers.map((provider) => (
|
||||
{auth_config?.socialaccount.providers.map((provider) => (
|
||||
<SsoButton provider={provider} key={provider.id} />
|
||||
))}
|
||||
</Group>
|
||||
@ -124,7 +136,7 @@ export function AuthenticationForm() {
|
||||
placeholder={t`Your password`}
|
||||
{...classicForm.getInputProps('password')}
|
||||
/>
|
||||
{auth_settings?.password_forgotten_enabled === true && (
|
||||
{password_forgotten_enabled() === true && (
|
||||
<Group justify='space-between' mt='0'>
|
||||
<Anchor
|
||||
component='button'
|
||||
@ -185,20 +197,42 @@ export function AuthenticationForm() {
|
||||
|
||||
export function RegistrationForm() {
|
||||
const registrationForm = useForm({
|
||||
initialValues: { username: '', email: '', password1: '', password2: '' }
|
||||
initialValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password2: '' as string | undefined
|
||||
}
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const [auth_settings] = useServerApiState((state) => [state.auth_settings]);
|
||||
const [auth_config, registration_enabled, sso_registration] =
|
||||
useServerApiState((state) => [
|
||||
state.auth_config,
|
||||
state.registration_enabled,
|
||||
state.sso_registration_enabled
|
||||
]);
|
||||
const [isRegistering, setIsRegistering] = useState<boolean>(false);
|
||||
|
||||
function handleRegistration() {
|
||||
async function handleRegistration() {
|
||||
// check if passwords match
|
||||
if (
|
||||
registrationForm.values.password !== registrationForm.values.password2
|
||||
) {
|
||||
registrationForm.setFieldError('password2', t`Passwords do not match`);
|
||||
return;
|
||||
}
|
||||
setIsRegistering(true);
|
||||
|
||||
// remove password2 from the request
|
||||
const { password2, ...vals } = registrationForm.values;
|
||||
await ensureCsrf();
|
||||
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.user_register), registrationForm.values, {
|
||||
.post(apiUrl(ApiEndpoints.auth_signup), vals, {
|
||||
headers: { Authorization: '' }
|
||||
})
|
||||
.then((ret) => {
|
||||
if (ret?.status === 204 || ret?.status === 201) {
|
||||
if (ret?.status === 200) {
|
||||
setIsRegistering(false);
|
||||
showLoginNotification({
|
||||
title: t`Registration successful`,
|
||||
@ -210,27 +244,33 @@ export function RegistrationForm() {
|
||||
.catch((err) => {
|
||||
if (err.response?.status === 400) {
|
||||
setIsRegistering(false);
|
||||
for (const [key, value] of Object.entries(err.response.data)) {
|
||||
registrationForm.setFieldError(key, value as string);
|
||||
|
||||
// collect all errors per field
|
||||
const errors: { [key: string]: string[] } = {};
|
||||
for (const val of err.response.data.errors) {
|
||||
if (!errors[val.param]) {
|
||||
errors[val.param] = [];
|
||||
}
|
||||
errors[val.param].push(val.message);
|
||||
}
|
||||
let err_msg = '';
|
||||
if (err.response?.data?.non_field_errors) {
|
||||
err_msg = err.response.data.non_field_errors;
|
||||
|
||||
for (const key in errors) {
|
||||
registrationForm.setFieldError(key, errors[key]);
|
||||
}
|
||||
|
||||
showLoginNotification({
|
||||
title: t`Input error`,
|
||||
message: t`Check your input and try again. ` + err_msg,
|
||||
message: t`Check your input and try again. `,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const both_reg_enabled =
|
||||
auth_settings?.registration_enabled && auth_settings?.sso_registration;
|
||||
const both_reg_enabled = registration_enabled() && sso_registration();
|
||||
return (
|
||||
<>
|
||||
{auth_settings?.registration_enabled && (
|
||||
{registration_enabled() && (
|
||||
<form onSubmit={registrationForm.onSubmit(() => {})}>
|
||||
<Stack gap={0}>
|
||||
<TextInput
|
||||
@ -253,7 +293,7 @@ export function RegistrationForm() {
|
||||
label={t`Password`}
|
||||
aria-label='register-password'
|
||||
placeholder={t`Your password`}
|
||||
{...registrationForm.getInputProps('password1')}
|
||||
{...registrationForm.getInputProps('password')}
|
||||
/>
|
||||
<PasswordInput
|
||||
required
|
||||
@ -279,9 +319,9 @@ export function RegistrationForm() {
|
||||
{both_reg_enabled && (
|
||||
<Divider label={t`Or use SSO`} labelPosition='center' my='lg' />
|
||||
)}
|
||||
{auth_settings?.sso_registration === true && (
|
||||
{sso_registration() && (
|
||||
<Group grow mb='md' mt='md'>
|
||||
{auth_settings.providers.map((provider) => (
|
||||
{auth_config?.socialaccount.providers.map((provider) => (
|
||||
<SsoButton provider={provider} key={provider.id} />
|
||||
))}
|
||||
</Group>
|
||||
@ -292,18 +332,18 @@ export function RegistrationForm() {
|
||||
|
||||
export function ModeSelector({
|
||||
loginMode,
|
||||
setMode
|
||||
changePage
|
||||
}: Readonly<{
|
||||
loginMode: boolean;
|
||||
setMode: any;
|
||||
changePage: (state: string) => void;
|
||||
}>) {
|
||||
const [auth_settings] = useServerApiState((state) => [state.auth_settings]);
|
||||
const registration_enabled =
|
||||
auth_settings?.registration_enabled ||
|
||||
auth_settings?.sso_registration ||
|
||||
false;
|
||||
const [sso_registration, registration_enabled] = useServerApiState(
|
||||
(state) => [state.sso_registration_enabled, state.registration_enabled]
|
||||
);
|
||||
const both_reg_enabled =
|
||||
registration_enabled() || sso_registration() || false;
|
||||
|
||||
if (registration_enabled === false) return null;
|
||||
if (both_reg_enabled === false) return null;
|
||||
return (
|
||||
<Text ta='center' size={'xs'} mt={'md'}>
|
||||
{loginMode ? (
|
||||
@ -314,7 +354,7 @@ export function ModeSelector({
|
||||
type='button'
|
||||
c='dimmed'
|
||||
size='xs'
|
||||
onClick={() => setMode.close()}
|
||||
onClick={() => changePage('register')}
|
||||
>
|
||||
<Trans>Register</Trans>
|
||||
</Anchor>
|
||||
@ -325,7 +365,7 @@ export function ModeSelector({
|
||||
type='button'
|
||||
c='dimmed'
|
||||
size='xs'
|
||||
onClick={() => setMode.open()}
|
||||
onClick={() => changePage('login')}
|
||||
>
|
||||
<Trans>Go back to login</Trans>
|
||||
</Anchor>
|
||||
|
@ -116,20 +116,20 @@ function BasePanelGroup({
|
||||
|
||||
// Callback when the active panel changes
|
||||
const handlePanelChange = useCallback(
|
||||
(panel: string, event?: any) => {
|
||||
(targetPanel: string, event?: any) => {
|
||||
if (event && (event?.ctrlKey || event?.shiftKey)) {
|
||||
const url = `${location.pathname}/../${panel}`;
|
||||
const url = `${location.pathname}/../${targetPanel}`;
|
||||
cancelEvent(event);
|
||||
navigateToLink(url, navigate, event);
|
||||
} else {
|
||||
navigate(`../${panel}`);
|
||||
navigate(`../${targetPanel}`);
|
||||
}
|
||||
|
||||
localState.setLastUsedPanel(pageKey)(panel);
|
||||
localState.setLastUsedPanel(pageKey)(targetPanel);
|
||||
|
||||
// Optionally call external callback hook
|
||||
if (panel && onPanelChange) {
|
||||
onPanelChange(panel);
|
||||
if (targetPanel && onPanelChange) {
|
||||
onPanelChange(targetPanel);
|
||||
}
|
||||
},
|
||||
[activePanels, navigate, location, onPanelChange]
|
||||
|
@ -20,6 +20,7 @@ export const emptyServerAPI = {
|
||||
target: null,
|
||||
default_locale: null,
|
||||
django_admin: null,
|
||||
settings: null,
|
||||
customize: null
|
||||
};
|
||||
|
||||
|
@ -16,18 +16,25 @@ export enum ApiEndpoints {
|
||||
user_token = 'user/token/',
|
||||
user_tokens = 'user/tokens/',
|
||||
user_simple_login = 'email/generate/',
|
||||
user_reset = 'auth/password/reset/',
|
||||
user_reset_set = 'auth/password/reset/confirm/',
|
||||
user_change_password = 'auth/password/change/',
|
||||
user_sso = 'auth/social/',
|
||||
user_sso_remove = 'auth/social/:id/disconnect/',
|
||||
user_emails = 'auth/emails/',
|
||||
user_email_remove = 'auth/emails/:id/remove/',
|
||||
user_email_verify = 'auth/emails/:id/verify/',
|
||||
user_email_primary = 'auth/emails/:id/primary/',
|
||||
user_login = 'auth/login/',
|
||||
user_logout = 'auth/logout/',
|
||||
user_register = 'auth/registration/',
|
||||
|
||||
// User auth endpoints
|
||||
user_reset = 'auth/v1/auth/password/request',
|
||||
user_reset_set = 'auth/v1/auth/password/reset',
|
||||
auth_pwd_change = 'auth/v1/account/password/change',
|
||||
auth_login = 'auth/v1/auth/login',
|
||||
auth_login_2fa = 'auth/v1/auth/2fa/authenticate',
|
||||
auth_session = 'auth/v1/auth/session',
|
||||
auth_signup = 'auth/v1/auth/signup',
|
||||
auth_authenticators = 'auth/v1/account/authenticators',
|
||||
auth_recovery = 'auth/v1/account/authenticators/recovery-codes',
|
||||
auth_mfa_reauthenticate = 'auth/v1/auth/2fa/reauthenticate',
|
||||
auth_totp = 'auth/v1/account/authenticators/totp',
|
||||
auth_reauthenticate = 'auth/v1/auth/reauthenticate',
|
||||
auth_email = 'auth/v1/account/email',
|
||||
auth_email_verify = 'auth/v1/auth/email/verify',
|
||||
auth_providers = 'auth/v1/account/providers',
|
||||
auth_provider_redirect = 'auth/v1/auth/provider/redirect',
|
||||
auth_config = 'auth/v1/config',
|
||||
|
||||
// Generic API endpoints
|
||||
currency_list = 'currency/exchange/',
|
||||
@ -45,7 +52,6 @@ export enum ApiEndpoints {
|
||||
custom_state_list = 'generic/status/custom/',
|
||||
version = 'version/',
|
||||
license = 'license/',
|
||||
sso_providers = 'auth/providers/',
|
||||
group_list = 'user/group/',
|
||||
owner_list = 'user/owner/',
|
||||
content_type_list = 'contenttype/',
|
||||
|
@ -1,15 +1,16 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import axios from 'axios';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { Location, NavigateFunction } from 'react-router-dom';
|
||||
import { api, setApiDefaults } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { apiUrl, useServerApiState } from '../states/ApiState';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import { useUserState } from '../states/UserState';
|
||||
import { fetchGlobalStates } from '../states/states';
|
||||
import { type Provider, fetchGlobalStates } from '../states/states';
|
||||
import { showLoginNotification } from './notifications';
|
||||
import { generateUrl } from './urls';
|
||||
|
||||
export function followRedirect(navigate: NavigateFunction, redirect: any) {
|
||||
let url = redirect?.redirectUrl ?? '/home';
|
||||
@ -59,24 +60,29 @@ function post(path: string, params: any, method = 'post') {
|
||||
* If login is successful, an API token will be returned.
|
||||
* This API token is used for any future API requests.
|
||||
*/
|
||||
export const doBasicLogin = async (username: string, password: string) => {
|
||||
export const doBasicLogin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
navigate: NavigateFunction
|
||||
) => {
|
||||
const { host } = useLocalState.getState();
|
||||
const { clearUserState, setToken, fetchUserState } = useUserState.getState();
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
|
||||
if (username.length == 0 || password.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCsrfCookie();
|
||||
await ensureCsrf();
|
||||
|
||||
const login_url = apiUrl(ApiEndpoints.user_login);
|
||||
|
||||
let result = false;
|
||||
let loginDone = false;
|
||||
let success = false;
|
||||
|
||||
// Attempt login with
|
||||
await api
|
||||
.post(
|
||||
login_url,
|
||||
apiUrl(ApiEndpoints.auth_login),
|
||||
{
|
||||
username: username,
|
||||
password: password
|
||||
@ -86,33 +92,41 @@ export const doBasicLogin = async (username: string, password: string) => {
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
if (response.data.key) {
|
||||
setToken(response.data.key);
|
||||
result = true;
|
||||
}
|
||||
setAuthContext(response.data?.data);
|
||||
if (response.status == 200 && response.data?.meta?.is_authenticated) {
|
||||
setToken(response.data.meta.access_token);
|
||||
loginDone = true;
|
||||
success = true;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (
|
||||
err?.response?.status == 403 &&
|
||||
err?.response?.data?.detail == 'MFA required for this user'
|
||||
) {
|
||||
post(apiUrl(ApiEndpoints.user_login), {
|
||||
username: username,
|
||||
password: password,
|
||||
csrfmiddlewaretoken: getCsrfCookie(),
|
||||
mfa: true
|
||||
if (err?.response?.status == 401) {
|
||||
setAuthContext(err.response.data?.data);
|
||||
const mfa_flow = err.response.data.data.flows.find(
|
||||
(flow: any) => flow.id == 'mfa_authenticate'
|
||||
);
|
||||
if (mfa_flow && mfa_flow.is_pending == true) {
|
||||
success = true;
|
||||
navigate('/mfa');
|
||||
}
|
||||
} else if (err?.response?.status == 409) {
|
||||
notifications.show({
|
||||
title: t`Already logged in`,
|
||||
message: t`There is a conflicting session on the server for this browser. Please logout of that first.`,
|
||||
color: 'red',
|
||||
autoClose: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (loginDone) {
|
||||
await fetchUserState();
|
||||
fetchGlobalStates();
|
||||
} else {
|
||||
// see if mfa registration is required
|
||||
await fetchGlobalStates(navigate);
|
||||
} else if (!success) {
|
||||
clearUserState();
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -125,8 +139,9 @@ export const doLogout = async (navigate: NavigateFunction) => {
|
||||
|
||||
// Logout from the server session
|
||||
if (isLoggedIn() || !!getCsrfCookie()) {
|
||||
await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => {});
|
||||
|
||||
await authApi(apiUrl(ApiEndpoints.auth_session), undefined, 'delete').catch(
|
||||
() => {}
|
||||
);
|
||||
showLoginNotification({
|
||||
title: t`Logged Out`,
|
||||
message: t`Successfully logged out`
|
||||
@ -158,26 +173,70 @@ export const doSimpleLogin = async (email: string) => {
|
||||
return mail;
|
||||
};
|
||||
|
||||
export function handleReset(navigate: any, values: { email: string }) {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.user_reset), values, {
|
||||
headers: { Authorization: '' }
|
||||
export async function ensureCsrf() {
|
||||
const cookie = getCsrfCookie();
|
||||
if (cookie == undefined) {
|
||||
await api.get(apiUrl(ApiEndpoints.user_token)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleReset(
|
||||
navigate: NavigateFunction,
|
||||
values: { email: string }
|
||||
) {
|
||||
ensureCsrf();
|
||||
api.post(apiUrl(ApiEndpoints.user_reset), values).then((val) => {
|
||||
if (val.status === 200) {
|
||||
notifications.show({
|
||||
title: t`Mail delivery successful`,
|
||||
message: t`Check your inbox for a reset link. This only works if you have an account. Check in spam too.`,
|
||||
color: 'green',
|
||||
autoClose: false
|
||||
});
|
||||
navigate('/login');
|
||||
} else {
|
||||
notifications.show({
|
||||
title: t`Reset failed`,
|
||||
message: t`Check your input and try again.`,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function handleMfaLogin(
|
||||
navigate: NavigateFunction,
|
||||
location: Location<any>,
|
||||
values: { code: string },
|
||||
setError: (message: string | undefined) => void
|
||||
) {
|
||||
const { setToken } = useUserState.getState();
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', {
|
||||
code: values.code
|
||||
})
|
||||
.then((response) => {
|
||||
setError(undefined);
|
||||
setAuthContext(response.data?.data);
|
||||
setToken(response.data.meta.access_token);
|
||||
followRedirect(navigate, location?.state);
|
||||
})
|
||||
.then((val) => {
|
||||
if (val.status === 200) {
|
||||
.catch((err) => {
|
||||
if (err?.response?.status == 409) {
|
||||
notifications.show({
|
||||
title: t`Mail delivery successful`,
|
||||
message: t`Check your inbox for a reset link. This only works if you have an account. Check in spam too.`,
|
||||
color: 'green',
|
||||
title: t`Already logged in`,
|
||||
message: t`There is a conflicting session on the server for this browser. Please logout of that first.`,
|
||||
color: 'red',
|
||||
autoClose: false
|
||||
});
|
||||
navigate('/login');
|
||||
} else {
|
||||
notifications.show({
|
||||
title: t`Reset failed`,
|
||||
message: t`Check your input and try again.`,
|
||||
color: 'red'
|
||||
});
|
||||
const errors = err.response?.data?.errors;
|
||||
let msg = t`An error occurred`;
|
||||
|
||||
if (errors) {
|
||||
msg = errors.map((e: any) => e.message).join(', ');
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -209,7 +268,7 @@ export const checkLoginState = async (
|
||||
message: t`Successfully logged in`
|
||||
});
|
||||
|
||||
fetchGlobalStates();
|
||||
fetchGlobalStates(navigate);
|
||||
|
||||
followRedirect(navigate, redirect);
|
||||
};
|
||||
@ -257,3 +316,45 @@ export function clearCsrfCookie() {
|
||||
document.cookie =
|
||||
'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
}
|
||||
|
||||
export async function ProviderLogin(
|
||||
provider: Provider,
|
||||
process: 'login' | 'connect' = 'login'
|
||||
) {
|
||||
await ensureCsrf();
|
||||
post(generateUrl(apiUrl(ApiEndpoints.auth_provider_redirect)), {
|
||||
provider: provider.id,
|
||||
callback_url: generateUrl('/logged-in'),
|
||||
process: process,
|
||||
csrfmiddlewaretoken: getCsrfCookie()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an API request with session tokens using the provided URL, configuration, method, and data.
|
||||
*
|
||||
* @param url - The URL to which the request is sent.
|
||||
* @param config - Optional Axios request configuration.
|
||||
* @param method - The HTTP method to use for the request. Defaults to 'get'.
|
||||
* @param data - Optional data to be sent with the request.
|
||||
* @returns A promise that resolves to the response of the API request.
|
||||
*/
|
||||
export function authApi(
|
||||
url: string,
|
||||
config: AxiosRequestConfig | undefined = undefined,
|
||||
method: 'get' | 'post' | 'put' | 'delete' = 'get',
|
||||
data?: any
|
||||
) {
|
||||
const requestConfig = config || {};
|
||||
|
||||
// set method
|
||||
requestConfig.method = method;
|
||||
|
||||
// set data
|
||||
if (data) {
|
||||
requestConfig.data = data;
|
||||
}
|
||||
|
||||
// use normal api
|
||||
return api(url, requestConfig);
|
||||
}
|
||||
|
@ -19,12 +19,14 @@ import { StylishText } from '../../components/items/StylishText';
|
||||
import { ProtectedRoute } from '../../components/nav/Layout';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { clearCsrfCookie } from '../../functions/auth';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
|
||||
export default function Set_Password() {
|
||||
const simpleForm = useForm({
|
||||
initialValues: {
|
||||
current_password: '',
|
||||
new_password1: '',
|
||||
new_password2: ''
|
||||
}
|
||||
@ -35,8 +37,10 @@ export default function Set_Password() {
|
||||
|
||||
function passwordError(values: any) {
|
||||
let message: any =
|
||||
values?.new_password ||
|
||||
values?.new_password2 ||
|
||||
values?.new_password1 ||
|
||||
values?.current_password ||
|
||||
values?.error ||
|
||||
t`Password could not be changed`;
|
||||
|
||||
@ -55,27 +59,45 @@ export default function Set_Password() {
|
||||
}
|
||||
|
||||
function handleSet() {
|
||||
const { clearUserState } = useUserState.getState();
|
||||
|
||||
// check if passwords match
|
||||
if (simpleForm.values.new_password1 !== simpleForm.values.new_password2) {
|
||||
passwordError({ new_password2: t`The two password fields didn’t match` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set password with call to backend
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.user_change_password), {
|
||||
new_password1: simpleForm.values.new_password1,
|
||||
new_password2: simpleForm.values.new_password2
|
||||
.post(apiUrl(ApiEndpoints.auth_pwd_change), {
|
||||
current_password: simpleForm.values.current_password,
|
||||
new_password: simpleForm.values.new_password2
|
||||
})
|
||||
.then((val) => {
|
||||
if (val.status === 200) {
|
||||
passwordError(val.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.status === 401) {
|
||||
notifications.show({
|
||||
title: t`Password Changed`,
|
||||
message: t`The password was set successfully. You can now login with your new password`,
|
||||
color: 'green',
|
||||
autoClose: false
|
||||
});
|
||||
clearUserState();
|
||||
clearCsrfCookie();
|
||||
navigate('/login');
|
||||
} else {
|
||||
passwordError(val.data);
|
||||
// compile errors
|
||||
const errors: { [key: string]: string[] } = {};
|
||||
for (const val of err.response.data.errors) {
|
||||
if (!errors[val.param]) {
|
||||
errors[val.param] = [];
|
||||
}
|
||||
errors[val.param].push(val.message);
|
||||
}
|
||||
passwordError(errors);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
passwordError(err.response.data);
|
||||
});
|
||||
}
|
||||
|
||||
@ -97,6 +119,13 @@ export default function Set_Password() {
|
||||
)}
|
||||
<Divider />
|
||||
<Stack gap='xs'>
|
||||
<PasswordInput
|
||||
required
|
||||
aria-label='password'
|
||||
label={t`Current Password`}
|
||||
description={t`Enter your current password`}
|
||||
{...simpleForm.getInputProps('current_password')}
|
||||
/>
|
||||
<PasswordInput
|
||||
required
|
||||
aria-label='input-password-1'
|
||||
|
@ -47,6 +47,14 @@ export default function Login() {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/register') {
|
||||
setMode.close();
|
||||
} else {
|
||||
setMode.open();
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const LoginMessage = useMemo(() => {
|
||||
const val = server.customize?.login_message;
|
||||
if (val) {
|
||||
@ -95,7 +103,8 @@ export default function Login() {
|
||||
if (searchParams.has('login') && searchParams.has('password')) {
|
||||
doBasicLogin(
|
||||
searchParams.get('login') ?? '',
|
||||
searchParams.get('password') ?? ''
|
||||
searchParams.get('password') ?? '',
|
||||
navigate
|
||||
).then(() => {
|
||||
followRedirect(navigate, location?.state);
|
||||
});
|
||||
@ -135,7 +144,10 @@ export default function Login() {
|
||||
</StylishText>
|
||||
<Divider p='xs' />
|
||||
{loginMode ? <AuthenticationForm /> : <RegistrationForm />}
|
||||
<ModeSelector loginMode={loginMode} setMode={setMode} />
|
||||
<ModeSelector
|
||||
loginMode={loginMode}
|
||||
changePage={(newPage) => navigate(`/${newPage}`)}
|
||||
/>
|
||||
{LoginMessage}
|
||||
</Paper>
|
||||
<AuthFormOptions
|
||||
|
59
src/frontend/src/pages/Auth/MFALogin.tsx
Normal file
59
src/frontend/src/pages/Auth/MFALogin.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
import { handleMfaLogin } from '../../functions/auth';
|
||||
|
||||
export default function MFALogin() {
|
||||
const simpleForm = useForm({ initialValues: { code: '' } });
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [loginError, setLoginError] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<LanguageContext>
|
||||
<Center mih='100vh'>
|
||||
<Container w='md' miw={425}>
|
||||
<Stack>
|
||||
<Title>
|
||||
<Trans>MFA Login</Trans>
|
||||
</Title>
|
||||
<Stack>
|
||||
<TextInput
|
||||
required
|
||||
label={t`TOTP Code`}
|
||||
name='TOTP'
|
||||
description={t`Enter your TOTP or recovery code`}
|
||||
{...simpleForm.getInputProps('code')}
|
||||
error={loginError}
|
||||
/>
|
||||
</Stack>
|
||||
<Button
|
||||
type='submit'
|
||||
onClick={() =>
|
||||
handleMfaLogin(
|
||||
navigate,
|
||||
location,
|
||||
simpleForm.values,
|
||||
setLoginError
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Log in</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Center>
|
||||
</LanguageContext>
|
||||
);
|
||||
}
|
76
src/frontend/src/pages/Auth/MFASetup.tsx
Normal file
76
src/frontend/src/pages/Auth/MFASetup.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Button, Center, Container, Stack, Title } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { authApi, doLogout, followRedirect } from '../../functions/auth';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { QrRegistrationForm } from '../Index/Settings/AccountSettings/QrRegistrationForm';
|
||||
|
||||
export default function MFASetup() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [totpQr, setTotpQr] = useState<{ totp_url: string; secret: string }>();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const registerTotp = async () => {
|
||||
await authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'get').catch(
|
||||
(err) => {
|
||||
if (err.status == 404 && err.response.data.meta.secret) {
|
||||
setTotpQr(err.response.data.meta);
|
||||
} else {
|
||||
const msg = err.response.data.errors[0].message;
|
||||
showNotification({
|
||||
title: t`Failed to set up MFA`,
|
||||
message: msg,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!totpQr) {
|
||||
registerTotp();
|
||||
}
|
||||
}, [totpQr]);
|
||||
|
||||
return (
|
||||
<LanguageContext>
|
||||
<Center mih='100vh'>
|
||||
<Container w='md' miw={425}>
|
||||
<Stack>
|
||||
<Title>
|
||||
<Trans>MFA Setup Required</Trans>
|
||||
</Title>
|
||||
<QrRegistrationForm
|
||||
url={totpQr?.totp_url ?? ''}
|
||||
secret={totpQr?.secret ?? ''}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
<Button
|
||||
disabled={!value}
|
||||
onClick={() => {
|
||||
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
|
||||
code: value
|
||||
}).then(() => {
|
||||
followRedirect(navigate, location?.state);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans>Add TOTP</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => doLogout(navigate)} color='red'>
|
||||
<Trans>Log off</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Center>
|
||||
</LanguageContext>
|
||||
);
|
||||
}
|
@ -22,32 +22,41 @@ export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const token = searchParams.get('token');
|
||||
const uid = searchParams.get('uid');
|
||||
const key = searchParams.get('key');
|
||||
|
||||
function invalidToken() {
|
||||
function invalidKey() {
|
||||
notifications.show({
|
||||
title: t`Token invalid`,
|
||||
message: t`You need to provide a valid token to set a new password. Check your inbox for a reset link.`,
|
||||
title: t`Key invalid`,
|
||||
message: t`You need to provide a valid key to set a new password. Check your inbox for a reset link.`,
|
||||
color: 'red'
|
||||
});
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
function success() {
|
||||
notifications.show({
|
||||
title: t`Password set`,
|
||||
message: t`The password was set successfully. You can now login with your new password`,
|
||||
color: 'green',
|
||||
autoClose: false
|
||||
});
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
function passwordError(values: any) {
|
||||
notifications.show({
|
||||
title: t`Reset failed`,
|
||||
message: values?.new_password2 || values?.new_password1 || values?.token,
|
||||
message: values?.errors.map((e: any) => e.message).join('\n'),
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// make sure we have a token
|
||||
if (!token || !uid) {
|
||||
invalidToken();
|
||||
// make sure we have a key
|
||||
if (!key) {
|
||||
invalidKey();
|
||||
}
|
||||
}, [token]);
|
||||
}, [key]);
|
||||
|
||||
function handleSet() {
|
||||
// Set password with call to backend
|
||||
@ -55,32 +64,23 @@ export default function ResetPassword() {
|
||||
.post(
|
||||
apiUrl(ApiEndpoints.user_reset_set),
|
||||
{
|
||||
uid: uid,
|
||||
token: token,
|
||||
new_password1: simpleForm.values.password,
|
||||
new_password2: simpleForm.values.password
|
||||
key: key,
|
||||
password: simpleForm.values.password
|
||||
},
|
||||
{ headers: { Authorization: '' } }
|
||||
)
|
||||
.then((val) => {
|
||||
if (val.status === 200) {
|
||||
notifications.show({
|
||||
title: t`Password set`,
|
||||
message: t`The password was set successfully. You can now login with your new password`,
|
||||
color: 'green',
|
||||
autoClose: false
|
||||
});
|
||||
navigate('/login');
|
||||
success();
|
||||
} else {
|
||||
passwordError(val.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (
|
||||
err.response?.status === 400 &&
|
||||
err.response?.data?.token == 'Invalid value'
|
||||
) {
|
||||
invalidToken();
|
||||
if (err.response?.status === 400) {
|
||||
passwordError(err.response.data);
|
||||
} else if (err.response?.status === 401) {
|
||||
success();
|
||||
} else {
|
||||
passwordError(err.response.data);
|
||||
}
|
||||
@ -99,12 +99,12 @@ export default function ResetPassword() {
|
||||
<PasswordInput
|
||||
required
|
||||
label={t`Password`}
|
||||
description={t`We will send you a link to login - if you are registered`}
|
||||
description={t`The desired new password`}
|
||||
{...simpleForm.getInputProps('password')}
|
||||
/>
|
||||
</Stack>
|
||||
<Button type='submit' onClick={handleSet}>
|
||||
<Trans>Send Email</Trans>
|
||||
<Trans>Send Password</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
61
src/frontend/src/pages/Auth/VerifyEmail.tsx
Normal file
61
src/frontend/src/pages/Auth/VerifyEmail.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Button, Center, Container, Stack, Title } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const { key } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function invalidKey() {
|
||||
notifications.show({
|
||||
title: t`Key invalid`,
|
||||
message: t`You need to provide a valid key.`,
|
||||
color: 'red'
|
||||
});
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// make sure we have a key
|
||||
if (!key) {
|
||||
invalidKey();
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
function handleSet() {
|
||||
// Set password with call to backend
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.auth_email_verify), {
|
||||
key: key
|
||||
})
|
||||
.then((val) => {
|
||||
if (val.status === 200) {
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<LanguageContext>
|
||||
<Center mih='100vh'>
|
||||
<Container w='md' miw={425}>
|
||||
<Stack>
|
||||
<Title>
|
||||
<Trans>Verify Email</Trans>
|
||||
</Title>
|
||||
<Button type='submit' onClick={handleSet}>
|
||||
<Trans>Verify</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Center>
|
||||
</LanguageContext>
|
||||
);
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Divider, Text, TextInput } from '@mantine/core';
|
||||
import { QRCode } from '../../../../components/barcodes/QRCode';
|
||||
|
||||
export function QrRegistrationForm({
|
||||
url,
|
||||
secret,
|
||||
value,
|
||||
error,
|
||||
setValue
|
||||
}: Readonly<{
|
||||
url: string;
|
||||
secret: string;
|
||||
value: string;
|
||||
error?: string;
|
||||
setValue: (value: string) => void;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<QRCode data={url} />
|
||||
<Text>
|
||||
<Trans>Secret</Trans>
|
||||
<br />
|
||||
{secret}
|
||||
</Text>
|
||||
<TextInput
|
||||
required
|
||||
label={t`One-Time Password`}
|
||||
description={t`Enter the TOTP code to ensure it registered correctly`}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
error={error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -3,59 +3,55 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Radio,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconAlertCircle, IconAt } from '@tabler/icons-react';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAt,
|
||||
IconExclamationCircle,
|
||||
IconX
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api, queryClient } from '../../../../App';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from '../../../../App';
|
||||
import { YesNoButton } from '../../../../components/buttons/YesNoButton';
|
||||
import { PlaceholderPill } from '../../../../components/items/Placeholder';
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../../../../states/ApiState';
|
||||
import { useUserState } from '../../../../states/UserState';
|
||||
import { ProviderLogin, authApi } from '../../../../functions/auth';
|
||||
import { apiUrl, useServerApiState } from '../../../../states/ApiState';
|
||||
import type { AuthConfig, Provider } from '../../../../states/states';
|
||||
import { QrRegistrationForm } from './QrRegistrationForm';
|
||||
import { useReauth } from './useConfirm';
|
||||
|
||||
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(ApiEndpoints.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]);
|
||||
const [auth_config, sso_enabled] = useServerApiState((state) => [
|
||||
state.auth_config,
|
||||
state.sso_enabled
|
||||
]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={5}>
|
||||
<Trans>Email</Trans>
|
||||
<Trans>Email Addresses</Trans>
|
||||
</Title>
|
||||
<EmailContent />
|
||||
<EmailSection />
|
||||
<Title order={5}>
|
||||
<Trans>Single Sign On Accounts</Trans>
|
||||
<Trans>Single Sign On</Trans>
|
||||
</Title>
|
||||
{isSsoEnabled ? (
|
||||
<SsoContent dataProvider={dataProvider} />
|
||||
{sso_enabled() ? (
|
||||
<ProviderSection auth_config={auth_config} />
|
||||
) : (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size='1rem' />}
|
||||
@ -66,65 +62,42 @@ export function SecurityContent() {
|
||||
</Alert>
|
||||
)}
|
||||
<Title order={5}>
|
||||
<Trans>Multifactor</Trans>
|
||||
<Trans>Multifactor authentication</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MfaSection />
|
||||
<Title order={5}>
|
||||
<Trans>Token</Trans>
|
||||
<Trans>Access Tokens</Trans>
|
||||
</Title>
|
||||
<TokenContent />
|
||||
<TokenSection />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailContent() {
|
||||
function EmailSection() {
|
||||
const [value, setValue] = useState<string>('');
|
||||
const [newEmailValue, setNewEmailValue] = useState('');
|
||||
const [user] = useUserState((state) => [state.user]);
|
||||
const { isLoading, data, refetch } = useQuery({
|
||||
queryKey: ['emails'],
|
||||
queryFn: () =>
|
||||
api.get(apiUrl(ApiEndpoints.user_emails)).then((res) => res.data)
|
||||
authApi(apiUrl(ApiEndpoints.auth_email)).then((res) => res.data.data)
|
||||
});
|
||||
const emailAvailable = useMemo(() => {
|
||||
return data == undefined || data.length == 0;
|
||||
}, [data]);
|
||||
|
||||
function runServerAction(url: ApiEndpoints) {
|
||||
api
|
||||
.post(apiUrl(url, undefined, { id: value }), {})
|
||||
.then(() => {
|
||||
refetch();
|
||||
})
|
||||
.catch((res) => console.log(res.data));
|
||||
}
|
||||
|
||||
function addEmail() {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.user_emails), {
|
||||
email: newEmailValue,
|
||||
user: user?.pk
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
})
|
||||
.catch((res) => console.log(res.data));
|
||||
function runServerAction(
|
||||
action: 'post' | 'put' | 'delete' = 'post',
|
||||
data?: any
|
||||
) {
|
||||
const vals: any = data || { email: value };
|
||||
return authApi(
|
||||
apiUrl(ApiEndpoints.auth_email),
|
||||
undefined,
|
||||
action,
|
||||
vals
|
||||
).then(() => {
|
||||
refetch();
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
@ -132,40 +105,50 @@ function EmailContent() {
|
||||
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 justify='space-between'>
|
||||
{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>
|
||||
{emailAvailable ? (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size='1rem' />}
|
||||
title={t`Not configured`}
|
||||
color='yellow'
|
||||
>
|
||||
<Trans>Currently no email addresses are registered.</Trans>
|
||||
</Alert>
|
||||
) : (
|
||||
<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((email: any) => (
|
||||
<Radio
|
||||
key={email.email}
|
||||
value={String(email.email)}
|
||||
label={
|
||||
<Group justify='space-between'>
|
||||
{email.email}
|
||||
{email.primary && (
|
||||
<Badge color='blue'>
|
||||
<Trans>Primary</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{email.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>
|
||||
@ -184,24 +167,44 @@ function EmailContent() {
|
||||
<Grid.Col span={6}>
|
||||
<Group>
|
||||
<Button
|
||||
onClick={() => runServerAction(ApiEndpoints.user_email_primary)}
|
||||
onClick={() =>
|
||||
runServerAction('post', { email: value, primary: true })
|
||||
}
|
||||
disabled={emailAvailable}
|
||||
>
|
||||
<Trans>Make Primary</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => runServerAction(ApiEndpoints.user_email_verify)}
|
||||
onClick={() => runServerAction('put')}
|
||||
disabled={emailAvailable}
|
||||
>
|
||||
<Trans>Re-send Verification</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => runServerAction(ApiEndpoints.user_email_remove)}
|
||||
onClick={() => runServerAction('delete')}
|
||||
disabled={emailAvailable}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Button onClick={addEmail}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
runServerAction('post', { email: newEmailValue }).catch((err) => {
|
||||
if (err.status == 400) {
|
||||
showNotification({
|
||||
title: t`Error while adding email`,
|
||||
message: err.response.data.errors
|
||||
.map((error: any) => error.message)
|
||||
.join('\n'),
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Add Email</Trans>
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
@ -209,68 +212,51 @@ function EmailContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
|
||||
function ProviderButton({ provider }: Readonly<{ provider: Provider }>) {
|
||||
return (
|
||||
<Button
|
||||
key={provider.id}
|
||||
variant='outline'
|
||||
onClick={() => ProviderLogin(provider, 'connect')}
|
||||
>
|
||||
<Group justify='space-between'>{provider.name}</Group>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSection({
|
||||
auth_config
|
||||
}: Readonly<{ auth_config: AuthConfig | undefined }>) {
|
||||
const [value, setValue] = useState<string>('');
|
||||
const [currentProviders, setCurrentProviders] = useState<[]>();
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ['sso-list'],
|
||||
const { isLoading, data, refetch } = useQuery({
|
||||
queryKey: ['provider-list'],
|
||||
queryFn: () =>
|
||||
api.get(apiUrl(ApiEndpoints.user_sso)).then((res) => res.data)
|
||||
authApi(apiUrl(ApiEndpoints.auth_providers)).then((res) => res.data.data)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (dataProvider === undefined) return;
|
||||
if (data === undefined) return;
|
||||
const availableProviders = useMemo(() => {
|
||||
if (!auth_config || !data) 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]);
|
||||
const configuredProviders = data.map((item: any) => item.provider.id);
|
||||
return auth_config.socialaccount.providers.filter(
|
||||
(provider: any) => !configuredProviders.includes(provider.id)
|
||||
);
|
||||
}, [auth_config, data]);
|
||||
|
||||
function removeProvider() {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.user_sso_remove, undefined, { id: value }))
|
||||
const [uid, provider] = value.split('$');
|
||||
authApi(apiUrl(ApiEndpoints.auth_providers), undefined, 'delete', {
|
||||
provider,
|
||||
account: uid
|
||||
})
|
||||
.then(() => {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ['sso-list']
|
||||
});
|
||||
refetch();
|
||||
})
|
||||
.catch((res) => console.log(res.data));
|
||||
}
|
||||
|
||||
/* renderer */
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
function ProviderButton({ provider }: Readonly<{ provider: any }>) {
|
||||
const button = (
|
||||
<Button
|
||||
key={provider.id}
|
||||
component='a'
|
||||
href={provider.connect}
|
||||
variant='outline'
|
||||
disabled={!provider.configured}
|
||||
>
|
||||
<Group justify='space-between'>
|
||||
{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}>
|
||||
@ -280,9 +266,7 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
|
||||
title={t`Not configured`}
|
||||
color='yellow'
|
||||
>
|
||||
<Trans>
|
||||
There are no social network accounts connected to this account.{' '}
|
||||
</Trans>
|
||||
<Trans>There are no providers connected to this account.</Trans>
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack>
|
||||
@ -290,20 +274,20 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
name='sso_accounts'
|
||||
label={t`You can sign in to your account using any of the following third party accounts`}
|
||||
label={t`You can sign in to your account using any of the following providers`}
|
||||
>
|
||||
<Stack mt='xs'>
|
||||
{data.map((link: any) => (
|
||||
<Radio
|
||||
key={link.id}
|
||||
value={String(link.id)}
|
||||
label={link.provider}
|
||||
key={link.uid}
|
||||
value={[link.uid, link.provider.id].join('$')}
|
||||
label={`${link.provider.name}: ${link.display}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
<Button onClick={removeProvider}>
|
||||
<Trans>Remove</Trans>
|
||||
<Trans>Remove Provider Link</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
@ -311,33 +295,394 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
|
||||
<Grid.Col span={6}>
|
||||
<Stack>
|
||||
<Text>Add SSO Account</Text>
|
||||
<Text>
|
||||
{currentProviders === undefined ? (
|
||||
{availableProviders === undefined ? (
|
||||
<Text>
|
||||
<Trans>Loading</Trans>
|
||||
) : (
|
||||
<Stack gap='xs'>
|
||||
{currentProviders.map((provider: any) => (
|
||||
<ProviderButton key={provider.id} provider={provider} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap='xs'>
|
||||
{availableProviders.map((provider: any) => (
|
||||
<ProviderButton key={provider.id} provider={provider} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function MfaContent() {
|
||||
function MfaSection() {
|
||||
const [getReauthText, ReauthModal] = useReauth();
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<
|
||||
Recoverycodes | undefined
|
||||
>();
|
||||
const [
|
||||
recoveryCodesOpen,
|
||||
{ open: openRecoveryCodes, close: closeRecoveryCodes }
|
||||
] = useDisclosure(false);
|
||||
const { isLoading, data, refetch } = useQuery({
|
||||
queryKey: ['mfa-list'],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.auth_authenticators))
|
||||
.then((res) => res.data.data)
|
||||
});
|
||||
|
||||
function showRecoveryCodes(codes: Recoverycodes) {
|
||||
setRecoveryCodes(codes);
|
||||
openRecoveryCodes();
|
||||
}
|
||||
|
||||
const removeTotp = () => {
|
||||
runActionWithFallback(
|
||||
() =>
|
||||
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'delete').then(
|
||||
() => {
|
||||
refetch();
|
||||
return ResultType.success;
|
||||
}
|
||||
),
|
||||
getReauthText
|
||||
);
|
||||
};
|
||||
const viewRecoveryCodes = () => {
|
||||
runActionWithFallback(
|
||||
() =>
|
||||
authApi(apiUrl(ApiEndpoints.auth_recovery), undefined, 'get').then(
|
||||
(res) => {
|
||||
showRecoveryCodes(res.data.data);
|
||||
return ResultType.success;
|
||||
}
|
||||
),
|
||||
getReauthText
|
||||
);
|
||||
};
|
||||
|
||||
const parseDate = (date: number) =>
|
||||
date == null ? 'Never' : new Date(date * 1000).toLocaleString();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (isLoading || !data) return null;
|
||||
return data.map((token: any) => (
|
||||
<Table.Tr key={`${token.created_at}-${token.type}`}>
|
||||
<Table.Td>{token.type}</Table.Td>
|
||||
<Table.Td>{parseDate(token.last_used_at)}</Table.Td>
|
||||
<Table.Td>{parseDate(token.created_at)}</Table.Td>
|
||||
<Table.Td>
|
||||
{token.type == 'totp' && (
|
||||
<Button color='red' onClick={removeTotp}>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{token.type == 'recovery_codes' && (
|
||||
<Button onClick={viewRecoveryCodes}>
|
||||
<Trans>View</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
}, [data, isLoading]);
|
||||
|
||||
const usedFactors: string[] = useMemo(() => {
|
||||
if (isLoading || !data) return [];
|
||||
return data.map((token: any) => token.type);
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
return (
|
||||
<>
|
||||
MFA Details
|
||||
<PlaceholderPill />
|
||||
<ReauthModal />
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
{data.length == 0 ? (
|
||||
<Alert icon={<IconAlertCircle size='1rem' />} color='yellow'>
|
||||
<Trans>No factors configured</Trans>
|
||||
</Alert>
|
||||
) : (
|
||||
<Table stickyHeader striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>
|
||||
<Trans>Type</Trans>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Trans>Last used at</Trans>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Trans>Created at</Trans>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Trans>Actions</Trans>
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<MfaAddSection
|
||||
usedFactors={usedFactors}
|
||||
refetch={refetch}
|
||||
showRecoveryCodes={showRecoveryCodes}
|
||||
/>
|
||||
<Modal
|
||||
opened={recoveryCodesOpen}
|
||||
onClose={() => {
|
||||
refetch();
|
||||
closeRecoveryCodes();
|
||||
}}
|
||||
title={t`Recovery Codes`}
|
||||
centered
|
||||
>
|
||||
<Title order={3}>
|
||||
<Trans>Unused Codes</Trans>
|
||||
</Title>
|
||||
<Code>{recoveryCodes?.unused_codes?.join('\n')}</Code>
|
||||
|
||||
<Title order={3}>
|
||||
<Trans>Used Codes</Trans>
|
||||
</Title>
|
||||
<Code>{recoveryCodes?.used_codes?.join('\n')}</Code>
|
||||
</Modal>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenContent() {
|
||||
enum ResultType {
|
||||
success = 0,
|
||||
reauth = 1,
|
||||
mfareauth = 2,
|
||||
error = 3
|
||||
}
|
||||
|
||||
export interface Recoverycodes {
|
||||
type: string;
|
||||
created_at: number;
|
||||
last_used_at: null;
|
||||
total_code_count: number;
|
||||
unused_code_count: number;
|
||||
unused_codes: string[];
|
||||
used_code_count: number;
|
||||
used_codes: string[];
|
||||
}
|
||||
|
||||
function MfaAddSection({
|
||||
usedFactors,
|
||||
refetch,
|
||||
showRecoveryCodes
|
||||
}: Readonly<{
|
||||
usedFactors: string[];
|
||||
refetch: () => void;
|
||||
showRecoveryCodes: (codes: Recoverycodes) => void;
|
||||
}>) {
|
||||
const [auth_config] = useServerApiState((state) => [state.auth_config]);
|
||||
const [totpQrOpen, { open: openTotpQr, close: closeTotpQr }] =
|
||||
useDisclosure(false);
|
||||
const [totpQr, setTotpQr] = useState<{ totp_url: string; secret: string }>();
|
||||
const [value, setValue] = useState('');
|
||||
const [getReauthText, ReauthModal] = useReauth();
|
||||
|
||||
const registerRecoveryCodes = async () => {
|
||||
await runActionWithFallback(
|
||||
() =>
|
||||
authApi(apiUrl(ApiEndpoints.auth_recovery), undefined, 'post')
|
||||
.then((res) => {
|
||||
showRecoveryCodes(res.data.data);
|
||||
return ResultType.success;
|
||||
})
|
||||
.catch((err) => {
|
||||
showNotification({
|
||||
title: t`Error while registering recovery codes`,
|
||||
message: err.response.data.errors
|
||||
.map((error: any) => error.message)
|
||||
.join('\n'),
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
|
||||
return ResultType.error;
|
||||
}),
|
||||
getReauthText
|
||||
);
|
||||
};
|
||||
const registerTotp = async () => {
|
||||
await runActionWithFallback(
|
||||
() =>
|
||||
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'get')
|
||||
.then(() => ResultType.error)
|
||||
.catch((err) => {
|
||||
if (err.status == 404 && err.response.data.meta.secret) {
|
||||
setTotpQr(err.response.data.meta);
|
||||
openTotpQr();
|
||||
return ResultType.success;
|
||||
}
|
||||
return ResultType.error;
|
||||
}),
|
||||
getReauthText
|
||||
);
|
||||
};
|
||||
|
||||
const possibleFactors = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
type: 'totp',
|
||||
name: t`TOTP`,
|
||||
description: t`Time-based One-Time Password`,
|
||||
function: registerTotp,
|
||||
used: usedFactors?.includes('totp')
|
||||
},
|
||||
{
|
||||
type: 'recovery_codes',
|
||||
name: t`Recovery Codes`,
|
||||
description: t`One-Time pre-generated recovery codes`,
|
||||
function: registerRecoveryCodes,
|
||||
used: usedFactors?.includes('recovery_codes')
|
||||
}
|
||||
].filter((factor) => {
|
||||
return auth_config?.mfa?.supported_types.includes(factor.type);
|
||||
});
|
||||
}, [usedFactors, auth_config]);
|
||||
|
||||
const [totpError, setTotpError] = useState<string>('');
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<ReauthModal />
|
||||
<Text>Add Factor</Text>
|
||||
{possibleFactors.map((factor) => (
|
||||
<Button
|
||||
key={factor.type}
|
||||
onClick={factor.function}
|
||||
disabled={factor.used}
|
||||
variant='outline'
|
||||
>
|
||||
{factor.name}
|
||||
</Button>
|
||||
))}
|
||||
<Modal
|
||||
opened={totpQrOpen}
|
||||
onClose={closeTotpQr}
|
||||
title={<StylishText size='lg'>{t`Register TOTP Token`}</StylishText>}
|
||||
>
|
||||
<Stack>
|
||||
<QrRegistrationForm
|
||||
url={totpQr?.totp_url ?? ''}
|
||||
secret={totpQr?.secret ?? ''}
|
||||
value={value}
|
||||
error={totpError}
|
||||
setValue={setValue}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
runActionWithFallback(
|
||||
() =>
|
||||
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
|
||||
code: value
|
||||
})
|
||||
.then(() => {
|
||||
setTotpError('');
|
||||
closeTotpQr();
|
||||
refetch();
|
||||
return ResultType.success;
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = t`Error registering TOTP token`;
|
||||
|
||||
setTotpError(
|
||||
error.response?.data?.errors[0]?.message ?? errorMsg
|
||||
);
|
||||
|
||||
hideNotification('totp-error');
|
||||
showNotification({
|
||||
id: 'totp-error',
|
||||
title: t`Error`,
|
||||
message: errorMsg,
|
||||
color: 'red',
|
||||
icon: <IconExclamationCircle />
|
||||
});
|
||||
return ResultType.error;
|
||||
}),
|
||||
getReauthText
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Submit</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
async function runActionWithFallback(
|
||||
action: () => Promise<ResultType>,
|
||||
getReauthText: (props: any) => any
|
||||
) {
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
const rslt = await action().catch((err) => {
|
||||
setAuthContext(err.response.data?.data);
|
||||
// check if we need to re-authenticate
|
||||
if (err.status == 401) {
|
||||
if (
|
||||
err.response.data.data.flows.find(
|
||||
(flow: any) => flow.id == 'mfa_reauthenticate'
|
||||
)
|
||||
) {
|
||||
return ResultType.mfareauth;
|
||||
} else if (
|
||||
err.response.data.data.flows.find(
|
||||
(flow: any) => flow.id == 'reauthenticate'
|
||||
)
|
||||
) {
|
||||
return ResultType.reauth;
|
||||
} else {
|
||||
return ResultType.error;
|
||||
}
|
||||
} else {
|
||||
return ResultType.error;
|
||||
}
|
||||
});
|
||||
if (rslt == ResultType.mfareauth) {
|
||||
authApi(apiUrl(ApiEndpoints.auth_mfa_reauthenticate), undefined, 'post', {
|
||||
code: await getReauthText({
|
||||
label: t`TOTP Code`,
|
||||
name: 'TOTP',
|
||||
description: t`Enter your TOTP or recovery code`
|
||||
})
|
||||
})
|
||||
.then((response) => {
|
||||
setAuthContext(response.data?.data);
|
||||
action();
|
||||
})
|
||||
.catch((err) => {
|
||||
setAuthContext(err.response.data?.data);
|
||||
});
|
||||
} else if (rslt == ResultType.reauth) {
|
||||
authApi(apiUrl(ApiEndpoints.auth_reauthenticate), undefined, 'post', {
|
||||
password: await getReauthText({
|
||||
label: t`Password`,
|
||||
name: 'password',
|
||||
description: t`Enter your password`
|
||||
})
|
||||
})
|
||||
.then((response) => {
|
||||
setAuthContext(response.data?.data);
|
||||
action();
|
||||
})
|
||||
.catch((err) => {
|
||||
setAuthContext(err.response.data?.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function TokenSection() {
|
||||
const { isLoading, data, refetch } = useQuery({
|
||||
queryKey: ['token-list'],
|
||||
queryFn: () =>
|
||||
@ -352,8 +697,9 @@ function TokenContent() {
|
||||
})
|
||||
.catch((res) => console.log(res.data));
|
||||
}
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (isLoading || data === undefined) return null;
|
||||
if (isLoading || !data) return null;
|
||||
return data.map((token: any) => (
|
||||
<Table.Tr key={token.id}>
|
||||
<Table.Td>
|
||||
@ -380,7 +726,6 @@ function TokenContent() {
|
||||
));
|
||||
}, [data, isLoading]);
|
||||
|
||||
/* renderer */
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
if (data.length == 0)
|
||||
|
@ -0,0 +1,116 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Button, Group, Modal, Stack, TextInput } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */
|
||||
const createPromise = () => {
|
||||
let resolver: any;
|
||||
return [
|
||||
new Promise((resolve) => {
|
||||
resolver = resolve;
|
||||
}),
|
||||
resolver
|
||||
];
|
||||
};
|
||||
|
||||
/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */
|
||||
export const useConfirm = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resolver, setResolver] = useState<((status: boolean) => void) | null>(
|
||||
null
|
||||
);
|
||||
const [label, setLabel] = useState('');
|
||||
|
||||
const getConfirmation = async (text: string) => {
|
||||
setLabel(text);
|
||||
setOpen(true);
|
||||
const [promise, resolve] = await createPromise();
|
||||
|
||||
setResolver(resolve);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const onClick = async (status: boolean) => {
|
||||
setOpen(false);
|
||||
if (resolver) {
|
||||
resolver(status);
|
||||
}
|
||||
};
|
||||
|
||||
const Confirmation = () => (
|
||||
<Modal opened={open} onClose={() => setOpen(false)}>
|
||||
{label}
|
||||
<Button onClick={() => onClick(false)}> Cancel </Button>
|
||||
<Button onClick={() => onClick(true)}> OK </Button>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return [getConfirmation, Confirmation];
|
||||
};
|
||||
|
||||
type InputProps = {
|
||||
label: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
export const useReauth = (): [
|
||||
(props: InputProps) => Promise<[string, boolean]>,
|
||||
() => JSX.Element
|
||||
] => {
|
||||
const [inputProps, setInputProps] = useState<InputProps>({
|
||||
label: '',
|
||||
name: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resolver, setResolver] = useState<{
|
||||
resolve: (result: string, positive: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
const getReauthText = async (props: InputProps) => {
|
||||
setInputProps(props);
|
||||
setOpen(true);
|
||||
const [promise, resolve] = await createPromise();
|
||||
|
||||
setResolver({ resolve });
|
||||
return promise;
|
||||
};
|
||||
|
||||
const onClick = async (result: string, positive: boolean) => {
|
||||
setOpen(false);
|
||||
if (resolver) {
|
||||
resolver.resolve(result, positive);
|
||||
}
|
||||
};
|
||||
|
||||
const ReauthModal = () => (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t`Reauthentication`}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
required
|
||||
label={inputProps.label}
|
||||
name={inputProps.name}
|
||||
description={inputProps.description}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
<Group justify='space-between'>
|
||||
<Button onClick={() => onClick('', false)} color='red'>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => onClick(value, true)}>
|
||||
<Trans>OK</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return [getReauthText, ReauthModal];
|
||||
};
|
@ -104,6 +104,8 @@ export const NotFound = Loadable(
|
||||
lazy(() => import('./components/errors/NotFound'))
|
||||
);
|
||||
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
|
||||
export const MFALogin = Loadable(lazy(() => import('./pages/Auth/MFALogin')));
|
||||
export const MFASetup = Loadable(lazy(() => import('./pages/Auth/MFASetup')));
|
||||
export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
|
||||
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
|
||||
export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset')));
|
||||
@ -115,6 +117,9 @@ export const ChangePassword = Loadable(
|
||||
export const ResetPassword = Loadable(
|
||||
lazy(() => import('./pages/Auth/ResetPassword'))
|
||||
);
|
||||
export const VerifyEmail = Loadable(
|
||||
lazy(() => import('./pages/Auth/VerifyEmail'))
|
||||
);
|
||||
|
||||
// Routes
|
||||
export const routes = (
|
||||
@ -170,11 +175,15 @@ export const routes = (
|
||||
</Route>
|
||||
<Route path='/' errorElement={<ErrorPage />}>
|
||||
<Route path='/login' element={<Login />} />,
|
||||
<Route path='/register' element={<Login />} />,
|
||||
<Route path='/mfa' element={<MFALogin />} />,
|
||||
<Route path='/mfa-setup' element={<MFASetup />} />,
|
||||
<Route path='/logout' element={<Logout />} />,
|
||||
<Route path='/logged-in' element={<Logged_In />} />
|
||||
<Route path='/reset-password' element={<Reset />} />
|
||||
<Route path='/set-password' element={<ResetPassword />} />
|
||||
<Route path='/change-password' element={<ChangePassword />} />
|
||||
<Route path='/verify-email/:key' element={<VerifyEmail />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
@ -4,18 +4,31 @@ import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import { api } from '../App';
|
||||
import { emptyServerAPI } from '../defaults/defaults';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import type { AuthProps, ServerAPIProps } from './states';
|
||||
import type { AuthConfig, AuthContext, ServerAPIProps } from './states';
|
||||
|
||||
interface ServerApiStateProps {
|
||||
server: ServerAPIProps;
|
||||
setServer: (newServer: ServerAPIProps) => void;
|
||||
fetchServerApiState: () => void;
|
||||
auth_settings?: AuthProps;
|
||||
auth_config?: AuthConfig;
|
||||
auth_context?: AuthContext;
|
||||
setAuthContext: (auth_context: AuthContext) => void;
|
||||
sso_enabled: () => boolean;
|
||||
registration_enabled: () => boolean;
|
||||
sso_registration_enabled: () => boolean;
|
||||
password_forgotten_enabled: () => boolean;
|
||||
}
|
||||
|
||||
function get_server_setting(val: any) {
|
||||
if (val === null || val === undefined) {
|
||||
return false;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
export const useServerApiState = create<ServerApiStateProps>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
server: emptyServerAPI,
|
||||
setServer: (newServer: ServerAPIProps) => set({ server: newServer }),
|
||||
fetchServerApiState: async () => {
|
||||
@ -31,17 +44,36 @@ export const useServerApiState = create<ServerApiStateProps>()(
|
||||
|
||||
// Fetch login/SSO behaviour
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.sso_providers), {
|
||||
.get(apiUrl(ApiEndpoints.auth_config), {
|
||||
headers: { Authorization: '' }
|
||||
})
|
||||
.then((response) => {
|
||||
set({ auth_settings: response.data });
|
||||
set({ auth_config: response.data.data });
|
||||
})
|
||||
.catch(() => {
|
||||
console.error('ERR: Error fetching SSO information');
|
||||
});
|
||||
},
|
||||
status: undefined
|
||||
auth_config: undefined,
|
||||
auth_context: undefined,
|
||||
setAuthContext(auth_context) {
|
||||
set({ auth_context });
|
||||
},
|
||||
sso_enabled: () => {
|
||||
const data = get().auth_config?.socialaccount.providers;
|
||||
return !(data === undefined || data.length == 0);
|
||||
},
|
||||
registration_enabled: () => {
|
||||
return get_server_setting(get().server?.settings?.registration_enabled);
|
||||
},
|
||||
sso_registration_enabled: () => {
|
||||
return get_server_setting(get().server?.settings?.sso_registration);
|
||||
},
|
||||
password_forgotten_enabled: () => {
|
||||
return get_server_setting(
|
||||
get().server?.settings?.password_forgotten_enabled
|
||||
);
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'server-api-state',
|
||||
|
@ -13,7 +13,7 @@ import type { Setting, SettingsLookup } from './states';
|
||||
export interface SettingsStateProps {
|
||||
settings: Setting[];
|
||||
lookup: SettingsLookup;
|
||||
fetchSettings: () => void;
|
||||
fetchSettings: () => Promise<boolean>;
|
||||
endpoint: ApiEndpoints;
|
||||
pathParams?: PathParams;
|
||||
getSetting: (key: string, default_value?: string) => string; // Return a raw setting value
|
||||
@ -29,10 +29,11 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
|
||||
lookup: {},
|
||||
endpoint: ApiEndpoints.settings_global_list,
|
||||
fetchSettings: async () => {
|
||||
let success = true;
|
||||
const { isLoggedIn } = useUserState.getState();
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
return success;
|
||||
}
|
||||
|
||||
await api
|
||||
@ -45,7 +46,10 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
|
||||
})
|
||||
.catch((_error) => {
|
||||
console.error('ERR: Error fetching global settings');
|
||||
success = false;
|
||||
});
|
||||
|
||||
return success;
|
||||
},
|
||||
getSetting: (key: string, default_value?: string) => {
|
||||
return get().lookup[key] ?? default_value ?? '';
|
||||
@ -65,10 +69,11 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
|
||||
lookup: {},
|
||||
endpoint: ApiEndpoints.settings_user_list,
|
||||
fetchSettings: async () => {
|
||||
let success = true;
|
||||
const { isLoggedIn } = useUserState.getState();
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
return success;
|
||||
}
|
||||
|
||||
await api
|
||||
@ -81,7 +86,10 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
|
||||
})
|
||||
.catch((_error) => {
|
||||
console.error('ERR: Error fetching user settings');
|
||||
success = false;
|
||||
});
|
||||
|
||||
return success;
|
||||
},
|
||||
getSetting: (key: string, default_value?: string) => {
|
||||
return get().lookup[key] ?? default_value ?? '';
|
||||
@ -110,6 +118,8 @@ export const createPluginSettingsState = ({
|
||||
endpoint: ApiEndpoints.plugin_setting_list,
|
||||
pathParams,
|
||||
fetchSettings: async () => {
|
||||
let success = true;
|
||||
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.plugin_setting_list, undefined, { plugin }))
|
||||
.then((response) => {
|
||||
@ -121,7 +131,10 @@ export const createPluginSettingsState = ({
|
||||
})
|
||||
.catch((_error) => {
|
||||
console.error(`Error fetching plugin settings for plugin ${plugin}`);
|
||||
success = false;
|
||||
});
|
||||
|
||||
return success;
|
||||
},
|
||||
getSetting: (key: string, default_value?: string) => {
|
||||
return get().lookup[key] ?? default_value ?? '';
|
||||
@ -153,6 +166,8 @@ export const createMachineSettingsState = ({
|
||||
endpoint: ApiEndpoints.machine_setting_detail,
|
||||
pathParams,
|
||||
fetchSettings: async () => {
|
||||
let success = true;
|
||||
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.machine_setting_list, undefined, { machine }))
|
||||
.then((response) => {
|
||||
@ -169,7 +184,10 @@ export const createMachineSettingsState = ({
|
||||
`Error fetching machine settings for machine ${machine} with type ${configType}:`,
|
||||
error
|
||||
);
|
||||
success = false;
|
||||
});
|
||||
|
||||
return success;
|
||||
},
|
||||
getSetting: (key: string, default_value?: string) => {
|
||||
return get().lookup[key] ?? default_value ?? '';
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import { setApiDefaults } from '../App';
|
||||
import { useServerApiState } from './ApiState';
|
||||
import { useIconState } from './IconState';
|
||||
@ -48,6 +49,11 @@ export interface ServerAPIProps {
|
||||
target: null | string;
|
||||
default_locale: null | string;
|
||||
django_admin: null | string;
|
||||
settings: {
|
||||
sso_registration: null | boolean;
|
||||
registration_enabled: null | boolean;
|
||||
password_forgotten_enabled: null | boolean;
|
||||
} | null;
|
||||
customize: null | {
|
||||
logo: string;
|
||||
splash: string;
|
||||
@ -56,22 +62,48 @@ export interface ServerAPIProps {
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthProps {
|
||||
sso_enabled: boolean;
|
||||
sso_registration: boolean;
|
||||
mfa_required: boolean;
|
||||
providers: Provider[];
|
||||
registration_enabled: boolean;
|
||||
password_forgotten_enabled: boolean;
|
||||
export interface AuthContext {
|
||||
status: number;
|
||||
data: { flows: Flow[] };
|
||||
meta: { is_authenticated: boolean };
|
||||
}
|
||||
|
||||
export enum FlowEnum {
|
||||
VerifyEmail = 'verify_email',
|
||||
Login = 'login',
|
||||
Signup = 'signup',
|
||||
ProviderRedirect = 'provider_redirect',
|
||||
ProviderSignup = 'provider_signup',
|
||||
ProviderToken = 'provider_token',
|
||||
MfaAuthenticate = 'mfa_authenticate',
|
||||
Reauthenticate = 'reauthenticate',
|
||||
MfaReauthenticate = 'mfa_reauthenticate'
|
||||
}
|
||||
|
||||
export interface Flow {
|
||||
id: FlowEnum;
|
||||
providers?: string[];
|
||||
is_pending?: boolean[];
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
account: {
|
||||
authentication_method: string;
|
||||
};
|
||||
socialaccount: { providers: Provider[] };
|
||||
mfa: {
|
||||
supported_types: string[];
|
||||
};
|
||||
usersessions: {
|
||||
track_activity: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
configured: boolean;
|
||||
login: string;
|
||||
connect: string;
|
||||
display_name: string;
|
||||
flows: string[];
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
// Type interface defining a single 'setting' object
|
||||
@ -134,7 +166,9 @@ export type SettingsLookup = {
|
||||
* Refetch all global state information.
|
||||
* Necessary on login, or if locale is changed.
|
||||
*/
|
||||
export function fetchGlobalStates() {
|
||||
export async function fetchGlobalStates(
|
||||
navigate?: NavigateFunction | undefined
|
||||
) {
|
||||
const { isLoggedIn } = useUserState.getState();
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
@ -144,7 +178,12 @@ export function fetchGlobalStates() {
|
||||
setApiDefaults();
|
||||
|
||||
useServerApiState.getState().fetchServerApiState();
|
||||
useUserSettingsState.getState().fetchSettings();
|
||||
const result = await useUserSettingsState.getState().fetchSettings();
|
||||
if (!result && navigate) {
|
||||
console.log('MFA is required - setting up');
|
||||
// call mfa setup
|
||||
navigate('/mfa-setup');
|
||||
}
|
||||
useGlobalSettingsState.getState().fetchSettings();
|
||||
useGlobalStatusState.getState().fetchStatus();
|
||||
useIconState.getState().fetchIcons();
|
||||
|
@ -20,8 +20,12 @@ export default function MainView() {
|
||||
const [allowMobile] = useLocalState((state) => [state.allowMobile]);
|
||||
// Set initial login status
|
||||
useEffect(() => {
|
||||
// Local state initialization
|
||||
setApiDefaults();
|
||||
try {
|
||||
// Local state initialization
|
||||
setApiDefaults();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check if mobile
|
||||
|
@ -71,6 +71,9 @@ export const test = baseTest.extend({
|
||||
url != 'http://localhost:8000/this/does/not/exist.js' &&
|
||||
url != 'http://localhost:8000/api/user/me/' &&
|
||||
url != 'http://localhost:8000/api/user/token/' &&
|
||||
url != 'http://localhost:8000/api/auth/v1/auth/login' &&
|
||||
url != 'http://localhost:8000/api/auth/v1/auth/session' &&
|
||||
url != 'http://localhost:8000/api/auth/v1/account/password/change' &&
|
||||
url != 'http://localhost:8000/api/barcode/' &&
|
||||
url != 'https://docs.inventree.org/en/versions.json' &&
|
||||
url != 'http://localhost:5173/favicon.ico' &&
|
||||
|
@ -32,10 +32,10 @@ export const doQuickLogin = async (
|
||||
password = password ?? user.password;
|
||||
url = url ?? baseUrl;
|
||||
|
||||
await navigate(page, `${url}/login/?login=${username}&password=${password}`);
|
||||
await navigate(page, `${url}/login?login=${username}&password=${password}`);
|
||||
await page.waitForURL('**/platform/home');
|
||||
|
||||
await page.getByLabel('navigation-menu').waitFor();
|
||||
await page.getByLabel('navigation-menu').waitFor({ timeout: 5000 });
|
||||
await page.getByText(/InvenTree Demo Server -/).waitFor();
|
||||
|
||||
// Wait for the dashboard to load
|
||||
|
@ -87,6 +87,7 @@ test('Login - Change Password', async ({ page }) => {
|
||||
await page.getByLabel('action-menu-user-actions-change-password').click();
|
||||
|
||||
// First attempt with some errors
|
||||
await page.getByLabel('password', { exact: true }).fill('youshallnotpass');
|
||||
await page.getByLabel('input-password-1').fill('12345');
|
||||
await page.getByLabel('input-password-2').fill('54321');
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
@ -105,9 +106,5 @@ test('Login - Change Password', async ({ page }) => {
|
||||
await page.getByText('Password Changed').waitFor();
|
||||
await page.getByText('The password was set successfully').waitFor();
|
||||
|
||||
// Should have redirected to the index page
|
||||
await page.waitForURL('**/platform/home**');
|
||||
await page.getByText('InvenTree Demo Server - Norman Nothington');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
Reference in New Issue
Block a user