diff --git a/app/Http/Controllers/Api/Client/ApiKeyController.php b/app/Http/Controllers/Api/Client/ApiKeyController.php new file mode 100644 index 00000000..0788e62e --- /dev/null +++ b/app/Http/Controllers/Api/Client/ApiKeyController.php @@ -0,0 +1,116 @@ +encrypter = $encrypter; + $this->keyCreationService = $keyCreationService; + $this->repository = $repository; + } + + /** + * 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(); + } + + /** + * 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/app/Http/Controllers/Api/Client/Servers/ServerController.php b/app/Http/Controllers/Api/Client/Servers/ServerController.php index ffb5a1bd..4cf91416 100644 --- a/app/Http/Controllers/Api/Client/Servers/ServerController.php +++ b/app/Http/Controllers/Api/Client/Servers/ServerController.php @@ -3,12 +3,31 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Pterodactyl\Models\Server; +use Pterodactyl\Repositories\Eloquent\SubuserRepository; use Pterodactyl\Transformers\Api\Client\ServerTransformer; +use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Requests\Api\Client\Servers\GetServerRequest; class ServerController extends ClientApiController { + /** + * @var \Pterodactyl\Repositories\Eloquent\SubuserRepository + */ + private $repository; + + /** + * ServerController constructor. + * + * @param \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository + */ + public function __construct(SubuserRepository $repository) + { + parent::__construct(); + + $this->repository = $repository; + } + /** * Transform an individual server into a response that can be consumed by a * client using the API. @@ -19,8 +38,21 @@ class ServerController extends ClientApiController */ public function index(GetServerRequest $request, Server $server): array { + try { + $permissions = $this->repository->findFirstWhere([ + 'server_id' => $server->id, + 'user_id' => $request->user()->id, + ])->permissions; + } catch (RecordNotFoundException $exception) { + $permissions = []; + } + return $this->fractal->item($server) ->transformWith($this->getTransformer(ServerTransformer::class)) + ->addMeta([ + 'is_server_owner' => $request->user()->id === $server->owner_id, + 'user_permissions' => $permissions, + ]) ->toArray(); } } 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 00000000..00197388 --- /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 7535e903..6fb8a0e8 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/Permission.php b/app/Models/Permission.php index dd3292d8..11db3454 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -286,28 +286,4 @@ class Permission extends Validable return collect(self::$deprecatedPermissions); } - - /** - * Find permission by permission node. - * - * @param \Illuminate\Database\Query\Builder $query - * @param string $permission - * @return \Illuminate\Database\Query\Builder - */ - public function scopePermission($query, $permission) - { - return $query->where('permission', $permission); - } - - /** - * Filter permission by server. - * - * @param \Illuminate\Database\Query\Builder $query - * @param \Pterodactyl\Models\Server $server - * @return \Illuminate\Database\Query\Builder - */ - public function scopeServer($query, Server $server) - { - return $query->where('server_id', $server->id); - } } diff --git a/app/Models/Subuser.php b/app/Models/Subuser.php index 15f315f5..e5e8e318 100644 --- a/app/Models/Subuser.php +++ b/app/Models/Subuser.php @@ -8,12 +8,12 @@ use Illuminate\Notifications\Notifiable; * @property int $id * @property int $user_id * @property int $server_id + * @property array $permissions * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * * @property \Pterodactyl\Models\User $user * @property \Pterodactyl\Models\Server $server - * @property \Pterodactyl\Models\Permission[]|\Illuminate\Database\Eloquent\Collection $permissions */ class Subuser extends Validable { @@ -45,8 +45,9 @@ class Subuser extends Validable * @var array */ protected $casts = [ - 'user_id' => 'integer', - 'server_id' => 'integer', + 'user_id' => 'int', + 'server_id' => 'int', + 'permissions' => 'array', ]; /** @@ -55,6 +56,8 @@ class Subuser extends Validable public static $validationRules = [ 'user_id' => 'required|numeric|exists:users,id', 'server_id' => 'required|numeric|exists:servers,id', + 'permissions' => 'nullable|array', + 'permissions.*' => 'string', ]; /** diff --git a/app/Models/User.php b/app/Models/User.php index a3dc4ab3..0a37311d 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/Providers/RepositoryServiceProvider.php b/app/Providers/RepositoryServiceProvider.php index 99fccf82..6ee5a8d5 100644 --- a/app/Providers/RepositoryServiceProvider.php +++ b/app/Providers/RepositoryServiceProvider.php @@ -21,7 +21,6 @@ use Pterodactyl\Repositories\Eloquent\SettingsRepository; use Pterodactyl\Repositories\Wings\DaemonPowerRepository; use Pterodactyl\Repositories\Eloquent\DaemonKeyRepository; use Pterodactyl\Repositories\Eloquent\AllocationRepository; -use Pterodactyl\Repositories\Eloquent\PermissionRepository; use Pterodactyl\Repositories\Wings\DaemonCommandRepository; use Pterodactyl\Contracts\Repository\EggRepositoryInterface; use Pterodactyl\Repositories\Eloquent\EggVariableRepository; @@ -43,7 +42,6 @@ use Pterodactyl\Contracts\Repository\SettingsRepositoryInterface; use Pterodactyl\Repositories\Wings\DaemonConfigurationRepository; use Pterodactyl\Contracts\Repository\DaemonKeyRepositoryInterface; use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; -use Pterodactyl\Contracts\Repository\PermissionRepositoryInterface; use Pterodactyl\Contracts\Repository\Daemon\FileRepositoryInterface; use Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface; use Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface; @@ -73,7 +71,6 @@ class RepositoryServiceProvider extends ServiceProvider $this->app->bind(NestRepositoryInterface::class, NestRepository::class); $this->app->bind(NodeRepositoryInterface::class, NodeRepository::class); $this->app->bind(PackRepositoryInterface::class, PackRepository::class); - $this->app->bind(PermissionRepositoryInterface::class, PermissionRepository::class); $this->app->bind(ScheduleRepositoryInterface::class, ScheduleRepository::class); $this->app->bind(ServerRepositoryInterface::class, ServerRepository::class); $this->app->bind(ServerVariableRepositoryInterface::class, ServerVariableRepository::class); diff --git a/app/Repositories/Eloquent/PermissionRepository.php b/app/Repositories/Eloquent/PermissionRepository.php index ad2fa638..e2d0b8cb 100644 --- a/app/Repositories/Eloquent/PermissionRepository.php +++ b/app/Repositories/Eloquent/PermissionRepository.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Repositories\Eloquent; -use Pterodactyl\Models\Permission; +use Exception; use Pterodactyl\Contracts\Repository\PermissionRepositoryInterface; class PermissionRepository extends EloquentRepository implements PermissionRepositoryInterface @@ -11,9 +11,10 @@ class PermissionRepository extends EloquentRepository implements PermissionRepos * Return the model backing this repository. * * @return string + * @throws \Exception */ public function model() { - return Permission::class; + throw new Exception('This functionality is not implemented.'); } } diff --git a/app/Services/Api/KeyCreationService.php b/app/Services/Api/KeyCreationService.php index 9c70353e..a1547897 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 00000000..4c30ea3a --- /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/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php b/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php new file mode 100644 index 00000000..1568ef72 --- /dev/null +++ b/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php @@ -0,0 +1,57 @@ +json('permissions')->nullable()->after('server_id'); + }); + + DB::statement(' + UPDATE subusers as s + LEFT JOIN ( + SELECT subuser_id, JSON_ARRAYAGG(permission) as permissions + FROM permissions + GROUP BY subuser_id + ) as p ON p.subuser_id = s.id + SET s.permissions = p.permissions + '); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + foreach (DB::select('SELECT id, permissions FROM subusers') as $datum) { + $values = []; + foreach(json_decode($datum->permissions, true) as $permission) { + $values[] = $datum->id; + $values[] = $permission; + } + + if (!empty($values)) { + $string = 'VALUES ' . implode(', ', array_fill(0, count($values) / 2, '(?, ?)')); + + DB::insert('INSERT INTO permissions(`subuser_id`, `permission`) ' . $string, $values); + } + } + + Schema::table('subusers', function (Blueprint $table) { + $table->dropColumn('permissions'); + }); + } +} diff --git a/database/migrations/2020_03_22_164814_drop_permissions_table.php b/database/migrations/2020_03_22_164814_drop_permissions_table.php new file mode 100644 index 00000000..da9d677a --- /dev/null +++ b/database/migrations/2020_03_22_164814_drop_permissions_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->unsignedInteger('subuser_id'); + $table->string('permission'); + + $table->foreign('subuser_id')->references('id')->on('subusers')->onDelete('cascade'); + }); + } +} diff --git a/package.json b/package.json index 6e53fcce..f66bd9a6 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 00000000..afe50926 --- /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/deleteApiKey.ts b/resources/scripts/api/account/deleteApiKey.ts new file mode 100644 index 00000000..e34350d0 --- /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/api/account/getApiKeys.ts b/resources/scripts/api/account/getApiKeys.ts new file mode 100644 index 00000000..f8937ddd --- /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((d: any) => rawDataToApiKey(d.attributes)))) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx new file mode 100644 index 00000000..7fbe2162 --- /dev/null +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } 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 } 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.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 new file mode 100644 index 00000000..cf1a596b --- /dev/null +++ b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx @@ -0,0 +1,108 @@ +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'; +import { ApiKey } from '@/api/account/getApiKeys'; + +interface Values { + description: string; + allowedIps: string; +} + +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(({ secretToken, ...key }) => { + resetForm(); + setSubmitting(false); + setApiKey(`${key.identifier}${secretToken}`); + onKeyCreated(key); + }) + .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/ConfirmationModal.tsx b/resources/scripts/components/elements/ConfirmationModal.tsx new file mode 100644 index 00000000..0796a586 --- /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/resources/scripts/components/elements/FormikFieldWrapper.tsx b/resources/scripts/components/elements/FormikFieldWrapper.tsx index 37031710..ec6a0fb1 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 0b218486..dab36630 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 5dbe0c20..9c3d6508 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 6b49f58f..7aa8e625 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/{identifier}', 'ApiKeyController@delete'); }); /* diff --git a/webpack.config.js b/webpack.config.js index 250b062e..de867170 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 cdb641ce..29f40dd6 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"