Login checkpoint cleanup, hide prompt when leaving screen
This commit is contained in:
parent
d9d9b1748f
commit
d6cd0c6230
3 changed files with 81 additions and 125 deletions
|
@ -1,12 +1,10 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { StaticContext, useLocation } from 'react-router';
|
||||
import { Link, RouteComponentProps, useHistory } from 'react-router-dom';
|
||||
import { StaticContext } from 'react-router';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import loginCheckpoint from '@/api/auth/loginCheckpoint';
|
||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||
import { ActionCreator } from 'easy-peasy';
|
||||
import { useFormikContext, withFormik } from 'formik';
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { FlashStore } from '@/state/flashes';
|
||||
import Field from '@/components/elements/Field';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
|
@ -16,37 +14,49 @@ interface Values {
|
|||
recoveryCode: '',
|
||||
}
|
||||
|
||||
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>
|
||||
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string, recovery?: boolean }>
|
||||
|
||||
type Props = OwnProps & {
|
||||
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
|
||||
}
|
||||
export default ({ history, location }: OwnProps) => {
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
|
||||
const LoginCheckpointContainer = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
|
||||
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
|
||||
|
||||
const switchToSecurityKey = () => {
|
||||
history.replace('/auth/login/key', { ...location.state });
|
||||
const onSubmit = ({ code, recoveryCode }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes();
|
||||
loginCheckpoint(location.state?.token || '', code, recoveryCode)
|
||||
.then(response => {
|
||||
if (response.complete) {
|
||||
// @ts-ignore
|
||||
window.location = response.intended || '/';
|
||||
}
|
||||
})
|
||||
.catch(error => clearAndAddHttpError({ error }))
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.state?.token) {
|
||||
history.replace('/auth/login');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValue('code', '');
|
||||
setFieldValue('recoveryCode', '');
|
||||
setIsMissingDevice(location.state?.recovery || false);
|
||||
}, [ location.state ]);
|
||||
|
||||
return (
|
||||
<LoginFormContainer title={'Device Checkpoint'} css={tw`w-full flex`}>
|
||||
<div css={tw`flex flex-col items-center justify-center w-full md:h-full md:pt-4`}>
|
||||
<div>
|
||||
<Formik initialValues={{ code: '', recoveryCode: '' }} onSubmit={onSubmit}>
|
||||
{({ isSubmitting, setFieldValue }) => (
|
||||
<LoginFormContainer title={'Two-Factor Authentication'} css={tw`w-full flex h-full`}>
|
||||
<div css={tw`flex flex-col`}>
|
||||
<div css={tw`flex-1`}>
|
||||
<Field
|
||||
light
|
||||
name={isMissingDevice ? 'recoveryCode' : 'code'}
|
||||
title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
|
||||
label={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
|
||||
inputMode={isMissingDevice ? undefined : 'numeric'}
|
||||
pattern={isMissingDevice ? undefined : '[0-9]*'}
|
||||
autoComplete={isMissingDevice ? undefined : 'one-time-code'}
|
||||
description={
|
||||
isMissingDevice
|
||||
? 'Enter one of the recovery codes generated when you setup 2-Factor authentication on this account in order to continue.'
|
||||
|
@ -56,90 +66,28 @@ const LoginCheckpointContainer = () => {
|
|||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6 md:mt-auto`}>
|
||||
<Button
|
||||
size={'large'}
|
||||
css={tw`mt-12 w-full block`}
|
||||
type={'submit'}
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Continue
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div css={tw`flex flex-row text-center mt-6 md:mt-auto`}>
|
||||
<div css={tw`mr-4`}>
|
||||
<a
|
||||
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700 text-center cursor-pointer`}
|
||||
onClick={() => switchToSecurityKey()}
|
||||
>
|
||||
Use security key
|
||||
</a>
|
||||
</div>
|
||||
<div css={tw`ml-4`}>
|
||||
<span
|
||||
<button
|
||||
type={'button'}
|
||||
onClick={() => {
|
||||
setFieldValue('code', '');
|
||||
setFieldValue('recoveryCode', '');
|
||||
setIsMissingDevice(s => !s);
|
||||
}}
|
||||
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
|
||||
css={tw`mt-4 p-2 w-full cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
|
||||
>
|
||||
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`mt-6 text-center`}>
|
||||
<Link
|
||||
to={'/auth/login'}
|
||||
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
|
||||
>
|
||||
Return to Login
|
||||
</Link>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</LoginFormContainer>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
const EnhancedForm = withFormik<Props, Values>({
|
||||
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
|
||||
loginCheckpoint(location.state?.token || '', code, recoveryCode)
|
||||
.then(response => {
|
||||
if (response.complete) {
|
||||
// @ts-ignore
|
||||
window.location = response.intended || '/';
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
clearAndAddHttpError({ error });
|
||||
});
|
||||
},
|
||||
|
||||
mapPropsToValues: () => ({
|
||||
code: '',
|
||||
recoveryCode: '',
|
||||
}),
|
||||
})(LoginCheckpointContainer);
|
||||
|
||||
export default ({ history, location, ...props }: OwnProps) => {
|
||||
const { clearAndAddHttpError } = useFlash();
|
||||
|
||||
if (!location.state?.token) {
|
||||
history.replace('/auth/login');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EnhancedForm
|
||||
clearAndAddHttpError={clearAndAddHttpError}
|
||||
history={history}
|
||||
location={location}
|
||||
{...props}
|
||||
/>;
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@ import { Form } from 'formik';
|
|||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import tw, { styled } from 'twin.macro';
|
||||
import PterodactylLogo from '@/assets/images/pterodactyl.svg';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
${tw`sm:w-4/5 sm:mx-auto md:p-10 lg:w-3/5 xl:w-full`}
|
||||
|
@ -17,7 +18,7 @@ interface InnerContentProps {
|
|||
const InnerContainer = ({ children, sidebar }: InnerContentProps) => (
|
||||
<div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}>
|
||||
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center w-48 md:w-64 mx-auto`}>
|
||||
{sidebar || <img src={PterodactylLogo} css={tw`block w-full`}/>}
|
||||
{sidebar || <Link to={'/auth/login'}><img src={PterodactylLogo} css={tw`block w-full`}/></Link>}
|
||||
</div>
|
||||
<div css={tw`flex-1`}>
|
||||
{children}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import tw from 'twin.macro';
|
||||
import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
@ -20,7 +20,7 @@ interface Credential extends PublicKeyCredential {
|
|||
response: AuthenticatorAssertionResponse;
|
||||
}
|
||||
|
||||
const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<Credential> => {
|
||||
const challenge = async (publicKey: PublicKeyCredentialRequestOptions, signal?: AbortSignal): Promise<Credential> => {
|
||||
const publicKeyCredential = Object.assign({}, publicKey);
|
||||
|
||||
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
|
||||
|
@ -28,7 +28,7 @@ const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<
|
|||
publicKeyCredential.allowCredentials = decodeSecurityKeyCredentials(publicKey.allowCredentials);
|
||||
}
|
||||
|
||||
const credential = await navigator.credentials.get({ publicKey: publicKeyCredential }) as Credential | null;
|
||||
const credential = await navigator.credentials.get({ signal, publicKey: publicKeyCredential }) as Credential | null;
|
||||
if (!credential) return Promise.reject(new Error('No credentials provided for challenge.'));
|
||||
|
||||
return credential;
|
||||
|
@ -37,13 +37,14 @@ const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<
|
|||
export default () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation<LocationParams>();
|
||||
const controller = useRef(new AbortController());
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ redirecting, setRedirecting ] = useState(false);
|
||||
|
||||
const triggerChallengePrompt = () => {
|
||||
clearFlashes();
|
||||
|
||||
challenge(location.state.publicKey)
|
||||
challenge(location.state.publicKey, controller.current.signal)
|
||||
.then((credential) => {
|
||||
setRedirecting(true);
|
||||
|
||||
|
@ -80,6 +81,12 @@ export default () => {
|
|||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controller.current.abort();
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.state?.token) {
|
||||
history.replace('/auth/login');
|
||||
|
@ -111,14 +118,14 @@ export default () => {
|
|||
</div>
|
||||
<Link
|
||||
css={tw`block mt-12 mb-6`}
|
||||
to={{ pathname: '/auth/login/checkpoint' }}
|
||||
to={{ pathname: '/auth/login/checkpoint', state: location.state }}
|
||||
>
|
||||
<Button size={'small'} type={'button'} css={tw`block w-full`}>
|
||||
Use a Different Method
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to={{ pathname: '/auth/login/checkpoint', state: { ...location.state, recovery: true } }}
|
||||
to={{ pathname: '/auth/login/checkpoint', state: { token: location.state.token, recovery: true } }}
|
||||
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700 text-center cursor-pointer`}
|
||||
>
|
||||
{'I\'ve Lost My Device'}
|
||||
|
|
Loading…
Reference in a new issue