Cleanup key UI when logging in
This commit is contained in:
parent
9032699deb
commit
d9d9b1748f
13 changed files with 280 additions and 337 deletions
76
resources/scripts/api/account/security-keys.ts
Normal file
76
resources/scripts/api/account/security-keys.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
|
||||
import { useStoreState } from '@/state/hooks';
|
||||
import http, { FractalResponseList } from '@/api/http';
|
||||
import Transformers from '@transformers';
|
||||
import { SecurityKey } from '@models';
|
||||
import { AxiosError } from 'axios';
|
||||
import { base64Decode, bufferDecode, bufferEncode, decodeSecurityKeyCredentials } from '@/helpers';
|
||||
import { LoginResponse } from '@/api/auth/login';
|
||||
|
||||
const useSecurityKeys = (config?: SWRConfiguration<SecurityKey[], AxiosError>): SWRResponse<SecurityKey[], AxiosError> => {
|
||||
const uuid = useStoreState(state => state.user.data!.uuid);
|
||||
|
||||
return useSWR<SecurityKey[], AxiosError>(
|
||||
[ 'account', uuid, 'security-keys' ],
|
||||
async (): Promise<SecurityKey[]> => {
|
||||
const { data } = await http.get('/api/client/account/security-keys');
|
||||
|
||||
return (data as FractalResponseList).data.map((datum) => Transformers.toSecurityKey(datum.attributes));
|
||||
},
|
||||
config,
|
||||
);
|
||||
};
|
||||
|
||||
const deleteSecurityKey = async (uuid: string): Promise<void> => {
|
||||
await http.delete(`/api/client/account/security-keys/${uuid}`);
|
||||
};
|
||||
|
||||
const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential): Promise<SecurityKey> => {
|
||||
const { data } = await http.post('/api/client/account/security-keys/register', {
|
||||
name,
|
||||
token_id: tokenId,
|
||||
registration: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: bufferEncode(credential.rawId),
|
||||
response: {
|
||||
attestationObject: bufferEncode((credential.response as AuthenticatorAttestationResponse).attestationObject),
|
||||
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return Transformers.toSecurityKey(data.attributes);
|
||||
};
|
||||
|
||||
const registerSecurityKey = async (name: string): Promise<SecurityKey> => {
|
||||
const { data } = await http.get('/api/client/account/security-keys/register');
|
||||
|
||||
const publicKey = data.data.credentials;
|
||||
publicKey.challenge = bufferDecode(base64Decode(publicKey.challenge));
|
||||
publicKey.user.id = bufferDecode(publicKey.user.id);
|
||||
|
||||
if (publicKey.excludeCredentials) {
|
||||
publicKey.excludeCredentials = decodeSecurityKeyCredentials(publicKey.excludeCredentials);
|
||||
}
|
||||
|
||||
const credentials = await navigator.credentials.create({ publicKey });
|
||||
|
||||
if (!credentials || credentials.type !== 'public-key') {
|
||||
throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`);
|
||||
}
|
||||
|
||||
return await registerCredentialForAccount(name, data.data.token_id, credentials);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
const authenticateSecurityKey = async (data: { confirmation_token: string; data: string }): Promise<LoginResponse> => {
|
||||
const response = await http.post('/auth/login/checkpoint/key', data);
|
||||
|
||||
return {
|
||||
complete: response.data.complete,
|
||||
intended: response.data.data?.intended || null,
|
||||
};
|
||||
};
|
||||
|
||||
export { useSecurityKeys, deleteSecurityKey, registerSecurityKey, authenticateSecurityKey };
|
|
@ -1,5 +0,0 @@
|
|||
import http from '@/api/http';
|
||||
|
||||
export default async (uuid: string): Promise<void> => {
|
||||
await http.delete(`/api/client/account/security-keys/${uuid}`);
|
||||
};
|
|
@ -1,25 +0,0 @@
|
|||
import http from '@/api/http';
|
||||
|
||||
export interface SecurityKey {
|
||||
uuid: string;
|
||||
name: string;
|
||||
type: 'public-key';
|
||||
publicKeyId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export const rawDataToSecurityKey = (data: any): SecurityKey => ({
|
||||
uuid: data.uuid,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
publicKeyId: data.public_key_id,
|
||||
createdAt: new Date(data.created_at),
|
||||
updatedAt: new Date(data.updated_at),
|
||||
});
|
||||
|
||||
export default async (): Promise<SecurityKey[]> => {
|
||||
const { data } = await http.get('/api/client/account/security-keys');
|
||||
|
||||
return (data.data || []).map((datum: any) => rawDataToSecurityKey(datum.attributes));
|
||||
};
|
|
@ -1,67 +0,0 @@
|
|||
import http from '@/api/http';
|
||||
import { rawDataToSecurityKey, SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
|
||||
|
||||
export const base64Decode = (input: string): string => {
|
||||
input = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = input.length % 4;
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
|
||||
}
|
||||
input += new Array(5 - pad).join('=');
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
export const bufferDecode = (value: string): ArrayBuffer => Uint8Array.from(window.atob(value), c => c.charCodeAt(0));
|
||||
|
||||
// @ts-ignore
|
||||
export const bufferEncode = (value: ArrayBuffer): string => window.btoa(String.fromCharCode.apply(null, new Uint8Array(value)));
|
||||
|
||||
export const decodeCredentials = (credentials: PublicKeyCredentialDescriptor[]) => {
|
||||
return credentials.map(c => {
|
||||
return {
|
||||
id: bufferDecode(base64Decode(c.id.toString())),
|
||||
type: c.type,
|
||||
transports: c.transports,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential): Promise<SecurityKey> => {
|
||||
const { data } = await http.post('/api/client/account/security-keys/register', {
|
||||
name,
|
||||
token_id: tokenId,
|
||||
registration: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: bufferEncode(credential.rawId),
|
||||
response: {
|
||||
attestationObject: bufferEncode((credential.response as AuthenticatorAttestationResponse).attestationObject),
|
||||
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return rawDataToSecurityKey(data.attributes);
|
||||
};
|
||||
|
||||
export default async (name: string): Promise<SecurityKey> => {
|
||||
const { data } = await http.get('/api/client/account/security-keys/register');
|
||||
|
||||
const publicKey = data.data.credentials;
|
||||
publicKey.challenge = bufferDecode(base64Decode(publicKey.challenge));
|
||||
publicKey.user.id = bufferDecode(publicKey.user.id);
|
||||
|
||||
if (publicKey.excludeCredentials) {
|
||||
publicKey.excludeCredentials = decodeCredentials(publicKey.excludeCredentials);
|
||||
}
|
||||
|
||||
const credentials = await navigator.credentials.create({ publicKey });
|
||||
|
||||
if (!credentials || credentials.type !== 'public-key') {
|
||||
throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`);
|
||||
}
|
||||
|
||||
return await registerCredentialForAccount(name, data.data.token_id, credentials);
|
||||
};
|
|
@ -1,22 +0,0 @@
|
|||
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
|
||||
import { useStoreState } from '@/state/hooks';
|
||||
import http, { FractalResponseList } from '@/api/http';
|
||||
import Transformers from '@transformers';
|
||||
import { SecurityKey } from '@models';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const useSecurityKeys = (config?: SWRConfiguration<SecurityKey[], AxiosError>): SWRResponse<SecurityKey[], AxiosError> => {
|
||||
const uuid = useStoreState(state => state.user.data!.uuid);
|
||||
|
||||
return useSWR<SecurityKey[], AxiosError>(
|
||||
[ 'account', uuid, 'security-keys' ],
|
||||
async (): Promise<SecurityKey[]> => {
|
||||
const { data } = await http.get('/api/client/account/security-keys');
|
||||
|
||||
return (data as FractalResponseList).data.map((datum) => Transformers.toSecurityKey(datum.attributes));
|
||||
},
|
||||
config,
|
||||
);
|
||||
};
|
||||
|
||||
export { useSecurityKeys };
|
|
@ -1,46 +0,0 @@
|
|||
import http from '@/api/http';
|
||||
import { LoginResponse } from '@/api/auth/login';
|
||||
import { base64Decode, bufferDecode, bufferEncode, decodeCredentials } from '@/api/account/webauthn/registerSecurityKey';
|
||||
|
||||
export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Promise<LoginResponse> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const publicKeyCredential = Object.assign({}, publicKey);
|
||||
|
||||
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
|
||||
if (publicKey.allowCredentials) {
|
||||
publicKeyCredential.allowCredentials = decodeCredentials(publicKey.allowCredentials);
|
||||
}
|
||||
|
||||
navigator.credentials.get({
|
||||
publicKey: publicKeyCredential,
|
||||
}).then((c) => {
|
||||
if (c === null) {
|
||||
return;
|
||||
}
|
||||
const credential = c as PublicKeyCredential;
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
|
||||
const data = {
|
||||
confirmation_token: token,
|
||||
data: JSON.stringify({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: bufferEncode(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: bufferEncode(response.authenticatorData),
|
||||
clientDataJSON: bufferEncode(response.clientDataJSON),
|
||||
signature: bufferEncode(response.signature),
|
||||
userHandle: response.userHandle ? bufferEncode(response.userHandle) : null,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
http.post('/auth/login/checkpoint/key', data).then(response => {
|
||||
return resolve({
|
||||
complete: response.data.complete,
|
||||
intended: response.data.data?.intended || undefined,
|
||||
});
|
||||
}).catch(reject);
|
||||
}).catch(reject);
|
||||
});
|
||||
};
|
|
@ -1,32 +1,23 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { Form } from 'formik';
|
||||
import { breakpoint } from '@/theme';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import tw, { styled } from 'twin.macro';
|
||||
import PterodactylLogo from '@/assets/images/pterodactyl.svg';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
${breakpoint('sm')`
|
||||
${tw`w-4/5 mx-auto`}
|
||||
`};
|
||||
|
||||
${breakpoint('md')`
|
||||
${tw`p-10`}
|
||||
`};
|
||||
|
||||
${breakpoint('lg')`
|
||||
${tw`w-3/5`}
|
||||
`};
|
||||
|
||||
${breakpoint('xl')`
|
||||
${tw`w-full`}
|
||||
${tw`sm:w-4/5 sm:mx-auto md:p-10 lg:w-3/5 xl:w-full`}
|
||||
max-width: 700px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Inner = ({ children }: { children: React.ReactNode }) => (
|
||||
interface InnerContentProps {
|
||||
children: React.ReactNode;
|
||||
sidebar?: React.ReactNode;
|
||||
}
|
||||
|
||||
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`}>
|
||||
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block 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`}/>}
|
||||
</div>
|
||||
<div css={tw`flex-1`}>
|
||||
{children}
|
||||
|
@ -59,24 +50,26 @@ const Container = ({ title, children }: { title?: string, children: React.ReactN
|
|||
|
||||
type FormContainerProps = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
||||
title?: string;
|
||||
sidebar?: React.ReactNode;
|
||||
}
|
||||
|
||||
const FormContainer = forwardRef<HTMLFormElement, FormContainerProps>(({ title, ...props }, ref) => (
|
||||
const FormContainer = forwardRef<HTMLFormElement, FormContainerProps>(({ title, sidebar, ...props }, ref) => (
|
||||
<Container title={title}>
|
||||
<Form {...props} ref={ref}>
|
||||
<Inner>{props.children}</Inner>
|
||||
<InnerContainer sidebar={sidebar}>{props.children}</InnerContainer>
|
||||
</Form>
|
||||
</Container>
|
||||
));
|
||||
|
||||
type DivContainerProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
title?: string;
|
||||
sidebar?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DivContainer = ({ title, ...props }: DivContainerProps) => (
|
||||
export const DivContainer = ({ title, sidebar, ...props }: DivContainerProps) => (
|
||||
<Container title={title}>
|
||||
<div {...props}>
|
||||
<Inner>{props.children}</Inner>
|
||||
<InnerContainer sidebar={sidebar}>{props.children}</InnerContainer>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import tw from 'twin.macro';
|
||||
import webauthnChallenge from '@/api/account/webauthn/webauthnChallenge';
|
||||
import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import Button from '@/components/elements/Button';
|
||||
import { authenticateSecurityKey } from '@/api/account/security-keys';
|
||||
import { base64Decode, bufferDecode, bufferEncode, decodeSecurityKeyCredentials } from '@/helpers';
|
||||
import { FingerPrintIcon } from '@heroicons/react/outline';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
|
||||
interface LocationParams {
|
||||
token: string;
|
||||
|
@ -14,99 +16,114 @@ interface LocationParams {
|
|||
hasTotp: boolean;
|
||||
}
|
||||
|
||||
const LoginKeyCheckpointContainer = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation<LocationParams>();
|
||||
|
||||
const { clearAndAddHttpError } = useFlash();
|
||||
|
||||
const [ challenging, setChallenging ] = useState(false);
|
||||
|
||||
const switchToCode = () => {
|
||||
history.replace('/auth/login/checkpoint', { ...location.state, recovery: false });
|
||||
};
|
||||
|
||||
const switchToRecovery = () => {
|
||||
history.replace('/auth/login/checkpoint', { ...location.state, recovery: true });
|
||||
};
|
||||
|
||||
const doChallenge = () => {
|
||||
setChallenging(true);
|
||||
|
||||
webauthnChallenge(location.state.token, location.state.publicKey)
|
||||
.then(response => {
|
||||
if (!response.complete) {
|
||||
return;
|
||||
interface Credential extends PublicKeyCredential {
|
||||
response: AuthenticatorAssertionResponse;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
window.location = response.intended || '/';
|
||||
})
|
||||
.catch(error => {
|
||||
clearAndAddHttpError({ error });
|
||||
console.error(error);
|
||||
setChallenging(false);
|
||||
});
|
||||
};
|
||||
const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<Credential> => {
|
||||
const publicKeyCredential = Object.assign({}, publicKey);
|
||||
|
||||
useEffect(() => {
|
||||
doChallenge();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LoginFormContainer title={'Key Checkpoint'} css={tw`w-full flex`}>
|
||||
<div css={tw`flex flex-col items-center justify-center w-full md:h-full md:pt-4`}>
|
||||
<h3 css={tw`font-sans text-2xl text-center text-neutral-500 font-normal`}>Attempting challenge...</h3>
|
||||
|
||||
<div css={tw`mt-6 md:mt-auto`}>
|
||||
{challenging ?
|
||||
<Spinner size={'large'} isBlue/>
|
||||
:
|
||||
<Button onClick={() => doChallenge()}>
|
||||
Retry
|
||||
</Button>
|
||||
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
|
||||
if (publicKey.allowCredentials) {
|
||||
publicKeyCredential.allowCredentials = decodeSecurityKeyCredentials(publicKey.allowCredentials);
|
||||
}
|
||||
</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={() => switchToCode()}
|
||||
>
|
||||
Use two-factor token
|
||||
</a>
|
||||
</div>
|
||||
<div css={tw`ml-4`}>
|
||||
<a
|
||||
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700 text-center cursor-pointer`}
|
||||
onClick={() => switchToRecovery()}
|
||||
>
|
||||
I've Lost My Device
|
||||
</a>
|
||||
</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 credential = await navigator.credentials.get({ publicKey: publicKeyCredential }) as Credential | null;
|
||||
if (!credential) return Promise.reject(new Error('No credentials provided for challenge.'));
|
||||
|
||||
return credential;
|
||||
};
|
||||
|
||||
export default () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation<LocationParams>();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ redirecting, setRedirecting ] = useState(false);
|
||||
|
||||
const triggerChallengePrompt = () => {
|
||||
clearFlashes();
|
||||
|
||||
challenge(location.state.publicKey)
|
||||
.then((credential) => {
|
||||
setRedirecting(true);
|
||||
|
||||
return authenticateSecurityKey({
|
||||
confirmation_token: location.state.token,
|
||||
data: JSON.stringify({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: bufferEncode(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: bufferEncode(credential.response.authenticatorData),
|
||||
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
||||
signature: bufferEncode(credential.response.signature),
|
||||
userHandle: credential.response.userHandle ? bufferEncode(credential.response.userHandle) : null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
})
|
||||
.then(({ complete, intended }) => {
|
||||
if (!complete) return;
|
||||
|
||||
// @ts-ignore
|
||||
window.location = intended || '/';
|
||||
})
|
||||
.catch(error => {
|
||||
setRedirecting(false);
|
||||
if (error instanceof DOMException) {
|
||||
// User canceled the operation.
|
||||
if (error.code === 20) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
clearAndAddHttpError({ error });
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.state?.token) {
|
||||
history.replace('/auth/login');
|
||||
return null;
|
||||
} else {
|
||||
triggerChallengePrompt();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <LoginKeyCheckpointContainer/>;
|
||||
return (
|
||||
<LoginFormContainer
|
||||
title={'Two-Factor Authentication'}
|
||||
css={tw`w-full flex`}
|
||||
sidebar={<FingerPrintIcon css={tw`h-24 w-24 mx-auto animate-pulse`}/>}
|
||||
>
|
||||
<SpinnerOverlay size={'base'} visible={redirecting}/>
|
||||
<div css={tw`flex flex-col md:h-full`}>
|
||||
<div css={tw`flex-1`}>
|
||||
<p css={tw`text-neutral-700`}>Insert your security key and touch it.</p>
|
||||
<p css={tw`text-neutral-700 mt-2`}>
|
||||
If your security key does not respond,
|
||||
<a
|
||||
href={'#'}
|
||||
css={tw`text-primary-500 font-medium hover:underline`}
|
||||
onClick={triggerChallengePrompt}
|
||||
>
|
||||
click here
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
css={tw`block mt-12 mb-6`}
|
||||
to={{ pathname: '/auth/login/checkpoint' }}
|
||||
>
|
||||
<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 } }}
|
||||
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'}
|
||||
</Link>
|
||||
</div>
|
||||
</LoginFormContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { SecurityKey } from '@models';
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { registerSecurityKey } from '@/api/account/security-keys';
|
||||
import { object, string } from 'yup';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import Field from '@/components/elements/Field';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import React from 'react';
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) => void }) => {
|
||||
const { clearAndAddHttpError } = useFlashKey('security_keys');
|
||||
|
||||
const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
|
||||
registerSecurityKey(name)
|
||||
.then(key => {
|
||||
resetForm();
|
||||
onKeyAdded(key);
|
||||
})
|
||||
.catch(clearAndAddHttpError)
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '' }}
|
||||
validationSchema={object().shape({
|
||||
name: string().required(),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<SpinnerOverlay visible={isSubmitting}/>
|
||||
<Field
|
||||
type={'text'}
|
||||
id={'name'}
|
||||
name={'name'}
|
||||
label={'Name'}
|
||||
description={'A descriptive name for this security key.'}
|
||||
/>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button>Create</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
|
@ -1,72 +1,17 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import tw from 'twin.macro';
|
||||
import { object, string } from 'yup';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFingerprint, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import deleteWebauthnKey from '@/api/account/webauthn/deleteSecurityKey';
|
||||
import { SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
|
||||
import registerSecurityKey from '@/api/account/webauthn/registerSecurityKey';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Button from '@/components/elements/Button';
|
||||
import ContentBox from '@/components/elements/ContentBox';
|
||||
import Field from '@/components/elements/Field';
|
||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import useFlash, { useFlashKey } from '@/plugins/useFlash';
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
import { useSecurityKeys } from '@/api/account/webauthn/security-keys';
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) => void }) => {
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
|
||||
const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
|
||||
clearFlashes('security_keys');
|
||||
|
||||
registerSecurityKey(name)
|
||||
.then(key => {
|
||||
resetForm();
|
||||
onKeyAdded(key);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
clearAndAddHttpError({ key: 'security_keys', error: err });
|
||||
})
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '' }}
|
||||
validationSchema={object().shape({
|
||||
name: string().required(),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<SpinnerOverlay visible={isSubmitting}/>
|
||||
<Field
|
||||
type={'text'}
|
||||
id={'name'}
|
||||
name={'name'}
|
||||
label={'Name'}
|
||||
description={'A descriptive name for this security key.'}
|
||||
/>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button>Create</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
import { useSecurityKeys, deleteSecurityKey } from '@/api/account/security-keys';
|
||||
import AddSecurityKeyForm from '@/components/dashboard/security/AddSecurityKeyForm';
|
||||
|
||||
export default () => {
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlashKey('security_keys');
|
||||
|
@ -83,7 +28,7 @@ export default () => {
|
|||
|
||||
if (!uuid) return;
|
||||
|
||||
deleteWebauthnKey(uuid).catch(error => {
|
||||
deleteSecurityKey(uuid).catch(error => {
|
||||
clearAndAddHttpError(error);
|
||||
mutate();
|
||||
});
|
|
@ -82,8 +82,8 @@ const ButtonStyle = styled.button<Props>`
|
|||
|
||||
type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props;
|
||||
|
||||
const Button: React.FC<ComponentProps> = ({ children, isLoading, disabled, ...props }) => (
|
||||
<ButtonStyle {...props} isLoading={isLoading} disabled={isLoading || disabled}>
|
||||
const Button: React.FC<ComponentProps> = ({ children, isLoading, disabled, className, ...props }) => (
|
||||
<ButtonStyle {...props} isLoading={isLoading} disabled={isLoading || disabled} className={className}>
|
||||
{isLoading &&
|
||||
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
|
||||
<Spinner size={'small'}/>
|
||||
|
|
|
@ -67,3 +67,26 @@ export function hashToPath (hash: string): string {
|
|||
export function formatIp (ip: string): string {
|
||||
return /([a-f0-9:]+:+)+[a-f0-9]+/.test(ip) ? `[${ip}]` : ip;
|
||||
}
|
||||
|
||||
export const base64Decode = (input: string): string => {
|
||||
input = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = input.length % 4;
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
|
||||
}
|
||||
input += new Array(5 - pad).join('=');
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
export const bufferDecode = (value: string): ArrayBuffer => Uint8Array.from(window.atob(value), c => c.charCodeAt(0));
|
||||
|
||||
// @ts-ignore
|
||||
export const bufferEncode = (value: ArrayBuffer): string => window.btoa(String.fromCharCode.apply(null, new Uint8Array(value)));
|
||||
|
||||
export const decodeSecurityKeyCredentials = (credentials: PublicKeyCredentialDescriptor[]) => credentials.map(c => ({
|
||||
id: bufferDecode(base64Decode(c.id.toString())),
|
||||
type: c.type,
|
||||
transports: c.transports,
|
||||
}));
|
||||
|
|
|
@ -5,7 +5,7 @@ import NavigationBar from '@/components/NavigationBar';
|
|||
import DashboardContainer from '@/components/dashboard/DashboardContainer';
|
||||
import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer';
|
||||
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
|
||||
import SecurityKeyContainer from '@/components/dashboard/SecurityKeyContainer';
|
||||
import SecurityKeyContainer from '@/components/dashboard/security/SecurityKeyContainer';
|
||||
import SSHKeyContainer from '@/components/dashboard/SSHKeyContainer';
|
||||
import { NotFound } from '@/components/elements/ScreenBlock';
|
||||
import SubNavigation from '@/components/elements/SubNavigation';
|
||||
|
|
Loading…
Reference in a new issue