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 React, { useEffect, useState } from 'react';
|
||||||
import { StaticContext, useLocation } from 'react-router';
|
import { StaticContext } from 'react-router';
|
||||||
import { Link, RouteComponentProps, useHistory } from 'react-router-dom';
|
import { RouteComponentProps } from 'react-router-dom';
|
||||||
import loginCheckpoint from '@/api/auth/loginCheckpoint';
|
import loginCheckpoint from '@/api/auth/loginCheckpoint';
|
||||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||||
import { ActionCreator } from 'easy-peasy';
|
import { Formik, FormikHelpers } from 'formik';
|
||||||
import { useFormikContext, withFormik } from 'formik';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import { FlashStore } from '@/state/flashes';
|
|
||||||
import Field from '@/components/elements/Field';
|
import Field from '@/components/elements/Field';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
|
@ -16,130 +14,80 @@ interface Values {
|
||||||
recoveryCode: '',
|
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 & {
|
export default ({ history, location }: OwnProps) => {
|
||||||
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
}
|
|
||||||
|
|
||||||
const LoginCheckpointContainer = () => {
|
const onSubmit = ({ code, recoveryCode }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
const history = useHistory();
|
clearFlashes();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
|
|
||||||
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
|
|
||||||
|
|
||||||
const switchToSecurityKey = () => {
|
|
||||||
history.replace('/auth/login/key', { ...location.state });
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
<Field
|
|
||||||
light
|
|
||||||
name={isMissingDevice ? 'recoveryCode' : 'code'}
|
|
||||||
title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
|
|
||||||
description={
|
|
||||||
isMissingDevice
|
|
||||||
? 'Enter one of the recovery codes generated when you setup 2-Factor authentication on this account in order to continue.'
|
|
||||||
: 'Enter the two-factor token generated by your device.'
|
|
||||||
}
|
|
||||||
type={'text'}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div css={tw`mt-6 md:mt-auto`}>
|
|
||||||
<Button
|
|
||||||
size={'large'}
|
|
||||||
type={'submit'}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
isLoading={isSubmitting}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</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
|
|
||||||
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`}
|
|
||||||
>
|
|
||||||
{!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>
|
|
||||||
</div>
|
|
||||||
</LoginFormContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EnhancedForm = withFormik<Props, Values>({
|
|
||||||
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
|
|
||||||
loginCheckpoint(location.state?.token || '', code, recoveryCode)
|
loginCheckpoint(location.state?.token || '', code, recoveryCode)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.complete) {
|
if (response.complete) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.location = response.intended || '/';
|
window.location = response.intended || '/';
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(false);
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => clearAndAddHttpError({ error }))
|
||||||
console.error(error);
|
.then(() => setSubmitting(false));
|
||||||
setSubmitting(false);
|
};
|
||||||
clearAndAddHttpError({ error });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
mapPropsToValues: () => ({
|
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
|
||||||
code: '',
|
|
||||||
recoveryCode: '',
|
|
||||||
}),
|
|
||||||
})(LoginCheckpointContainer);
|
|
||||||
|
|
||||||
export default ({ history, location, ...props }: OwnProps) => {
|
useEffect(() => {
|
||||||
const { clearAndAddHttpError } = useFlash();
|
if (!location.state?.token) {
|
||||||
|
history.replace('/auth/login');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!location.state?.token) {
|
useEffect(() => {
|
||||||
history.replace('/auth/login');
|
setIsMissingDevice(location.state?.recovery || false);
|
||||||
|
}, [ location.state ]);
|
||||||
|
|
||||||
return null;
|
return (
|
||||||
}
|
<Formik initialValues={{ code: '', recoveryCode: '' }} onSubmit={onSubmit}>
|
||||||
|
{({ isSubmitting, setFieldValue }) => (
|
||||||
return <EnhancedForm
|
<LoginFormContainer title={'Two-Factor Authentication'} css={tw`w-full flex h-full`}>
|
||||||
clearAndAddHttpError={clearAndAddHttpError}
|
<div css={tw`flex flex-col`}>
|
||||||
history={history}
|
<div css={tw`flex-1`}>
|
||||||
location={location}
|
<Field
|
||||||
{...props}
|
light
|
||||||
/>;
|
name={isMissingDevice ? 'recoveryCode' : '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.'
|
||||||
|
: 'Enter the two-factor token generated by your device.'
|
||||||
|
}
|
||||||
|
type={'text'}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
css={tw`mt-12 w-full block`}
|
||||||
|
type={'submit'}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => {
|
||||||
|
setFieldValue('code', '');
|
||||||
|
setFieldValue('recoveryCode', '');
|
||||||
|
setIsMissingDevice(s => !s);
|
||||||
|
}}
|
||||||
|
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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</LoginFormContainer>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { Form } from 'formik';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import tw, { styled } from 'twin.macro';
|
import tw, { styled } from 'twin.macro';
|
||||||
import PterodactylLogo from '@/assets/images/pterodactyl.svg';
|
import PterodactylLogo from '@/assets/images/pterodactyl.svg';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
const Wrapper = styled.div`
|
||||||
${tw`sm:w-4/5 sm:mx-auto md:p-10 lg:w-3/5 xl:w-full`}
|
${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) => (
|
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`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`}>
|
<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>
|
||||||
<div css={tw`flex-1`}>
|
<div css={tw`flex-1`}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer';
|
import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
@ -20,7 +20,7 @@ interface Credential extends PublicKeyCredential {
|
||||||
response: AuthenticatorAssertionResponse;
|
response: AuthenticatorAssertionResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<Credential> => {
|
const challenge = async (publicKey: PublicKeyCredentialRequestOptions, signal?: AbortSignal): Promise<Credential> => {
|
||||||
const publicKeyCredential = Object.assign({}, publicKey);
|
const publicKeyCredential = Object.assign({}, publicKey);
|
||||||
|
|
||||||
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
|
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
|
||||||
|
@ -28,7 +28,7 @@ const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<
|
||||||
publicKeyCredential.allowCredentials = decodeSecurityKeyCredentials(publicKey.allowCredentials);
|
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.'));
|
if (!credential) return Promise.reject(new Error('No credentials provided for challenge.'));
|
||||||
|
|
||||||
return credential;
|
return credential;
|
||||||
|
@ -37,13 +37,14 @@ const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<
|
||||||
export default () => {
|
export default () => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation<LocationParams>();
|
const location = useLocation<LocationParams>();
|
||||||
|
const controller = useRef(new AbortController());
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const [ redirecting, setRedirecting ] = useState(false);
|
const [ redirecting, setRedirecting ] = useState(false);
|
||||||
|
|
||||||
const triggerChallengePrompt = () => {
|
const triggerChallengePrompt = () => {
|
||||||
clearFlashes();
|
clearFlashes();
|
||||||
|
|
||||||
challenge(location.state.publicKey)
|
challenge(location.state.publicKey, controller.current.signal)
|
||||||
.then((credential) => {
|
.then((credential) => {
|
||||||
setRedirecting(true);
|
setRedirecting(true);
|
||||||
|
|
||||||
|
@ -80,6 +81,12 @@ export default () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
controller.current.abort();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!location.state?.token) {
|
if (!location.state?.token) {
|
||||||
history.replace('/auth/login');
|
history.replace('/auth/login');
|
||||||
|
@ -111,14 +118,14 @@ export default () => {
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
css={tw`block mt-12 mb-6`}
|
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`}>
|
<Button size={'small'} type={'button'} css={tw`block w-full`}>
|
||||||
Use a Different Method
|
Use a Different Method
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<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`}
|
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'}
|
{'I\'ve Lost My Device'}
|
||||||
|
|
Loading…
Add table
Reference in a new issue