From 8d52e2e1a72fe40008d64f0f69516f67ebf432ae Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 22 Mar 2020 19:10:49 -0700 Subject: [PATCH] Finalize API key management for accounts --- .../Api/Client/ApiKeyController.php | 36 ++++++- resources/scripts/api/account/deleteApiKey.ts | 9 ++ .../dashboard/AccountApiContainer.tsx | 96 ++++++++++++++----- .../dashboard/forms/CreateApiKeyForm.tsx | 8 +- .../components/elements/ConfirmationModal.tsx | 32 +++++++ routes/api-client.php | 2 +- 6 files changed, 154 insertions(+), 29 deletions(-) create mode 100644 resources/scripts/api/account/deleteApiKey.ts create mode 100644 resources/scripts/components/elements/ConfirmationModal.tsx diff --git a/app/Http/Controllers/Api/Client/ApiKeyController.php b/app/Http/Controllers/Api/Client/ApiKeyController.php index 093c7ed6c..0788e62e2 100644 --- a/app/Http/Controllers/Api/Client/ApiKeyController.php +++ b/app/Http/Controllers/Api/Client/ApiKeyController.php @@ -3,11 +3,14 @@ namespace Pterodactyl\Http\Controllers\Api\Client; use Pterodactyl\Models\ApiKey; +use Illuminate\Http\JsonResponse; use Pterodactyl\Exceptions\DisplayException; use Illuminate\Contracts\Encryption\Encrypter; use Pterodactyl\Services\Api\KeyCreationService; +use Pterodactyl\Repositories\Eloquent\ApiKeyRepository; use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; use Pterodactyl\Transformers\Api\Client\ApiKeyTransformer; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Pterodactyl\Http\Requests\Api\Client\Account\StoreApiKeyRequest; class ApiKeyController extends ClientApiController @@ -22,18 +25,28 @@ class ApiKeyController extends ClientApiController */ private $encrypter; + /** + * @var \Pterodactyl\Repositories\Eloquent\ApiKeyRepository + */ + private $repository; + /** * ApiKeyController constructor. * * @param \Illuminate\Contracts\Encryption\Encrypter $encrypter * @param \Pterodactyl\Services\Api\KeyCreationService $keyCreationService + * @param \Pterodactyl\Repositories\Eloquent\ApiKeyRepository $repository */ - public function __construct(Encrypter $encrypter, KeyCreationService $keyCreationService) - { + public function __construct( + Encrypter $encrypter, + KeyCreationService $keyCreationService, + ApiKeyRepository $repository + ) { parent::__construct(); $this->encrypter = $encrypter; $this->keyCreationService = $keyCreationService; + $this->repository = $repository; } /** @@ -80,7 +93,24 @@ class ApiKeyController extends ClientApiController ->toArray(); } - public function delete() + /** + * Deletes a given API key. + * + * @param \Pterodactyl\Http\Requests\Api\Client\ClientApiRequest $request + * @param string $identifier + * @return \Illuminate\Http\JsonResponse + */ + public function delete(ClientApiRequest $request, string $identifier) { + $response = $this->repository->deleteWhere([ + 'user_id' => $request->user()->id, + 'identifier' => $identifier, + ]); + + if (! $response) { + throw new NotFoundHttpException; + } + + return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT); } } diff --git a/resources/scripts/api/account/deleteApiKey.ts b/resources/scripts/api/account/deleteApiKey.ts new file mode 100644 index 000000000..e34350d0f --- /dev/null +++ b/resources/scripts/api/account/deleteApiKey.ts @@ -0,0 +1,9 @@ +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/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx index 07f819518..7fbe21626 100644 --- a/resources/scripts/components/dashboard/AccountApiContainer.tsx +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -3,51 +3,103 @@ 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 { Simulate } from 'react-dom/test-utils'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faKey } from '@fortawesome/free-solid-svg-icons/faKey'; import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import deleteApiKey from '@/api/account/deleteApiKey'; +import { Actions, useStoreActions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { httpErrorToHuman } from '@/api/http'; +import format from 'date-fns/format'; export default () => { + const [ deleteIdentifier, setDeleteIdentifier ] = useState(''); const [ keys, setKeys ] = useState([]); const [ loading, setLoading ] = useState(true); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); 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)); + }; + return (
- - + + + setKeys(s => ([...s!, key]))}/> + {deleteIdentifier && + { + doDeletion(deleteIdentifier); + setDeleteIdentifier(''); + }} + onCanceled={() => setDeleteIdentifier('')} + > + Are you sure you wish to delete this API key? All requests using it will immediately be + invalidated and will fail. + + } { - keys.map(key => ( -
- -

- {key.description} -

-

- - {key.identifier} - -

- -
- )) + keys.length === 0 ? +

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

+ : + keys.map(key => ( +
+ +
+

{key.description}

+

+ Last + used: {key.lastUsedAt ? format(key.lastUsedAt, 'MMM Do, YYYY HH:mm') : 'Never'} +

+
+

+ + {key.identifier} + +

+ +
+ )) }
diff --git a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx index 338418cd3..cf1a596bf 100644 --- a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx +++ b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx @@ -8,23 +8,25 @@ 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'; interface Values { description: string; allowedIps: string; } -export default () => { +export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { const [ apiKey, setApiKey ] = useState(''); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers) => { clearFlashes('account'); createApiKey(values.description, values.allowedIps) - .then(key => { + .then(({ secretToken, ...key }) => { resetForm(); setSubmitting(false); - setApiKey(`${key.identifier}.${key.secretToken}`); + setApiKey(`${key.identifier}${secretToken}`); + onKeyCreated(key); }) .catch(error => { console.error(error); diff --git a/resources/scripts/components/elements/ConfirmationModal.tsx b/resources/scripts/components/elements/ConfirmationModal.tsx new file mode 100644 index 000000000..0796a5866 --- /dev/null +++ b/resources/scripts/components/elements/ConfirmationModal.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import Modal from '@/components/elements/Modal'; + +interface Props { + title: string; + buttonText: string; + children: string; + visible: boolean; + onConfirmed: () => void; + onCanceled: () => void; +} + +const ConfirmationModal = ({ title, children, visible, buttonText, onConfirmed, onCanceled }: Props) => ( + onCanceled()} + > +

{title}

+

{children}

+
+ + +
+
+); + +export default ConfirmationModal; diff --git a/routes/api-client.php b/routes/api-client.php index a71961d10..7aa8e6252 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -25,7 +25,7 @@ Route::group(['prefix' => '/account'], function () { Route::get('/api-keys', 'ApiKeyController@index'); Route::post('/api-keys', 'ApiKeyController@store'); - Route::delete('/api-keys/{key}', 'ApiKeyController@delete'); + Route::delete('/api-keys/{identifier}', 'ApiKeyController@delete'); }); /*