Use SWR for security key index

This commit is contained in:
Dane Everitt 2022-02-13 15:44:19 -05:00
parent b43e8835bb
commit 9032699deb
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
9 changed files with 135 additions and 67 deletions

View file

@ -61,7 +61,7 @@ class StoreSecurityKeyService
// void — so we need to just query the database immediately after this to pull the information // void — so we need to just query the database immediately after this to pull the information
// we just stored to return to the caller. // we just stored to return to the caller.
/** @var \Pterodactyl\Models\SecurityKey $key */ /** @var \Pterodactyl\Models\SecurityKey $key */
$key = $user->securityKeys()->forceCreate([ $key = $user->securityKeys()->make()->forceFill([
'uuid' => Uuid::uuid4(), 'uuid' => Uuid::uuid4(),
'name' => $this->keyName ?? 'Security Key (' . Str::random() . ')', 'name' => $this->keyName ?? 'Security Key (' . Str::random() . ')',
'public_key_id' => $source->getPublicKeyCredentialId(), 'public_key_id' => $source->getPublicKeyCredentialId(),
@ -76,6 +76,8 @@ class StoreSecurityKeyService
'other_ui' => $source->getOtherUI(), 'other_ui' => $source->getOtherUI(),
]); ]);
$key->saveOrFail();
return $key; return $key;
} }
} }

View file

@ -0,0 +1,22 @@
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 };

11
resources/scripts/api/types/models.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Model {}
interface SecurityKey extends Model {
uuid: string;
name: string;
type: 'public-key';
publicKeyId: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,14 @@
import * as Models from '@models';
export default class Transformers {
static toSecurityKey (data: Record<string, any>): Models.SecurityKey {
return {
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),
};
}
}

View file

