mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-29 20:30:39 +00:00
* bump allauth * add trust * add device trust handling * fix style * [FR] Add passkey as a factor Fixes #4002 * add registration * allow better testing * add mfa context * fix login * add changelog entry * fix registration * remove multi device packages * move to helper * handle mfa trust * simplify page fnc
875 lines
25 KiB
TypeScript
875 lines
25 KiB
TypeScript
import { create } from '@github/webauthn-json/browser-ponyfill';
|
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
|
import { apiUrl } from '@lib/functions/Api';
|
|
import { type AuthConfig, type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
|
import { t } from '@lingui/core/macro';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import {
|
|
Accordion,
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Code,
|
|
Grid,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
Radio,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
TextInput,
|
|
Tooltip
|
|
} from '@mantine/core';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import { hideNotification, showNotification } from '@mantine/notifications';
|
|
import {
|
|
IconAlertCircle,
|
|
IconAt,
|
|
IconExclamationCircle,
|
|
IconRefresh,
|
|
IconX
|
|
} from '@tabler/icons-react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useCallback, useMemo, useState } from 'react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import { api } from '../../../../App';
|
|
import { StylishText } from '../../../../components/items/StylishText';
|
|
import { ProviderLogin, authApi } from '../../../../functions/auth';
|
|
import { useServerApiState } from '../../../../states/ServerApiState';
|
|
import { useUserState } from '../../../../states/UserState';
|
|
import { ApiTokenTable } from '../../../../tables/settings/ApiTokenTable';
|
|
import { QrRegistrationForm } from './QrRegistrationForm';
|
|
import { useReauth } from './useConfirm';
|
|
|
|
export function SecurityContent() {
|
|
const [auth_config, sso_enabled] = useServerApiState(
|
|
useShallow((state) => [state.auth_config, state.sso_enabled])
|
|
);
|
|
|
|
const user = useUserState();
|
|
|
|
return (
|
|
<Stack>
|
|
<Accordion multiple defaultValue={['email']}>
|
|
<Accordion.Item value='email'>
|
|
<Accordion.Control>
|
|
<StylishText size='lg'>{t`Email Addresses`}</StylishText>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<EmailSection />
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
<Accordion.Item value='sso'>
|
|
<Accordion.Control>
|
|
<StylishText size='lg'>{t`Single Sign On`}</StylishText>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
{sso_enabled() ? (
|
|
<ProviderSection auth_config={auth_config} />
|
|
) : (
|
|
<Alert
|
|
icon={<IconAlertCircle size='1rem' />}
|
|
title={t`Not enabled`}
|
|
color='yellow'
|
|
>
|
|
<Trans>Single Sign On is not enabled for this server </Trans>
|
|
</Alert>
|
|
)}
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
<Accordion.Item value='mfa'>
|
|
<Accordion.Control>
|
|
<StylishText size='lg'>{t`Multi-Factor Authentication`}</StylishText>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<MfaSection />
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
<Accordion.Item value='token'>
|
|
<Accordion.Control>
|
|
<StylishText size='lg'>{t`Access Tokens`}</StylishText>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<ApiTokenTable only_myself />
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
{user.isSuperuser() && (
|
|
<Accordion.Item value='session'>
|
|
<Accordion.Control>
|
|
<StylishText size='lg'>{t`Session Information`}</StylishText>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<AuthContextSection />
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
)}
|
|
</Accordion>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function AuthContextSection() {
|
|
const [auth_context, setAuthContext] = useServerApiState(
|
|
useShallow((state) => [state.auth_context, state.setAuthContext])
|
|
);
|
|
|
|
const fetchAuthContext = useCallback(() => {
|
|
authApi(apiUrl(ApiEndpoints.auth_session)).then((resp) => {
|
|
setAuthContext(resp.data.data);
|
|
});
|
|
}, [setAuthContext]);
|
|
|
|
return (
|
|
<Stack gap='xs'>
|
|
<Group>
|
|
<ActionIcon
|
|
onClick={fetchAuthContext}
|
|
variant='transparent'
|
|
aria-label='refresh-auth-context'
|
|
>
|
|
<IconRefresh />
|
|
</ActionIcon>
|
|
</Group>
|
|
|
|
<Table>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>{t`Timestamp`}</Table.Th>
|
|
<Table.Th>{t`Method`}</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{auth_context?.methods?.map((method: any, index: number) => (
|
|
<Table.Tr key={`auth-method-${index}`}>
|
|
<Table.Td>{parseDate(method.at)}</Table.Td>
|
|
<Table.Td>{method.method}</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function EmailSection() {
|
|
const [selectedEmail, setSelectedEmail] = useState<string>('');
|
|
const [newEmailValue, setNewEmailValue] = useState('');
|
|
const { isLoading, data, refetch } = useQuery({
|
|
queryKey: ['emails'],
|
|
queryFn: () =>
|
|
authApi(apiUrl(ApiEndpoints.auth_email)).then((res) => res.data.data)
|
|
});
|
|
const emailAvailable = useMemo(() => {
|
|
return data == undefined || data.length == 0;
|
|
}, [data]);
|
|
|
|
function runServerAction(
|
|
action: 'patch' | 'post' | 'put' | 'delete' = 'post',
|
|
data?: any
|
|
) {
|
|
const vals: any = data || { email: selectedEmail };
|
|
return authApi(apiUrl(ApiEndpoints.auth_email), undefined, action, vals)
|
|
.then(() => {
|
|
refetch();
|
|
})
|
|
.catch((err) => {
|
|
hideNotification('email-error');
|
|
|
|
showNotification({
|
|
id: 'email-error',
|
|
title: t`Error`,
|
|
message: t`Error while updating email`,
|
|
color: 'red'
|
|
});
|
|
});
|
|
}
|
|
|
|
if (isLoading) return <Loader />;
|
|
|
|
return (
|
|
<SimpleGrid cols={{ xs: 1, md: 2 }} spacing='sm'>
|
|
{emailAvailable ? (
|
|
<Stack gap='xs'>
|
|
<Alert
|
|
icon={<IconAlertCircle size='1rem' />}
|
|
title={t`Not Configured`}
|
|
color='yellow'
|
|
>
|
|
<Trans>Currently no email addresses are registered.</Trans>
|
|
</Alert>
|
|
</Stack>
|
|
) : (
|
|
<Radio.Group
|
|
value={selectedEmail}
|
|
onChange={setSelectedEmail}
|
|
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-apart'>
|
|
{email.email}
|
|
<Group justify='right'>
|
|
{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>
|
|
</Group>
|
|
}
|
|
/>
|
|
))}
|
|
<Group>
|
|
<Button
|
|
onClick={() =>
|
|
runServerAction('patch', {
|
|
email: selectedEmail,
|
|
primary: true
|
|
})
|
|
}
|
|
disabled={!selectedEmail}
|
|
>
|
|
<Trans>Make Primary</Trans>
|
|
</Button>
|
|
<Button
|
|
onClick={() => runServerAction('put')}
|
|
disabled={!selectedEmail}
|
|
>
|
|
<Trans>Re-send Verification</Trans>
|
|
</Button>
|
|
<Button
|
|
onClick={() => runServerAction('delete')}
|
|
disabled={!selectedEmail}
|
|
color='red'
|
|
>
|
|
<Trans>Remove</Trans>
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Radio.Group>
|
|
)}
|
|
<Stack>
|
|
<StylishText size='md'>{t`Add Email Address`}</StylishText>
|
|
<TextInput
|
|
label={t`E-Mail`}
|
|
placeholder={t`E-Mail address`}
|
|
leftSection={<IconAt />}
|
|
aria-label='email-address-input'
|
|
value={newEmailValue}
|
|
onChange={(event) => setNewEmailValue(event.currentTarget.value)}
|
|
/>
|
|
<Button
|
|
aria-label='email-address-submit'
|
|
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>
|
|
</Stack>
|
|
</SimpleGrid>
|
|
);
|
|
}
|
|
|
|
function ProviderButton({ provider }: Readonly<{ provider: AuthProvider }>) {
|
|
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 { isLoading, data, refetch } = useQuery({
|
|
queryKey: ['provider-list'],
|
|
queryFn: () =>
|
|
authApi(apiUrl(ApiEndpoints.auth_providers))
|
|
.then((res) => res?.data?.data ?? [])
|
|
.catch(() => [])
|
|
});
|
|
|
|
const availableProviders = useMemo(() => {
|
|
if (!auth_config || !data) return [];
|
|
|
|
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() {
|
|
const [uid, provider] = value.split('$');
|
|
authApi(apiUrl(ApiEndpoints.auth_providers), undefined, 'delete', {
|
|
provider,
|
|
account: uid
|
|
})
|
|
.then(() => {
|
|
refetch();
|
|
})
|
|
.catch((res) => console.log(res.data));
|
|
}
|
|
|
|
if (isLoading) return <Loader />;
|
|
|
|
return (
|
|
<Grid>
|
|
<Grid.Col span={6}>
|
|
{data.length == 0 ? (
|
|
<Stack gap='xs'>
|
|
<Alert
|
|
icon={<IconAlertCircle size='1rem' />}
|
|
title={t`Not Configured`}
|
|
color='yellow'
|
|
>
|
|
<Trans>There are no providers connected to this account.</Trans>
|
|
</Alert>
|
|
</Stack>
|
|
) : (
|
|
<Stack>
|
|
<Radio.Group
|
|
value={value}
|
|
onChange={setValue}
|
|
name='sso_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.uid}
|
|
value={[link.uid, link.provider.id].join('$')}
|
|
label={`${link.provider.name}: ${link.display}`}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Radio.Group>
|
|
<Button onClick={removeProvider}>
|
|
<Trans>Remove Provider Link</Trans>
|
|
</Button>
|
|
</Stack>
|
|
)}
|
|
</Grid.Col>
|
|
<Grid.Col span={6}>
|
|
<Stack>
|
|
<Text>Add SSO Account</Text>
|
|
{availableProviders === undefined ? (
|
|
<Text>
|
|
<Trans>Loading</Trans>
|
|
</Text>
|
|
) : (
|
|
<Stack gap='xs'>
|
|
{availableProviders.map((provider: any) => (
|
|
<ProviderButton key={provider.id} provider={provider} />
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Grid.Col>
|
|
</Grid>
|
|
);
|
|
}
|
|
|
|
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 ?? [])
|
|
.catch(() => [])
|
|
});
|
|
|
|
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 removeWebauthn = (code: string) => {
|
|
runActionWithFallback(
|
|
() =>
|
|
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'delete', {
|
|
authenticators: [code]
|
|
}).then(() => {
|
|
refetch();
|
|
return ResultType.success;
|
|
}),
|
|
getReauthText
|
|
);
|
|
};
|
|
|
|
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>
|
|
)}
|
|
{token.type == 'webauthn' && (
|
|
<Button
|
|
color='red'
|
|
onClick={() => {
|
|
removeWebauthn(token.id);
|
|
}}
|
|
>
|
|
<Trans>Remove</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 (
|
|
<>
|
|
<ReauthModal />
|
|
<SimpleGrid cols={{ xs: 1, md: 2 }} spacing='sm'>
|
|
{data.length == 0 ? (
|
|
<Stack gap='xs'>
|
|
<Alert
|
|
title={t`Not Configured`}
|
|
icon={<IconAlertCircle size='1rem' />}
|
|
color='yellow'
|
|
>
|
|
<Trans>No multi-factor tokens configured for this account</Trans>
|
|
</Alert>
|
|
</Stack>
|
|
) : (
|
|
<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>
|
|
)}
|
|
<MfaAddSection
|
|
usedFactors={usedFactors}
|
|
refetch={refetch}
|
|
showRecoveryCodes={showRecoveryCodes}
|
|
/>
|
|
<Modal
|
|
opened={recoveryCodesOpen}
|
|
onClose={() => {
|
|
refetch();
|
|
closeRecoveryCodes();
|
|
}}
|
|
title={t`Recovery Codes`}
|
|
centered
|
|
>
|
|
<StylishText size='lg'>
|
|
<Trans>Unused Codes</Trans>
|
|
</StylishText>
|
|
<Code>{recoveryCodes?.unused_codes?.join('\n')}</Code>
|
|
|
|
<StylishText size='lg'>
|
|
<Trans>Used Codes</Trans>
|
|
</StylishText>
|
|
<Code>{recoveryCodes?.used_codes?.join('\n')}</Code>
|
|
</Modal>
|
|
</SimpleGrid>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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(
|
|
useShallow((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 registerWebauthn = async () => {
|
|
let data: any = {};
|
|
await runActionWithFallback(
|
|
() =>
|
|
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'get').then(
|
|
(res) => {
|
|
data = res.data?.data;
|
|
if (data?.creation_options) {
|
|
return ResultType.success;
|
|
} else {
|
|
return ResultType.error;
|
|
}
|
|
}
|
|
),
|
|
getReauthText
|
|
);
|
|
if (data?.creation_options == undefined) {
|
|
showNotification({
|
|
title: t`Error while registering WebAuthn authenticator`,
|
|
message: t`Please reload page and try again.`,
|
|
color: 'red',
|
|
icon: <IconX />
|
|
});
|
|
}
|
|
|
|
// register the webauthn authenticator with the browser
|
|
const resp = await create({
|
|
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(
|
|
data.creation_options.publicKey
|
|
)
|
|
});
|
|
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'post', {
|
|
name: 'Master Key',
|
|
credential: JSON.stringify(resp)
|
|
})
|
|
.then(() => {
|
|
showNotification({
|
|
title: t`WebAuthn authenticator registered successfully`,
|
|
message: t`You can now use this authenticator for multi-factor authentication.`,
|
|
color: 'green'
|
|
});
|
|
refetch();
|
|
return ResultType.success;
|
|
})
|
|
.catch((err) => {
|
|
showNotification({
|
|
title: t`Error while registering WebAuthn authenticator`,
|
|
message: err.response.data.errors
|
|
.map((error: any) => error.message)
|
|
.join('\n'),
|
|
color: 'red',
|
|
icon: <IconX />
|
|
});
|
|
return ResultType.error;
|
|
});
|
|
};
|
|
|
|
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')
|
|
},
|
|
{
|
|
type: 'webauthn',
|
|
name: t`WebAuthn`,
|
|
description: t`Web Authentication (WebAuthn) is a web standard for secure authentication`,
|
|
function: registerWebauthn,
|
|
used: usedFactors?.includes('webauthn')
|
|
}
|
|
].filter((factor) => {
|
|
return auth_config?.mfa?.supported_types.includes(factor.type);
|
|
});
|
|
}, [usedFactors, auth_config]);
|
|
|
|
const [totpError, setTotpError] = useState<string>('');
|
|
|
|
return (
|
|
<Stack>
|
|
<ReauthModal />
|
|
<StylishText size='md'>{t`Add Token`}</StylishText>
|
|
{possibleFactors.map((factor) => (
|
|
<Tooltip label={factor.description} key={factor.type}>
|
|
<Button
|
|
onClick={factor.function}
|
|
disabled={factor.used}
|
|
variant='outline'
|
|
>
|
|
{factor.name}
|
|
</Button>
|
|
</Tooltip>
|
|
))}
|
|
<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, setMfaContext, mfa_context } =
|
|
useServerApiState.getState();
|
|
|
|
const result = await action().catch((err) => {
|
|
setAuthContext(err.response.data?.data);
|
|
// check if we need to re-authenticate
|
|
if (err.status == 401) {
|
|
const mfaFlow = err.response.data.data.flows.find(
|
|
(flow: any) => flow.id == FlowEnum.MfaReauthenticate
|
|
);
|
|
if (mfaFlow) {
|
|
setMfaContext(mfaFlow);
|
|
return ResultType.mfareauth;
|
|
} else if (
|
|
err.response.data.data.flows.find(
|
|
(flow: any) => flow.id == FlowEnum.Reauthenticate
|
|
)
|
|
) {
|
|
return ResultType.reauth;
|
|
} else {
|
|
return ResultType.error;
|
|
}
|
|
} else {
|
|
return ResultType.error;
|
|
}
|
|
});
|
|
|
|
// run the re-authentication flows as needed
|
|
if (result == ResultType.mfareauth) {
|
|
const mfa_types = mfa_context?.types || [];
|
|
const mfaCode = await getReauthText({
|
|
label: t`TOTP Code`,
|
|
name: 'TOTP',
|
|
description: t`Enter one of your codes: ${mfa_types}`
|
|
});
|
|
|
|
authApi(apiUrl(ApiEndpoints.auth_mfa_reauthenticate), undefined, 'post', {
|
|
code: mfaCode
|
|
})
|
|
.then((response) => {
|
|
setAuthContext(response.data?.data);
|
|
action();
|
|
})
|
|
.catch((err) => {
|
|
setAuthContext(err.response.data?.data);
|
|
});
|
|
} else if (result == ResultType.reauth) {
|
|
const passwordInput = await getReauthText({
|
|
label: t`Password`,
|
|
name: 'password',
|
|
description: t`Enter your password`
|
|
});
|
|
|
|
authApi(apiUrl(ApiEndpoints.auth_reauthenticate), undefined, 'post', {
|
|
password: passwordInput
|
|
})
|
|
.then((response) => {
|
|
setAuthContext(response.data?.data);
|
|
action();
|
|
})
|
|
.catch((err) => {
|
|
setAuthContext(err.response.data?.data);
|
|
});
|
|
}
|
|
}
|
|
|
|
export const parseDate = (date: number) =>
|
|
date == null ? 'Never' : new Date(date * 1000).toLocaleString();
|