Make personal access tokens soft-deletable; update front-end
This commit is contained in:
parent
4cbdaa6699
commit
cb4d4b5ce6
13 changed files with 184 additions and 133 deletions
|
@ -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';
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
33
resources/scripts/api/account/api-keys.ts
Normal file
33
resources/scripts/api/account/api-keys.ts
Normal 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 };
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
|
||||
|
|
8
resources/scripts/api/types/models.d.ts
vendored
8
resources/scripts/api/types/models.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
))
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
12
resources/scripts/plugins/useUserSWRContextKey.ts
Normal file
12
resources/scripts/plugins/useUserSWRContextKey.ts
Normal 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()}`;
|
||||
};
|
Loading…
Reference in a new issue