@ -6,7 +6,7 @@ import { object, string } from 'yup';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFingerprint, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faFingerprint, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import deleteWebauthnKey from '@/api/account/webauthn/deleteSecurityKey'; import deleteWebauthnKey from '@/api/account/webauthn/deleteSecurityKey';
import getWebauthnKeys, { SecurityKey } from '@/api/account/webauthn/getSecurityKeys'; import { SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
import registerSecurityKey from '@/api/account/webauthn/registerSecurityKey'; import registerSecurityKey from '@/api/account/webauthn/registerSecurityKey';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
@ -15,8 +15,9 @@ import Field from '@/components/elements/Field';
import GreyRowBox from '@/components/elements/GreyRowBox'; import GreyRowBox from '@/components/elements/GreyRowBox';
import PageContentBlock from '@/components/elements/PageContentBlock'; import PageContentBlock from '@/components/elements/PageContentBlock';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash'; import useFlash, { useFlashKey } from '@/plugins/useFlash';
import ConfirmationModal from '@/components/elements/ConfirmationModal'; import ConfirmationModal from '@/components/elements/ConfirmationModal';
import { useSecurityKeys } from '@/api/account/webauthn/security-keys';
interface Values { interface Values {
name: string; name: string;
@ -68,91 +69,77 @@ const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) =>
}; };
export default () => { export default () => {
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlashKey('security_keys');
const [ keys, setKeys ] = useState<SecurityKey[]>([]);
const [ loading, setLoading ] = useState(true);
const [ deleteId, setDeleteId ] = useState<string | null>(null); const [ deleteId, setDeleteId ] = useState<string | null>(null);
const { data, mutate, error } = useSecurityKeys({ revalidateOnFocus: false });
const doDeletion = (uuid: string | null) => { const doDeletion = () => {
if (uuid === null) { const uuid = deleteId;
return;
}
clearFlashes('security_keys'); setDeleteId(null);
clearFlashes();
mutate(keys => !keys ? undefined : keys.filter(key => key.uuid !== deleteId));
deleteWebauthnKey(uuid) if (!uuid) return;
.then(() => setKeys(s => ([
...(s || []).filter(key => key.uuid !== uuid), deleteWebauthnKey(uuid).catch(error => {
]))) clearAndAddHttpError(error);
.catch(error => { mutate();
console.error(error); });
clearAndAddHttpError({ key: 'security_keys', error });
});
}; };
useEffect(() => { useEffect(() => {
clearFlashes('security_keys'); clearAndAddHttpError(error);
}, [ error ]);
getWebauthnKeys()
.then(keys => setKeys(keys))
.then(() => setLoading(false))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'security_keys', error });
});
}, []);
return ( return (
<PageContentBlock title={'Security Keys'}> <PageContentBlock title={'Security Keys'}>
<FlashMessageRender byKey={'security_keys'}/> <FlashMessageRender byKey={'security_keys'}/>
<div css={tw`md:flex flex-nowrap my-10`}> <div css={tw`md:flex flex-nowrap my-10`}>
<ContentBox title={'Add Security Key'} css={tw`flex-1 md:mr-8`}> <ContentBox title={'Add Security Key'} css={tw`flex-1 md:mr-8`}>
<AddSecurityKeyForm onKeyAdded={key => setKeys(s => ([ ...s!, key ]))}/> <AddSecurityKeyForm onKeyAdded={key => mutate((keys) => (keys || []).concat(key))}/>
</ContentBox> </ContentBox>
<ContentBox title={'Security Keys'} css={tw`flex-none w-full mt-8 md:mt-0 md:w-1/2`}> <ContentBox title={'Security Keys'} css={tw`flex-none w-full mt-8 md:mt-0 md:w-1/2`}>
<SpinnerOverlay visible={loading}/>
<ConfirmationModal <ConfirmationModal
visible={!!deleteId} visible={!!deleteId}
title={'Confirm key deletion'} title={'Confirm key deletion'}
buttonText={'Yes, delete key'} buttonText={'Yes, Delete Key'}
onConfirmed={() => { onConfirmed={doDeletion}
doDeletion(deleteId);
setDeleteId(null);
}}
onModalDismissed={() => setDeleteId(null)} onModalDismissed={() => setDeleteId(null)}
> >
Are you sure you wish to delete this security key? Are you sure you wish to delete this security key?
You will no longer be able to authenticate using this key. You will no longer be able to authenticate using this key.
</ConfirmationModal> </ConfirmationModal>
{keys.length === 0 ? {!data ?
!loading ? <SpinnerOverlay visible/>
:
data?.length === 0 ?
<p css={tw`text-center text-sm`}> <p css={tw`text-center text-sm`}>
No security keys have been configured for this account. No security keys have been configured for this account.
</p> </p>
: null :
: data.map((key, index) => (
keys.map((key, index) => ( <GreyRowBox
<GreyRowBox key={index}
key={index} css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}
css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]} >
> <FontAwesomeIcon icon={faFingerprint} css={tw`text-neutral-300`}/>
<FontAwesomeIcon icon={faFingerprint} css={tw`text-neutral-300`}/> <div css={tw`ml-4 flex-1 overflow-hidden`}>
<div css={tw`ml-4 flex-1 overflow-hidden`}> <p css={tw`text-sm break-words`}>{key.name}</p>
<p css={tw`text-sm break-words`}>{key.name}</p> <p css={tw`text-2xs text-neutral-300 uppercase`}>
<p css={tw`text-2xs text-neutral-300 uppercase`}> Created at:&nbsp;
Created at:&nbsp; {key.createdAt ? format(key.createdAt, 'MMM do, yyyy HH:mm') : 'Never'}
{key.createdAt ? format(key.createdAt, 'MMM do, yyyy HH:mm') : 'Never'} </p>
</p> </div>
</div> <button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteId(key.uuid)}>
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteId(key.uuid)}> <FontAwesomeIcon
<FontAwesomeIcon icon={faTrashAlt}
icon={faTrashAlt} css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`} />
/> </button>
</button> </GreyRowBox>
</GreyRowBox> ))
))
} }
</ContentBox> </ContentBox>
</div> </div>

View file

@ -1,9 +1,24 @@
import { Actions, useStoreActions } from 'easy-peasy'; import { Actions } from 'easy-peasy';
import { FlashStore } from '@/state/flashes'; import { FlashStore } from '@/state/flashes';
import { ApplicationStore } from '@/state'; import { useStoreActions } from '@/state/hooks';
const useFlash = (): Actions<FlashStore> => { const useFlash = (): Actions<FlashStore> => {
return useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); return useStoreActions(actions => actions.flashes);
}; };
interface KeyedFlashStore {
clearFlashes: () => void;
clearAndAddHttpError: (error?: Error | string | null) => void;
}
const useFlashKey = (key: string): KeyedFlashStore => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
return {
clearFlashes: () => clearFlashes(key),
clearAndAddHttpError: (error) => clearAndAddHttpError({ key, error }),
};
};
export { useFlashKey };
export default useFlash; export default useFlash;

View file

@ -6,7 +6,7 @@ export interface FlashStore {
items: FlashMessage[]; items: FlashMessage[];
addFlash: Action<FlashStore, FlashMessage>; addFlash: Action<FlashStore, FlashMessage>;
addError: Action<FlashStore, { message: string; key?: string }>; addError: Action<FlashStore, { message: string; key?: string }>;
clearAndAddHttpError: Action<FlashStore, { error: any; key?: string }>; clearAndAddHttpError: Action<FlashStore, { error?: Error | any | null; key?: string }>;
clearFlashes: Action<FlashStore, string | void>; clearFlashes: Action<FlashStore, string | void>;
} }
@ -30,9 +30,18 @@ const flashes: FlashStore = {
}), }),
clearAndAddHttpError: action((state, payload) => { clearAndAddHttpError: action((state, payload) => {
console.error(payload.error); if (!payload.error) {
state.items = [];
} else {
console.error(payload.error);
state.items = [ { type: 'error', title: 'Error', key: payload.key, message: httpErrorToHuman(payload.error) } ]; state.items = [ {
type: 'error',
title: 'Error',
key: payload.key,
message: httpErrorToHuman(payload.error),
} ];
}
}), }),
clearFlashes: action((state, payload) => { clearFlashes: action((state, payload) => {

View file

@ -25,6 +25,12 @@
], ],
"@feature/*": [ "@feature/*": [
"./resources/scripts/components/server/features/*" "./resources/scripts/components/server/features/*"
],
"@models": [
"./resources/scripts/api/types/models.d.ts"
],
"@transformers": [
"./resources/scripts/api/types/transformers.ts"
] ]
}, },
"plugins": [ "plugins": [

View file

@ -60,6 +60,8 @@ module.exports = {
extensions: ['.ts', '.tsx', '.js', '.json'], extensions: ['.ts', '.tsx', '.js', '.json'],
alias: { alias: {
'@': path.join(__dirname, '/resources/scripts'), '@': path.join(__dirname, '/resources/scripts'),
'@models': path.join(__dirname, '/resources/scripts/api/types/models.d.ts'),
'@transformers': path.join(__dirname, '/resources/scripts/api/types/transformers.ts'),
'@feature': path.join(__dirname, '/resources/scripts/components/server/features'), '@feature': path.join(__dirname, '/resources/scripts/components/server/features'),
'react-dom': '@hot-loader/react-dom', 'react-dom': '@hot-loader/react-dom',
}, },