From 933a4733e83e1fd58cb63f6449c134707ec50b16 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 22 Mar 2020 18:15:38 -0700 Subject: [PATCH] Add base support for creating a new API key for an account --- .../Api/Client/ApiKeyController.php | 86 ++++++++++++++ .../Api/Client/Account/StoreApiKeyRequest.php | 20 ++++ app/Models/ApiKey.php | 14 +++ app/Models/User.php | 10 ++ app/Services/Api/KeyCreationService.php | 4 +- .../Api/Client/ApiKeyTransformer.php | 33 ++++++ package.json | 1 + resources/scripts/api/account/createApiKey.ts | 17 +++ resources/scripts/api/account/getApiKeys.ts | 25 +++++ .../dashboard/AccountApiContainer.tsx | 16 +++ .../dashboard/forms/CreateApiKeyForm.tsx | 106 ++++++++++++++++++ .../elements/FormikFieldWrapper.tsx | 5 +- resources/scripts/routers/DashboardRouter.tsx | 14 ++- resources/styles/components/forms.css | 12 +- routes/api-client.php | 4 + webpack.config.js | 2 + yarn.lock | 15 +++ 17 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 app/Http/Controllers/Api/Client/ApiKeyController.php create mode 100644 app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php create mode 100644 app/Transformers/Api/Client/ApiKeyTransformer.php create mode 100644 resources/scripts/api/account/createApiKey.ts create mode 100644 resources/scripts/api/account/getApiKeys.ts create mode 100644 resources/scripts/components/dashboard/AccountApiContainer.tsx create mode 100644 resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx diff --git a/app/Http/Controllers/Api/Client/ApiKeyController.php b/app/Http/Controllers/Api/Client/ApiKeyController.php new file mode 100644 index 000000000..093c7ed6c --- /dev/null +++ b/app/Http/Controllers/Api/Client/ApiKeyController.php @@ -0,0 +1,86 @@ +encrypter = $encrypter; + $this->keyCreationService = $keyCreationService; + } + + /** + * Returns all of the API keys that exist for the given client. + * + * @param \Pterodactyl\Http\Requests\Api\Client\ClientApiRequest $request + * @return array + */ + public function index(ClientApiRequest $request) + { + return $this->fractal->collection($request->user()->apiKeys) + ->transformWith($this->getTransformer(ApiKeyTransformer::class)) + ->toArray(); + } + + /** + * Store a new API key for a user's account. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Account\StoreApiKeyRequest $request + * @return array + * + * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + */ + public function store(StoreApiKeyRequest $request) + { + if ($request->user()->apiKeys->count() >= 5) { + throw new DisplayException( + 'You have reached the account limit for number of API keys.' + ); + } + + $key = $this->keyCreationService->setKeyType(ApiKey::TYPE_ACCOUNT)->handle([ + 'user_id' => $request->user()->id, + 'memo' => $request->input('description'), + 'allowed_ips' => $request->input('allowed_ips') ?? [], + ]); + + return $this->fractal->item($key) + ->transformWith($this->getTransformer(ApiKeyTransformer::class)) + ->addMeta([ + 'secret_token' => $this->encrypter->decrypt($key->token), + ]) + ->toArray(); + } + + public function delete() + { + } +} diff --git a/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php b/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php new file mode 100644 index 000000000..00197388a --- /dev/null +++ b/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php @@ -0,0 +1,20 @@ + 'required|string|min:4', + 'allowed_ips' => 'array', + 'allowed_ips.*' => 'ip', + ]; + } +} diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index 7535e9037..6fb8a0e82 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -4,8 +4,22 @@ namespace Pterodactyl\Models; use Pterodactyl\Services\Acl\Api\AdminAcl; +/** + * @property int $id + * @property int $user_id + * @property int $key_type + * @property string $identifier + * @property string $token + * @property array $allowed_ips + * @property string $memo + * @property \Carbon\Carbon|null $last_used_at + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + */ class ApiKey extends Validable { + const RESOURCE_NAME = 'api_key'; + /** * Different API keys that can exist on the system. */ diff --git a/app/Models/User.php b/app/Models/User.php index a3dc4ab36..0a37311d3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -36,6 +36,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; * @property \Carbon\Carbon $updated_at * * @property string $name + * @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys * @property \Pterodactyl\Models\Permission[]|\Illuminate\Database\Eloquent\Collection $permissions * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\Subuser[]|\Illuminate\Database\Eloquent\Collection $subuserOf @@ -258,4 +259,13 @@ class User extends Validable implements { return $this->hasMany(DaemonKey::class); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function apiKeys() + { + return $this->hasMany(ApiKey::class) + ->where('key_type', ApiKey::TYPE_ACCOUNT); + } } diff --git a/app/Services/Api/KeyCreationService.php b/app/Services/Api/KeyCreationService.php index 9c70353ed..a1547897e 100644 --- a/app/Services/Api/KeyCreationService.php +++ b/app/Services/Api/KeyCreationService.php @@ -72,8 +72,6 @@ class KeyCreationService $data = array_merge($data, $permissions); } - $instance = $this->repository->create($data, true, true); - - return $instance; + return $this->repository->create($data, true, true); } } diff --git a/app/Transformers/Api/Client/ApiKeyTransformer.php b/app/Transformers/Api/Client/ApiKeyTransformer.php new file mode 100644 index 000000000..4c30ea3a6 --- /dev/null +++ b/app/Transformers/Api/Client/ApiKeyTransformer.php @@ -0,0 +1,33 @@ + $model->identifier, + 'description' => $model->memo, + 'allowed_ips' => $model->allowed_ips, + 'last_used_at' => $model->last_used_at ? $model->last_used_at->toIso8601String() : null, + 'created_at' => $model->created_at->toIso8601String(), + ]; + } +} diff --git a/package.json b/package.json index 4e01b865d..da6bef422 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "devDependencies": { "@babel/core": "^7.7.5", "@babel/plugin-proposal-class-properties": "^7.7.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-proposal-object-rest-spread": "^7.7.4", "@babel/plugin-proposal-optional-chaining": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.7.4", diff --git a/resources/scripts/api/account/createApiKey.ts b/resources/scripts/api/account/createApiKey.ts new file mode 100644 index 000000000..afe509264 --- /dev/null +++ b/resources/scripts/api/account/createApiKey.ts @@ -0,0 +1,17 @@ +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, + // eslint-disable-next-line @typescript-eslint/camelcase + allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [], + }) + .then(({ data }) => resolve({ + ...rawDataToApiKey(data.attributes), + secretToken: data.meta?.secret_token ?? '', + })) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/account/getApiKeys.ts b/resources/scripts/api/account/getApiKeys.ts new file mode 100644 index 000000000..759fc75a2 --- /dev/null +++ b/resources/scripts/api/account/getApiKeys.ts @@ -0,0 +1,25 @@ +import http from '@/api/http'; + +export interface ApiKey { + identifier: string; + description: string; + allowedIps: string[]; + createdAt: Date | null; + lastUsedAt: Date | null; +} + +export const rawDataToApiKey = (data: any): ApiKey => ({ + identifier: data.identifier, + description: data.description, + allowedIps: data.allowed_ips, + 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(rawDataToApiKey))) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx new file mode 100644 index 000000000..99063fa7e --- /dev/null +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ContentBox from '@/components/elements/ContentBox'; +import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm'; + +export default () => { + return ( +
+ + + + +

Testing

+
+
+ ); +}; diff --git a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx new file mode 100644 index 000000000..338418cd3 --- /dev/null +++ b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { Field, Form, Formik, FormikHelpers } from 'formik'; +import { object, string } from 'yup'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; +import Modal from '@/components/elements/Modal'; +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'; + +interface Values { + description: string; + allowedIps: string; +} + +export default () => { + 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 => { + resetForm(); + setSubmitting(false); + setApiKey(`${key.identifier}.${key.secretToken}`); + }) + .catch(error => { + console.error(error); + + addError({ key: 'account', message: httpErrorToHuman(error) }); + setSubmitting(false); + }); + }; + + return ( + <> + 0} + onDismissed={() => setApiKey('')} + closeOnEscape={false} + closeOnBackground={false} + > +

