Make personal access tokens soft-deletable; update front-end

This commit is contained in:
Dane Everitt 2022-02-20 13:07:12 -05:00
parent 4cbdaa6699
commit cb4d4b5ce6
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
13 changed files with 184 additions and 133 deletions

View file

@ -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';

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class MakePersonalAccessTokensSoftDeletable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->dropColumn('deleted_at');
});
}
}

View file

@ -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<PersonalAccessToken[], AxiosError>,
): SWRResponse<PersonalAccessToken[], AxiosError> => {
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 };

View file

@ -1,17 +0,0 @@
import http from '@/api/http';
import { ApiKey, rawDataToApiKey } from '@/api/account/getApiKeys';
export default (description: string, allowedIps: string): Promise<ApiKey & { secretToken: string }> => {
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);
});
};

View file

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (identifier: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/account/api-keys/${identifier}`)
.then(() => resolve())
.catch(reject);
});
};

View file

@ -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<ApiKey[]> => {
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);
});
};

View file

@ -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<SecurityKey[], AxiosError>): SWRResponse<SecurityKey[], AxiosError> => {
const uuid = useStoreState(state => state.user.data!.uuid);
const key = useUserSWRContextKey([ 'account', 'security-keys' ]);
return useSWR<SecurityKey[], AxiosError>(
[ 'account', uuid, 'security-keys' ],
key,
async (): Promise<SecurityKey[]> => {
const { data } = await http.get('/api/client/account/security-keys');

View file

@ -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;
}

View file

@ -11,4 +11,14 @@ export default class Transformers {
updatedAt: new Date(data.updated_at),
};
}
static toPersonalAccessToken (data: Record<string, any>): 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,
};
}
}

View file

@ -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<ApiKey[]>([]);
const [ loading, setLoading ] = useState(true);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => 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 (
<PageContentBlock title={'Account API'}>
<FlashMessageRender byKey={'account'}/>
<div css={tw`md:flex flex-nowrap my-10`}>
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/>
<CreateApiKeyForm/>
</ContentBox>
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
<SpinnerOverlay visible={loading}/>
<ConfirmationModal
visible={!!deleteIdentifier}
title={'Confirm key deletion'}
buttonText={'Yes, delete key'}
onConfirmed={() => {
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.
</ConfirmationModal>
<SpinnerOverlay visible={!keys && isValidating}/>
{
keys.length === 0 ?
!keys || !keys.length ?
<p css={tw`text-center text-sm`}>
{loading ? 'Loading...' : 'No API keys exist for this account.'}
{!keys ? 'Loading...' : 'No API keys exist for this account.'}
</p>
:
keys.map((key, index) => (
@ -93,15 +57,7 @@ export default () => {
{key.identifier}
</code>
</p>
<button
css={tw`ml-4 p-2 text-sm`}
onClick={() => setDeleteIdentifier(key.identifier)}
>
<FontAwesomeIcon
icon={faTrashAlt}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
/>
</button>
<DeleteAPIKeyButton identifier={key.identifier}/>
</GreyRowBox>
))
}

View file

@ -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<ApplicationStore>) => actions.flashes);
const { mutate } = useAPIKeys();
const { clearAndAddHttpError } = useFlashKey('account');
const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
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 (

View file

@ -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 (
<>
<ConfirmationModal
visible={visible}
title={'Confirm Key Deletion'}
buttonText={'Yes, Delete Key'}
onConfirmed={onClick}
onModalDismissed={() => setVisible(false)}
>
Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail.
</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

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