Add support for storing SSH keys on user accounts

This commit is contained in:
DaneEveritt 2022-05-14 17:31:53 -04:00
parent 5705d7dbdd
commit 97280a62a2
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
20 changed files with 678 additions and 6 deletions

View file

@ -0,0 +1,48 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Transformers\Api\Client\SSHKeyTransformer;
use Pterodactyl\Http\Requests\Api\Client\Account\StoreSSHKeyRequest;
class SSHKeyController extends ClientApiController
{
/**
* Returns all of the SSH keys that have been configured for the logged in
* user account.
*/
public function index(ClientApiRequest $request): array
{
return $this->fractal->collection($request->user()->sshKeys)
->transformWith($this->getTransformer(SSHKeyTransformer::class))
->toArray();
}
/**
* Stores a new SSH key for the authenticated user's account.
*/
public function store(StoreSSHKeyRequest $request): array
{
$model = $request->user()->sshKeys()->create([
'name' => $request->input('name'),
'public_key' => $request->input('public_key'),
'fingerprint' => $request->getKeyFingerprint(),
]);
return $this->fractal->item($model)
->transformWith($this->getTransformer(SSHKeyTransformer::class))
->toArray();
}
/**
* Deletes an SSH key from the user's account.
*/
public function delete(ClientApiRequest $request, string $identifier): JsonResponse
{
$request->user()->sshKeys()->where('fingerprint', $identifier)->delete();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}

View file

@ -3,6 +3,7 @@
namespace Pterodactyl\Http\Requests\Api\Application; namespace Pterodactyl\Http\Requests\Api\Application;
use Pterodactyl\Models\ApiKey; use Pterodactyl\Models\ApiKey;
use Illuminate\Validation\Validator;
use Pterodactyl\Services\Acl\Api\AdminAcl; use Pterodactyl\Services\Acl\Api\AdminAcl;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Pterodactyl\Exceptions\PterodactylException; use Pterodactyl\Exceptions\PterodactylException;
@ -96,6 +97,16 @@ abstract class ApplicationApiRequest extends FormRequest
return $this->route()->parameter($parameterKey); return $this->route()->parameter($parameterKey);
} }
/**
* Helper method allowing a developer to easily hook into this logic without having
* to remember what the method name is called or where to use it. By default this is
* a no-op.
*/
public function withValidator(Validator $validator): void
{
// do nothing
}
/** /**
* Validate that the resource exists and can be accessed prior to booting * Validate that the resource exists and can be accessed prior to booting
* the validator and attempting to use the data. * the validator and attempting to use the data.

View file

@ -0,0 +1,71 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Account;
use Exception;
use phpseclib3\Crypt\DSA;
use phpseclib3\Crypt\RSA;
use Pterodactyl\Models\UserSSHKey;
use Illuminate\Validation\Validator;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\Common\PublicKey;
use phpseclib3\Exception\NoKeyLoadedException;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class StoreSSHKeyRequest extends ClientApiRequest
{
protected ?PublicKey $key;
/**
* Returns the rules for this request.
*/
public function rules(): array
{
return [
'name' => UserSSHKey::getRulesForField('name'),
'public_key' => UserSSHKey::getRulesForField('public_key'),
];
}
/**
* Check to see if this SSH key has already been added to the user's account
* and if so return an error.
*/
public function withValidator(Validator $validator): void
{
$validator->after(function () {
try {
$this->key = PublicKeyLoader::loadPublicKey($this->input('public_key'));
} catch (NoKeyLoadedException $exception) {
$this->validator->errors()->add('public_key', 'The public key provided is not valid.');
return;
}
if ($this->key instanceof DSA) {
$this->validator->errors()->add('public_key', 'DSA public keys are not supported.');
}
if ($this->key instanceof RSA && $this->key->getLength() < 2048) {
$this->validator->errors()->add('public_key', 'RSA keys must be at 2048 bytes.');
}
$fingerprint = $this->key->getFingerprint('sha256');
if ($this->user()->sshKeys()->where('fingerprint', $fingerprint)->exists()) {
$this->validator->errors()->add('public_key', 'The public key provided already exists on your account.');
}
});
}
/**
* Returns the SHA256 fingerprint of the key provided.
*/
public function getKeyFingerprint(): string
{
if (!$this->key) {
throw new Exception('The public key was not properly loaded for this request.');
}
return $this->key->getFingerprint('sha256');
}
}