Your API Key

+

+ The API key you have requested is shown below. Please store this in a safe location, it will not be + shown again. +

+
+                    {apiKey}
+                
+
+ +
+
+ + {({ isSubmitting }) => ( +
+ + + + + + + +
+ +
+ + )} +
+ + ); +}; diff --git a/resources/scripts/components/elements/FormikFieldWrapper.tsx b/resources/scripts/components/elements/FormikFieldWrapper.tsx index 37031710b..ec6a0fb13 100644 --- a/resources/scripts/components/elements/FormikFieldWrapper.tsx +++ b/resources/scripts/components/elements/FormikFieldWrapper.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import InputError from '@/components/elements/InputError'; interface Props { + id?: string; name: string; children: React.ReactNode; className?: string; @@ -12,12 +13,12 @@ interface Props { validate?: (value: any) => undefined | string | Promise; } -const FormikFieldWrapper = ({ name, label, className, description, validate, children }: Props) => ( +const FormikFieldWrapper = ({ id, name, label, className, description, validate, children }: Props) => ( { ({ field, form: { errors, touched } }: FieldProps) => (
- {label && } + {label && } {children} {description ?

{description}

: null} diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index 0b218486f..dab36630c 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -1,18 +1,28 @@ import * as React from 'react'; -import { Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom'; import DesignElementsContainer from '@/components/dashboard/DesignElementsContainer'; import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer'; import NavigationBar from '@/components/NavigationBar'; import DashboardContainer from '@/components/dashboard/DashboardContainer'; import TransitionRouter from '@/TransitionRouter'; +import AccountApiContainer from '@/components/dashboard/AccountApiContainer'; export default ({ location }: RouteComponentProps) => ( + {location.pathname.startsWith('/account') && +
+
+ Settings + API Credentials +
+
+ } - + + diff --git a/resources/styles/components/forms.css b/resources/styles/components/forms.css index 5dbe0c20a..9c3d6508d 100644 --- a/resources/styles/components/forms.css +++ b/resources/styles/components/forms.css @@ -65,12 +65,8 @@ input[type=number] { @apply .text-xs .text-neutral-400; } - &.error { - @apply .text-red-100 .border-red-400; - } - &.error + .input-help { - @apply .text-red-400; + @apply .text-red-400 !important; } &:disabled { @@ -78,11 +74,15 @@ input[type=number] { } } +.has-error .input-dark:not(select), .input-dark.error { + @apply .text-red-100 .border-red-400; +} + .input-help { @apply .text-xs .text-neutral-400 .pt-2; &.error { - @apply .text-red-400; + @apply .text-red-400 !important; } } diff --git a/routes/api-client.php b/routes/api-client.php index 6b49f58f4..a71961d10 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -22,6 +22,10 @@ Route::group(['prefix' => '/account'], function () { Route::put('/email', 'AccountController@updateEmail')->name('api.client.account.update-email'); Route::put('/password', 'AccountController@updatePassword')->name('api.client.account.update-password'); + + Route::get('/api-keys', 'ApiKeyController@index'); + Route::post('/api-keys', 'ApiKeyController@store'); + Route::delete('/api-keys/{key}', 'ApiKeyController@delete'); }); /* diff --git a/webpack.config.js b/webpack.config.js index 250b062e4..de8671705 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -87,6 +87,7 @@ module.exports = { '@babel/proposal-class-properties', '@babel/proposal-object-rest-spread', '@babel/proposal-optional-chaining', + '@babel/proposal-nullish-coalescing-operator', '@babel/syntax-dynamic-import', ], }, @@ -164,6 +165,7 @@ module.exports = { ], }, watchOptions: { + poll: 1000, ignored: /node_modules/, }, devServer: { diff --git a/yarn.lock b/yarn.lock index 030630e9f..bd4f476f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -250,6 +250,14 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-json-strings" "^7.7.4" +"@babel/plugin-proposal-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.8.3.tgz#e4572253fdeed65cddeecfdab3f928afeb2fd5d2" + integrity sha512-TS9MlfzXpXKt6YYomudb/KU7nQI6/xnapG6in1uZxoxDghuSMZsPb6D2fyUwNYSAp4l1iR7QtFOjkqcRYcUsfw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + "@babel/plugin-proposal-object-rest-spread@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.7.4.tgz#cc57849894a5c774214178c8ab64f6334ec8af71" @@ -302,6 +310,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + "@babel/plugin-syntax-object-rest-spread@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.7.4.tgz#47cf220d19d6d0d7b154304701f468fc1cc6ff46"