diff --git a/app/Services/Users/SecurityKeys/StoreSecurityKeyService.php b/app/Services/Users/SecurityKeys/StoreSecurityKeyService.php index a262425f4..6908b1271 100644 --- a/app/Services/Users/SecurityKeys/StoreSecurityKeyService.php +++ b/app/Services/Users/SecurityKeys/StoreSecurityKeyService.php @@ -61,7 +61,7 @@ class StoreSecurityKeyService // void — so we need to just query the database immediately after this to pull the information // we just stored to return to the caller. /** @var \Pterodactyl\Models\SecurityKey $key */ - $key = $user->securityKeys()->forceCreate([ + $key = $user->securityKeys()->make()->forceFill([ 'uuid' => Uuid::uuid4(), 'name' => $this->keyName ?? 'Security Key (' . Str::random() . ')', 'public_key_id' => $source->getPublicKeyCredentialId(), @@ -76,6 +76,8 @@ class StoreSecurityKeyService 'other_ui' => $source->getOtherUI(), ]); + $key->saveOrFail(); + return $key; } } diff --git a/resources/scripts/api/account/webauthn/security-keys.ts b/resources/scripts/api/account/webauthn/security-keys.ts new file mode 100644 index 000000000..f89849664 --- /dev/null +++ b/resources/scripts/api/account/webauthn/security-keys.ts @@ -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): SWRResponse => { + const uuid = useStoreState(state => state.user.data!.uuid); + + return useSWR( + [ 'account', uuid, 'security-keys' ], + async (): Promise => { + const { data } = await http.get('/api/client/account/security-keys'); + + return (data as FractalResponseList).data.map((datum) => Transformers.toSecurityKey(datum.attributes)); + }, + config, + ); +}; + +export { useSecurityKeys }; diff --git a/resources/scripts/api/types/models.d.ts b/resources/scripts/api/types/models.d.ts new file mode 100644 index 000000000..ca99c24b8 --- /dev/null +++ b/resources/scripts/api/types/models.d.ts @@ -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; +} diff --git a/resources/scripts/api/types/transformers.ts b/resources/scripts/api/types/transformers.ts new file mode 100644 index 000000000..9fa2e6f1e --- /dev/null +++ b/resources/scripts/api/types/transformers.ts @@ -0,0 +1,14 @@ +import * as Models from '@models'; + +export default class Transformers { + static toSecurityKey (data: Record): 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), + }; + } +} diff --git a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx index 381554b15..6446d2a55 100644 --- a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx +++ b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx @@ -6,7 +6,7 @@ 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 getWebauthnKeys, { SecurityKey } from '@/api/account/webauthn/getSecurityKeys'; +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'; @@ -15,8 +15,9 @@ 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 from '@/plugins/useFlash'; +import useFlash, { useFlashKey } from '@/plugins/useFlash'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import { useSecurityKeys } from '@/api/account/webauthn/security-keys'; interface Values { name: string; @@ -68,91 +69,77 @@ const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) => }; export default () => { - const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { clearFlashes, clearAndAddHttpError } = useFlashKey('security_keys'); - const [ keys, setKeys ] = useState([]); - const [ loading, setLoading ] = useState(true); const [ deleteId, setDeleteId ] = useState(null); + const { data, mutate, error } = useSecurityKeys({ revalidateOnFocus: false }); - const doDeletion = (uuid: string | null) => { - if (uuid === null) { - return; - } + const doDeletion = () => { + const uuid = deleteId; - clearFlashes('security_keys'); + setDeleteId(null); + clearFlashes(); + mutate(keys => !keys ? undefined : keys.filter(key => key.uuid !== deleteId)); - deleteWebauthnKey(uuid) - .then(() => setKeys(s => ([ - ...(s || []).filter(key => key.uuid !== uuid), - ]))) - .catch(error => { - console.error(error); - clearAndAddHttpError({ key: 'security_keys', error }); - }); + if (!uuid) return; + + deleteWebauthnKey(uuid).catch(error => { + clearAndAddHttpError(error); + mutate(); + }); }; useEffect(() => { - clearFlashes('security_keys'); - - getWebauthnKeys() - .then(keys => setKeys(keys)) - .then(() => setLoading(false)) - .catch(error => { - console.error(error); - clearAndAddHttpError({ key: 'security_keys', error }); - }); - }, []); + clearAndAddHttpError(error); + }, [ error ]); return (
- setKeys(s => ([ ...s!, key ]))}/> + mutate((keys) => (keys || []).concat(key))}/> - { - doDeletion(deleteId); - setDeleteId(null); - }} + buttonText={'Yes, Delete Key'} + onConfirmed={doDeletion} onModalDismissed={() => setDeleteId(null)} > Are you sure you wish to delete this security key? You will no longer be able to authenticate using this key. - {keys.length === 0 ? - !loading ? + {!data ? + + : + data?.length === 0 ?

No security keys have been configured for this account.

- : null - : - keys.map((key, index) => ( - 0 && tw`mt-2` ]} - > - -
-

{key.name}

-

- Created at:  - {key.createdAt ? format(key.createdAt, 'MMM do, yyyy HH:mm') : 'Never'} -

-
- -
- )) + : + data.map((key, index) => ( + 0 && tw`mt-2` ]} + > + +
+

{key.name}

+

+ Created at:  + {key.createdAt ? format(key.createdAt, 'MMM do, yyyy HH:mm') : 'Never'} +

+
+ +
+ )) }
diff --git a/resources/scripts/plugins/useFlash.ts b/resources/scripts/plugins/useFlash.ts index a55b87312..95295f1ef 100644 --- a/resources/scripts/plugins/useFlash.ts +++ b/resources/scripts/plugins/useFlash.ts @@ -1,9 +1,24 @@ -import { Actions, useStoreActions } from 'easy-peasy'; +import { Actions } from 'easy-peasy'; import { FlashStore } from '@/state/flashes'; -import { ApplicationStore } from '@/state'; +import { useStoreActions } from '@/state/hooks'; const useFlash = (): Actions => { - return useStoreActions((actions: Actions) => 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; diff --git a/resources/scripts/state/flashes.ts b/resources/scripts/state/flashes.ts index f888d07d6..31ab75a36 100644 --- a/resources/scripts/state/flashes.ts +++ b/resources/scripts/state/flashes.ts @@ -6,7 +6,7 @@ export interface FlashStore { items: FlashMessage[]; addFlash: Action; addError: Action; - clearAndAddHttpError: Action; + clearAndAddHttpError: Action; clearFlashes: Action; } @@ -30,9 +30,18 @@ const flashes: FlashStore = { }), 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) => { diff --git a/tsconfig.json b/tsconfig.json index 18a3fc91c..ac41d53cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,12 @@ ], "@feature/*": [ "./resources/scripts/components/server/features/*" + ], + "@models": [ + "./resources/scripts/api/types/models.d.ts" + ], + "@transformers": [ + "./resources/scripts/api/types/transformers.ts" ] }, "plugins": [ diff --git a/webpack.config.js b/webpack.config.js index 40deffd22..24bcae984 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -60,6 +60,8 @@ module.exports = { extensions: ['.ts', '.tsx', '.js', '.json'], alias: { '@': 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'), 'react-dom': '@hot-loader/react-dom', },