View file

@ -10,6 +10,7 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Auth\Passwords\CanResetPassword;
use Pterodactyl\Traits\Helpers\AvailableLanguages; use Pterodactyl\Traits\Helpers\AvailableLanguages;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\Access\Authorizable; use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
@ -17,6 +18,8 @@ use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
/** /**
* \Pterodactyl\Models\User.
*
* @property int $id * @property int $id
* @property string|null $external_id * @property string|null $external_id
* @property string $uuid * @property string $uuid
@ -38,6 +41,37 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys * @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens * @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens
* @property string|null $remember_token
* @property int|null $api_keys_count
* @property \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications
* @property int|null $notifications_count
* @property int|null $recovery_tokens_count
* @property int|null $servers_count
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\UserSSHKey[] $sshKeys
* @property int|null $ssh_keys_count
*
* @method static \Database\Factories\UserFactory factory(...$parameters)
* @method static Builder|User newModelQuery()
* @method static Builder|User newQuery()
* @method static Builder|User query()
* @method static Builder|User whereCreatedAt($value)
* @method static Builder|User whereEmail($value)
* @method static Builder|User whereExternalId($value)
* @method static Builder|User whereGravatar($value)
* @method static Builder|User whereId($value)
* @method static Builder|User whereLanguage($value)
* @method static Builder|User whereNameFirst($value)
* @method static Builder|User whereNameLast($value)
* @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereRootAdmin($value)
* @method static Builder|User whereTotpAuthenticatedAt($value)
* @method static Builder|User whereTotpSecret($value)
* @method static Builder|User whereUpdatedAt($value)
* @method static Builder|User whereUseTotp($value)
* @method static Builder|User whereUsername($value)
* @method static Builder|User whereUuid($value)
* @mixin \Eloquent
*/ */
class User extends Model implements class User extends Model implements
AuthenticatableContract, AuthenticatableContract,
@ -225,6 +259,11 @@ class User extends Model implements
return $this->hasMany(RecoveryToken::class); return $this->hasMany(RecoveryToken::class);
} }
public function sshKeys(): HasMany
{
return $this->hasMany(UserSSHKey::class);
}
/** /**
* Returns all of the servers that a user can access by way of being the owner of the * Returns all of the servers that a user can access by way of being the owner of the
* server, or because they are assigned as a subuser for that server. * server, or because they are assigned as a subuser for that server.

61
app/Models/UserSSHKey.php Normal file
View file

@ -0,0 +1,61 @@
<?php
namespace Pterodactyl\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* \Pterodactyl\Models\UserSSHKey.
*
* @property int $id
* @property int $user_id
* @property string $name
* @property string $fingerprint
* @property string $public_key
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property \Pterodactyl\Models\User $user
*
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey newQuery()
* @method static \Illuminate\Database\Query\Builder|UserSSHKey onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey query()
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey whereFingerprint($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey wherePublicKey($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserSSHKey whereUserId($value)
* @method static \Illuminate\Database\Query\Builder|UserSSHKey withTrashed()
* @method static \Illuminate\Database\Query\Builder|UserSSHKey withoutTrashed()
* @mixin \Eloquent
*/
class UserSSHKey extends Model
{
use SoftDeletes;
public const RESOURCE_NAME = 'ssh_key';
protected $table = 'user_ssh_keys';
protected $fillable = [
'name',
'public_key',
'fingerprint',
];
public static $validationRules = [
'name' => ['required', 'string'],
'fingerprint' => ['required', 'string'],
'public_key' => ['required', 'string'],
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\UserSSHKey;
class SSHKeyTransformer extends BaseClientTransformer
{
public function getResourceName(): string
{
return UserSSHKey::RESOURCE_NAME;
}
/**
* Return's a user's SSH key in an API response format.
*/
public function transform(UserSSHKey $model): array
{
return [
'name' => $model->name,
'fingerprint' => $model->fingerprint,
'public_key' => $model->public_key,
'created_at' => $model->created_at->toIso8601String(),
];
}
}

View file

@ -30,6 +30,7 @@
"league/flysystem-aws-s3-v3": "~1.0.29", "league/flysystem-aws-s3-v3": "~1.0.29",
"league/flysystem-memory": "~1.0.2", "league/flysystem-memory": "~1.0.2",
"matriphe/iso-639": "~1.2.0", "matriphe/iso-639": "~1.2.0",
"phpseclib/phpseclib": "~3.0",
"pragmarx/google2fa": "~5.0.0", "pragmarx/google2fa": "~5.0.0",
"predis/predis": "~1.1.10", "predis/predis": "~1.1.10",
"prologue/alerts": "~0.4.8", "prologue/alerts": "~0.4.8",

111
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "966e12710f76fb744c32e90103b9f823", "content-hash": "59024efe671be95afe14319b19606566",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -3266,6 +3266,115 @@
], ],
"time": "2021-12-04T23:24:31+00:00" "time": "2021-12-04T23:24:31+00:00"
}, },
{
"name": "phpseclib/phpseclib",
"version": "3.0.14",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "2f0b7af658cbea265cbb4a791d6c29a6613f98ef"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2f0b7af658cbea265cbb4a791d6c29a6613f98ef",
"reference": "2f0b7af658cbea265cbb4a791d6c29a6613f98ef",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.14"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2022-04-04T05:15:45+00:00"
},
{ {
"name": "pragmarx/google2fa", "name": "pragmarx/google2fa",
"version": "v5.0.0", "version": "v5.0.0",

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUserSshKeysTable extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
Schema::create('user_ssh_keys', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('user_id');
$table->string('name');
$table->string('fingerprint');
$table->text('public_key');
$table->timestamps();
$table->softDeletes();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down()
{
Schema::dropIfExists('user_ssh_keys');
}
}

View file

@ -0,0 +1,28 @@
import useSWR, { ConfigInterface } from 'swr';
import useUserSWRContentKey from '@/plugins/useUserSWRContentKey';
import http, { FractalResponseList } from '@/api/http';
import { SSHKey, Transformers } from '@definitions/user';
import { AxiosError } from 'axios';
const useSSHKeys = (config?: ConfigInterface<SSHKey[], AxiosError>) => {
const key = useUserSWRContentKey([ 'account', 'ssh-keys' ]);
return useSWR(key, async () => {
const { data } = await http.get('/api/client/account/ssh-keys');
return (data as FractalResponseList).data.map((datum: any) => {
return Transformers.toSSHKey(datum.attributes);
});
}, { revalidateOnMount: false, ...(config || {}) });
};
const createSSHKey = async (name: string, publicKey: string): Promise<SSHKey> => {
const { data } = await http.post('/api/client/account/ssh-keys', { name, public_key: publicKey });
return Transformers.toSSHKey(data.attributes);
};
const deleteSSHKey = async (fingerprint: string): Promise<void> =>
await http.delete(`/api/client/account/ssh-keys/${fingerprint}`);
export { useSSHKeys, createSSHKey, deleteSSHKey };

View file

@ -1,2 +1,8 @@
// empty export import { Model } from '@/api/definitions';
export type _T = string;
interface SSHKey extends Model {
name: string;
publicKey: string;
fingerprint: string;
createdAt: Date;
}

View file

@ -1,4 +1,14 @@
import { SSHKey } from '@definitions/user/models';
export default class Transformers { export default class Transformers {
static toSSHKey (data: Record<any, any>): SSHKey {
return {
name: data.name,
publicKey: data.public_key,
fingerprint: data.fingerprint,
createdAt: new Date(data.created_at),
};
}
} }
export class MetaTransformers { export class MetaTransformers {

View file

@ -0,0 +1,66 @@
import React, { useEffect } from 'react';
import ContentBox from '@/components/elements/ContentBox';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import FlashMessageRender from '@/components/FlashMessageRender';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
import { useSSHKeys } from '@/api/account/ssh-keys';
import { useFlashKey } from '@/plugins/useFlash';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey } from '@fortawesome/free-solid-svg-icons';
import { format } from 'date-fns';
import CreateSSHKeyForm from '@/components/dashboard/ssh/CreateSSHKeyForm';
import DeleteSSHKeyButton from '@/components/dashboard/ssh/DeleteSSHKeyButton';
export default () => {
const { clearAndAddHttpError } = useFlashKey('account');
const { data, isValidating, error } = useSSHKeys({
revalidateOnMount: true,
revalidateOnFocus: false,
});
useEffect(() => {
clearAndAddHttpError(error);
}, [ error ]);
return (
<PageContentBlock title={'Account API'}>
<FlashMessageRender byKey={'account'}/>
<div css={tw`md:flex flex-nowrap my-10`}>
<ContentBox title={'Add SSH Key'} css={tw`flex-none w-full md:w-1/2`}>
<CreateSSHKeyForm/>
</ContentBox>
<ContentBox title={'SSH Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
<SpinnerOverlay visible={!data && isValidating}/>
{
!data || !data.length ?
<p css={tw`text-center text-sm`}>
{!data ? 'Loading...' : 'No SSH Keys exist for this account.'}
</p>
:
data.map((key, index) => (
<GreyRowBox
key={key.fingerprint}
css={[ tw`bg-neutral-600 flex space-x-4 items-center`, index > 0 && tw`mt-2` ]}
>
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
<div css={tw`flex-1`}>
<p css={tw`text-sm break-words font-medium`}>{key.name}</p>
<p css={tw`text-xs mt-1 font-mono truncate`}>
SHA256:{key.fingerprint}
</p>
<p css={tw`text-xs mt-1 text-neutral-300 uppercase`}>
Added on:&nbsp;
{format(key.createdAt, 'MMM do, yyyy HH:mm')}
</p>
</div>
<DeleteSSHKeyButton fingerprint={key.fingerprint} />
</GreyRowBox>
))
}
</ContentBox>
</div>
</PageContentBlock>
);
};

View file

@ -0,0 +1,67 @@
import React from 'react';
import { Field, Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input';
import styled from 'styled-components/macro';
import { useFlashKey } from '@/plugins/useFlash';
import { createSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
interface Values {
name: string;
publicKey: string;
}
const CustomTextarea = styled(Textarea)`${tw`h-32`}`;
export default () => {
const { clearAndAddHttpError } = useFlashKey('account');
const { mutate } = useSSHKeys();
const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearAndAddHttpError();
createSSHKey(values.name, values.publicKey)
.then((key) => {
resetForm();
mutate((data) => (data || []).concat(key));
})
.catch((error) => clearAndAddHttpError(error))
.then(() => setSubmitting(false));
};
return (
<>
<Formik
onSubmit={submit}
initialValues={{ name: '', publicKey: '' }}
validationSchema={object().shape({
name: string().required(),
publicKey: string().required(),
})}
>
{({ isSubmitting }) => (
<Form>
<SpinnerOverlay visible={isSubmitting}/>
<FormikFieldWrapper label={'SSH Key Name'} name={'name'} css={tw`mb-6`}>
<Field name={'name'} as={Input}/>
</FormikFieldWrapper>
<FormikFieldWrapper
label={'Public Key'}
name={'publicKey'}
description={'Enter your public SSH key.'}
>
<Field name={'publicKey'} as={CustomTextarea}/>
</FormikFieldWrapper>
<div css={tw`flex justify-end mt-6`}>
<Button>Save</Button>
</div>
</Form>
)}
</Formik>
</>
);
};

View file

@ -0,0 +1,46 @@
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 { useFlashKey } from '@/plugins/useFlash';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import { deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
export default ({ fingerprint }: { fingerprint: string }) => {
const { clearAndAddHttpError } = useFlashKey('account');
const [ visible, setVisible ] = useState(false);
const { mutate } = useSSHKeys();
const onClick = () => {
clearAndAddHttpError();
Promise.all([
mutate((data) => data?.filter((value) => value.fingerprint !== fingerprint), false),
deleteSSHKey(fingerprint),
])
.catch((error) => {
mutate(undefined, true);
clearAndAddHttpError(error);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Confirm Key Deletion'}
buttonText={'Yes, Delete SSH Key'}
onConfirmed={onClick}
onModalDismissed={() => setVisible(false)}
>
Are you sure you wish to delete this SSH key?
</ConfirmationModal>
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setVisible(true)}>
<FontAwesomeIcon
icon={faTrashAlt}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
/>
</button>
</>
);
};

View file

@ -2,8 +2,23 @@ import { Actions, useStoreActions } from 'easy-peasy';
import { FlashStore } from '@/state/flashes'; import { FlashStore } from '@/state/flashes';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
interface KeyedFlashStore {
clearFlashes: () => void;
clearAndAddHttpError: (error?: Error | string | null) => void;
}
const useFlash = (): Actions<FlashStore> => { const useFlash = (): Actions<FlashStore> => {
return useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); return useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
}; };
const useFlashKey = (key: string): KeyedFlashStore => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
return {
clearFlashes: () => clearFlashes(key),
clearAndAddHttpError: (error) => clearAndAddHttpError({ key, error }),
};
};
export { useFlashKey };
export default useFlash; export default useFlash;

View file

@ -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()}`;
};

View file

@ -7,6 +7,7 @@ import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
import { NotFound } from '@/components/elements/ScreenBlock'; import { NotFound } from '@/components/elements/ScreenBlock';
import TransitionRouter from '@/TransitionRouter'; import TransitionRouter from '@/TransitionRouter';
import SubNavigation from '@/components/elements/SubNavigation'; import SubNavigation from '@/components/elements/SubNavigation';
import AccountSSHContainer from '@/components/dashboard/ssh/AccountSSHContainer';
export default ({ location }: RouteComponentProps) => ( export default ({ location }: RouteComponentProps) => (
<> <>
@ -16,6 +17,7 @@ export default ({ location }: RouteComponentProps) => (
<div> <div>
<NavLink to={'/account'} exact>Settings</NavLink> <NavLink to={'/account'} exact>Settings</NavLink>
<NavLink to={'/account/api'}>API Credentials</NavLink> <NavLink to={'/account/api'}>API Credentials</NavLink>
<NavLink to={'/account/ssh'}>SSH Keys</NavLink>
</div> </div>
</SubNavigation> </SubNavigation>
} }
@ -30,6 +32,9 @@ export default ({ location }: RouteComponentProps) => (
<Route path={'/account/api'} exact> <Route path={'/account/api'} exact>
<AccountApiContainer/> <AccountApiContainer/>
</Route> </Route>
<Route path={'/account/ssh'} exact>
<AccountSSHContainer/>
</Route>
<Route path={'*'}> <Route path={'*'}>
<NotFound/> <NotFound/>
</Route> </Route>

View file

@ -6,7 +6,7 @@ export interface FlashStore {
items: FlashMessage[]; items: FlashMessage[];
addFlash: Action<FlashStore, FlashMessage>; addFlash: Action<FlashStore, FlashMessage>;
addError: Action<FlashStore, { message: string; key?: string }>; addError: Action<FlashStore, { message: string; key?: string }>;
clearAndAddHttpError: Action<FlashStore, { error: any, key?: string }>; clearAndAddHttpError: Action<FlashStore, { error?: Error | any | null; key?: string }>;
clearFlashes: Action<FlashStore, string | void>; clearFlashes: Action<FlashStore, string | void>;
} }
@ -29,8 +29,19 @@ const flashes: FlashStore = {
state.items.push({ type: 'error', title: 'Error', ...payload }); state.items.push({ type: 'error', title: 'Error', ...payload });
}), }),
clearAndAddHttpError: action((state, { key, error }) => { clearAndAddHttpError: action((state, payload) => {
state.items = [ { type: 'error', title: 'Error', key, message: httpErrorToHuman(error) } ]; if (!payload.error) {
state.items = [];
} else {
console.error(payload.error);
state.items = [ {
type: 'error',
title: 'Error',
key: payload.key,
message: httpErrorToHuman(payload.error),
} ];
}
}), }),
clearFlashes: action((state, payload) => { clearFlashes: action((state, payload) => {

View file

@ -29,6 +29,12 @@ Route::group(['prefix' => '/account'], function () {
Route::get('/api-keys', [Client\ApiKeyController::class, 'index']); Route::get('/api-keys', [Client\ApiKeyController::class, 'index']);
Route::post('/api-keys', [Client\ApiKeyController::class, 'store']); Route::post('/api-keys', [Client\ApiKeyController::class, 'store']);
Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']); Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']);
Route::prefix('/ssh-keys')->group(function () {
Route::get('/', [Client\SSHKeyController::class, 'index']);
Route::post('/', [Client\SSHKeyController::class, 'store']);
Route::delete('/{identifier}', [Client\SSHKeyController::class, 'delete']);
});
}); });
/* /*