diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php index 56ca18cea..d9c2638ac 100644 --- a/app/Models/PersonalAccessToken.php +++ b/app/Models/PersonalAccessToken.php @@ -4,11 +4,13 @@ namespace Pterodactyl\Models; use Illuminate\Support\Str; use Laravel\Sanctum\Contracts\HasAbilities; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Factories\HasFactory; class PersonalAccessToken extends Model implements HasAbilities { use HasFactory; + use SoftDeletes; public const RESOURCE_NAME = 'personal_access_token'; diff --git a/database/migrations/2022_02_20_122102_make_personal_access_tokens_soft_deletable.php b/database/migrations/2022_02_20_122102_make_personal_access_tokens_soft_deletable.php new file mode 100644 index 000000000..87a092c9a --- /dev/null +++ b/database/migrations/2022_02_20_122102_make_personal_access_tokens_soft_deletable.php @@ -0,0 +1,32 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->dropColumn('deleted_at'); + }); + } +} diff --git a/resources/scripts/api/account/api-keys.ts b/resources/scripts/api/account/api-keys.ts new file mode 100644 index 000000000..66217a587 --- /dev/null +++ b/resources/scripts/api/account/api-keys.ts @@ -0,0 +1,33 @@ +import useSWR, { SWRConfiguration, SWRResponse } from 'swr'; +import http, { FractalResponseList } from '@/api/http'; +import Transformers from '@transformers'; +import { PersonalAccessToken } from '@models'; +import { AxiosError } from 'axios'; +import useUserSWRContextKey from '@/plugins/useUserSWRContextKey'; + +const useAPIKeys = ( + config?: SWRConfiguration, +): SWRResponse => { + const key = useUserSWRContextKey([ 'account', 'api-keys' ]); + + return useSWR(key, async () => { + const { data } = await http.get('/api/client/account/api-keys'); + + return (data as FractalResponseList).data.map((datum: any) => { + return Transformers.toPersonalAccessToken(datum.attributes); + }); + }, config || { revalidateOnMount: false }); +}; + +const createAPIKey = async (description: string): Promise<[ PersonalAccessToken, string ]> => { + const { data } = await http.post('/api/client/account/api-keys', { description }); + + const token = Transformers.toPersonalAccessToken(data.attributes); + + return [ token, data.meta?.secret_token || '' ]; +}; + +const deleteAPIKey = async (identifier: string) => + await http.delete(`/api/client/account/api-keys/${identifier}`); + +export { useAPIKeys, createAPIKey, deleteAPIKey }; diff --git a/resources/scripts/api/account/createApiKey.ts b/resources/scripts/api/account/createApiKey.ts deleted file mode 100644 index 7067ec145..000000000 --- a/resources/scripts/api/account/createApiKey.ts +++ /dev/null @@ -1,17 +0,0 @@ -import http from '@/api/http'; -import { ApiKey, rawDataToApiKey } from '@/api/account/getApiKeys'; - -export default (description: string, allowedIps: string): Promise => { - return new Promise((resolve, reject) => { - http.post('/api/client/account/api-keys', { - description, - allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [], - }) - .then(({ data }) => resolve({ - ...rawDataToApiKey(data.attributes), - // eslint-disable-next-line camelcase - secretToken: data.meta?.secret_token ?? '', - })) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/account/deleteApiKey.ts b/resources/scripts/api/account/deleteApiKey.ts deleted file mode 100644 index e34350d0f..000000000 --- a/resources/scripts/api/account/deleteApiKey.ts +++ /dev/null @@ -1,9 +0,0 @@ -import http from '@/api/http'; - -export default (identifier: string): Promise => { - return new Promise((resolve, reject) => { - http.delete(`/api/client/account/api-keys/${identifier}`) - .then(() => resolve()) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/account/getApiKeys.ts b/resources/scripts/api/account/getApiKeys.ts deleted file mode 100644 index bcedb9e81..000000000 --- a/resources/scripts/api/account/getApiKeys.ts +++ /dev/null @@ -1,23 +0,0 @@ -import http from '@/api/http'; - -export interface ApiKey { - identifier: string; - description: string; - createdAt: Date | null; - lastUsedAt: Date | null; -} - -export const rawDataToApiKey = (data: any): ApiKey => ({ - identifier: data.token_id, - description: data.description, - createdAt: data.created_at ? new Date(data.created_at) : null, - lastUsedAt: data.last_used_at ? new Date(data.last_used_at) : null, -}); - -export default (): Promise => { - return new Promise((resolve, reject) => { - http.get('/api/client/account/api-keys') - .then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToApiKey(d.attributes)))) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/account/security-keys.ts b/resources/scripts/api/account/security-keys.ts index 248008789..d2a9af9fd 100644 --- a/resources/scripts/api/account/security-keys.ts +++ b/resources/scripts/api/account/security-keys.ts @@ -1,17 +1,17 @@ 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'; +import useUserSWRContextKey from '@/plugins/useUserSWRContextKey'; const useSecurityKeys = (config?: SWRConfiguration): SWRResponse => { - const uuid = useStoreState(state => state.user.data!.uuid); + const key = useUserSWRContextKey([ 'account', 'security-keys' ]); return useSWR( - [ 'account', uuid, 'security-keys' ], + key, async (): Promise => { const { data } = await http.get('/api/client/account/security-keys'); diff --git a/resources/scripts/api/types/models.d.ts b/resources/scripts/api/types/models.d.ts index ca99c24b8..e587d70de 100644 --- a/resources/scripts/api/types/models.d.ts +++ b/resources/scripts/api/types/models.d.ts @@ -9,3 +9,11 @@ interface SecurityKey extends Model { createdAt: Date; updatedAt: Date; } + +interface PersonalAccessToken extends Model { + identifier: string; + description: string; + createdAt: Date; + updatedAt: Date; + lastUsedAt: Date | null; +} diff --git a/resources/scripts/api/types/transformers.ts b/resources/scripts/api/types/transformers.ts index 9fa2e6f1e..cf7b000a3 100644 --- a/resources/scripts/api/types/transformers.ts +++ b/resources/scripts/api/types/transformers.ts @@ -11,4 +11,14 @@ export default class Transformers { updatedAt: new Date(data.updated_at), }; } + + static toPersonalAccessToken (data: Record): Models.PersonalAccessToken { + return { + identifier: data.token_id, + description: data.description, + createdAt: new Date(data.created_at), + updatedAt: new Date(data.updated_at), + lastUsedAt: data.last_used_at ? new Date(data.last_used_at) : null, + }; + } } diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx index f3497d994..19dbbfecc 100644 --- a/resources/scripts/components/dashboard/AccountApiContainer.tsx +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -1,78 +1,42 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import ContentBox from '@/components/elements/ContentBox'; import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm'; -import getApiKeys, { ApiKey } from '@/api/account/getApiKeys'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; -import ConfirmationModal from '@/components/elements/ConfirmationModal'; -import deleteApiKey from '@/api/account/deleteApiKey'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; +import { faKey } from '@fortawesome/free-solid-svg-icons'; import FlashMessageRender from '@/components/FlashMessageRender'; -import { httpErrorToHuman } from '@/api/http'; import { format } from 'date-fns'; import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; import GreyRowBox from '@/components/elements/GreyRowBox'; +import { useAPIKeys } from '@/api/account/api-keys'; +import { useFlashKey } from '@/plugins/useFlash'; +import DeleteAPIKeyButton from '@/components/dashboard/security/DeleteAPIKeyButton'; export default () => { - const [ deleteIdentifier, setDeleteIdentifier ] = useState(''); - const [ keys, setKeys ] = useState([]); - const [ loading, setLoading ] = useState(true); - const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + const { clearAndAddHttpError } = useFlashKey('account'); + const { data: keys, isValidating, error } = useAPIKeys({ + revalidateOnMount: true, + revalidateOnFocus: false, + }); useEffect(() => { - clearFlashes('account'); - getApiKeys() - .then(keys => setKeys(keys)) - .then(() => setLoading(false)) - .catch(error => { - console.error(error); - addError({ key: 'account', message: httpErrorToHuman(error) }); - }); - }, []); - - const doDeletion = (identifier: string) => { - setLoading(true); - clearFlashes('account'); - deleteApiKey(identifier) - .then(() => setKeys(s => ([ - ...(s || []).filter(key => key.identifier !== identifier), - ]))) - .catch(error => { - console.error(error); - addError({ key: 'account', message: httpErrorToHuman(error) }); - }) - .then(() => setLoading(false)); - }; + clearAndAddHttpError(error); + }, [ error ]); return (
- setKeys(s => ([ ...s!, key ]))}/> + - - { - doDeletion(deleteIdentifier); - setDeleteIdentifier(''); - }} - onModalDismissed={() => setDeleteIdentifier('')} - > - Are you sure you wish to delete this API key? All requests using it will immediately be - invalidated and will fail. - + { - keys.length === 0 ? + !keys || !keys.length ?

- {loading ? 'Loading...' : 'No API keys exist for this account.'} + {!keys ? 'Loading...' : 'No API keys exist for this account.'}

: keys.map((key, index) => ( @@ -93,15 +57,7 @@ export default () => { {key.identifier}

- + )) } diff --git a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx index 3c3146a79..1cc5b3c8b 100644 --- a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx +++ b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx @@ -2,41 +2,41 @@ import React, { useState } from 'react'; import { Field, Form, Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; -import createApiKey from '@/api/account/createApiKey'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; -import { httpErrorToHuman } from '@/api/http'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; -import { ApiKey } from '@/api/account/getApiKeys'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import Input from '@/components/elements/Input'; import ApiKeyModal from '@/components/dashboard/ApiKeyModal'; +import { createAPIKey, useAPIKeys } from '@/api/account/api-keys'; +import { useFlashKey } from '@/plugins/useFlash'; interface Values { description: string; allowedIps: string; } -export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { +export default () => { const [ apiKey, setApiKey ] = useState(''); - const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + const { mutate } = useAPIKeys(); + const { clearAndAddHttpError } = useFlashKey('account'); const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers) => { - clearFlashes('account'); - createApiKey(values.description, values.allowedIps) - .then(({ secretToken, ...key }) => { - resetForm(); - setSubmitting(false); - setApiKey(secretToken); - onKeyCreated(key); - }) - .catch(error => { - console.error(error); + clearAndAddHttpError(); - addError({ key: 'account', message: httpErrorToHuman(error) }); - setSubmitting(false); - }); + createAPIKey(values.description) + .then(async ([ token, secretToken ]) => { + await mutate((data) => { + return (data || []).filter((value) => value.identifier !== token.identifier).concat(token); + }, false); + + return secretToken; + }) + .then((token) => { + resetForm(); + setApiKey(token); + }) + .catch(error => clearAndAddHttpError(error)) + .finally(() => setSubmitting(false)); }; return ( diff --git a/resources/scripts/components/dashboard/security/DeleteAPIKeyButton.tsx b/resources/scripts/components/dashboard/security/DeleteAPIKeyButton.tsx new file mode 100644 index 000000000..cc678b62c --- /dev/null +++ b/resources/scripts/components/dashboard/security/DeleteAPIKeyButton.tsx @@ -0,0 +1,47 @@ +import tw from 'twin.macro'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import React, { useState } from 'react'; +import { deleteAPIKey, useAPIKeys } from '@/api/account/api-keys'; +import { useFlashKey } from '@/plugins/useFlash'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; + +export default ({ identifier }: { identifier: string }) => { + const { clearAndAddHttpError } = useFlashKey('account'); + const [ visible, setVisible ] = useState(false); + const { mutate } = useAPIKeys(); + + const onClick = () => { + clearAndAddHttpError(); + + Promise.all([ + mutate((data) => data?.filter((value) => value.identifier !== identifier), false), + deleteAPIKey(identifier), + ]) + .catch((error) => { + mutate(undefined, true); + clearAndAddHttpError(error); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you wish to delete this API key? All requests using it will immediately be + invalidated and will fail. + + + + ); +}; diff --git a/resources/scripts/plugins/useUserSWRContextKey.ts b/resources/scripts/plugins/useUserSWRContextKey.ts new file mode 100644 index 000000000..23ea2eca2 --- /dev/null +++ b/resources/scripts/plugins/useUserSWRContextKey.ts @@ -0,0 +1,12 @@ +import { useStoreState } from '@/state/hooks'; + +export default (context: string | string[]) => { + const key = Array.isArray(context) ? context.join(':') : context; + const uuid = useStoreState(state => state.user.data?.uuid); + + if (!key.trim().length) { + throw new Error('Must provide a valid context key to "useUserSWRContextKey".'); + } + + return `swr::${uuid || 'unknown'}:${key.trim()}`; +};