2
0
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:
Matthias Mair
2025-02-22 01:11:04 +01:00
committed by GitHub
parent 1f84f24514
commit 03278c56c9
56 changed files with 1814 additions and 2356 deletions

View File

@ -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>
);

View File

@ -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>

View File

@ -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]

View File

@ -20,6 +20,7 @@ export const emptyServerAPI = {
target: null,
default_locale: null,
django_admin: null,
settings: null,
customize: null
};

View File

@ -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/',

View File

@ -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);
}

View File

@ -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 didnt 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'

View File

@ -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

View 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>
);
}

View 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>
);
}

View File

@ -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>

View 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>
);
}

View File

@ -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}
/>
</>
);
}

View File

@ -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)

View File

@ -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];
};

View File

@ -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>
);

View File

@ -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',

View File

@ -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 ?? '';

View File

@ -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();

View File

@ -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

View File

@ -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' &&

View File

@ -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

View File

@ -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);
});