UI tweaking and transformer for the stored keys
This commit is contained in:
parent
81a6a8653f
commit
5a4d1a668f
10 changed files with 179 additions and 80 deletions
|
@ -12,9 +12,10 @@ use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Pterodactyl\Exceptions\DisplayException;
|
use Pterodactyl\Exceptions\DisplayException;
|
||||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
|
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
|
||||||
|
use Pterodactyl\Transformers\Api\Client\SecurityKeyTransformer;
|
||||||
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||||
|
use Pterodactyl\Services\Users\SecurityKeys\StoreSecurityKeyService;
|
||||||
use Pterodactyl\Http\Requests\Api\Client\Account\RegisterWebauthnTokenRequest;
|
use Pterodactyl\Http\Requests\Api\Client\Account\RegisterWebauthnTokenRequest;
|
||||||
use Pterodactyl\Repositories\SecurityKeys\PublicKeyCredentialSourceRepository;
|
|
||||||
use Pterodactyl\Services\Users\SecurityKeys\CreatePublicKeyCredentialsService;
|
use Pterodactyl\Services\Users\SecurityKeys\CreatePublicKeyCredentialsService;
|
||||||
|
|
||||||
class SecurityKeyController extends ClientApiController
|
class SecurityKeyController extends ClientApiController
|
||||||
|
@ -25,9 +26,12 @@ class SecurityKeyController extends ClientApiController
|
||||||
|
|
||||||
protected WebauthnServerRepository $webauthnServerRepository;
|
protected WebauthnServerRepository $webauthnServerRepository;
|
||||||
|
|
||||||
|
protected StoreSecurityKeyService $storeSecurityKeyService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Repository $cache,
|
Repository $cache,
|
||||||
WebauthnServerRepository $webauthnServerRepository,
|
WebauthnServerRepository $webauthnServerRepository,
|
||||||
|
StoreSecurityKeyService $storeSecurityKeyService,
|
||||||
CreatePublicKeyCredentialsService $createPublicKeyCredentials
|
CreatePublicKeyCredentialsService $createPublicKeyCredentials
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
@ -35,6 +39,7 @@ class SecurityKeyController extends ClientApiController
|
||||||
$this->cache = $cache;
|
$this->cache = $cache;
|
||||||
$this->webauthnServerRepository = $webauthnServerRepository;
|
$this->webauthnServerRepository = $webauthnServerRepository;
|
||||||
$this->createPublicKeyCredentials = $createPublicKeyCredentials;
|
$this->createPublicKeyCredentials = $createPublicKeyCredentials;
|
||||||
|
$this->storeSecurityKeyService = $storeSecurityKeyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -42,7 +47,9 @@ class SecurityKeyController extends ClientApiController
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): array
|
public function index(Request $request): array
|
||||||
{
|
{
|
||||||
return [];
|
return $this->fractal->collection($request->user()->securityKeys)
|
||||||
|
->transformWith(SecurityKeyTransformer::class)
|
||||||
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -74,7 +81,7 @@ class SecurityKeyController extends ClientApiController
|
||||||
* @throws \Pterodactyl\Exceptions\DisplayException
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
* @throws \Throwable
|
* @throws \Throwable
|
||||||
*/
|
*/
|
||||||
public function store(RegisterWebauthnTokenRequest $request): JsonResponse
|
public function store(RegisterWebauthnTokenRequest $request): array
|
||||||
{
|
{
|
||||||
$credentials = unserialize(
|
$credentials = unserialize(
|
||||||
$this->cache->pull("register-security-key:{$request->input('token_id')}", serialize(null))
|
$this->cache->pull("register-security-key:{$request->input('token_id')}", serialize(null))
|
||||||
|
@ -88,35 +95,24 @@ class SecurityKeyController extends ClientApiController
|
||||||
throw new DisplayException('Could not register security key: invalid data present in session, please try again.');
|
throw new DisplayException('Could not register security key: invalid data present in session, please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$source = $this->webauthnServerRepository->getServer($request->user())
|
$key = $this->storeSecurityKeyService
|
||||||
->loadAndCheckAttestationResponse(
|
->setRequest($this->getServerRequest($request))
|
||||||
json_encode($request->input('registration')),
|
->setKeyName($request->input('name'))
|
||||||
$credentials,
|
->handle($request->user(), $request->input('registration'), $credentials);
|
||||||
$this->getServerRequest($request),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Unfortunately this repository interface doesn't define a response — it is explicitly
|
return $this->fractal->item($key)
|
||||||
// void — so we need to just query the database immediately after this to pull the information
|
->transformWith(SecurityKeyTransformer::class)
|
||||||
// we just stored to return to the caller.
|
->toArray();
|
||||||
PublicKeyCredentialSourceRepository::factory($request->user())->saveCredentialSource($source);
|
|
||||||
|
|
||||||
$created = $request->user()->securityKeys()
|
|
||||||
->where('public_key_id', base64_encode($source->getPublicKeyCredentialId()))
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$created->update(['name' => $request->input('name')]);
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'data' => [],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a WebAuthn key from a user's account.
|
* Removes a WebAuthn key from a user's account.
|
||||||
*/
|
*/
|
||||||
public function delete(Request $request, int $webauthnKeyId): JsonResponse
|
public function delete(Request $request, string $securityKey): JsonResponse
|
||||||
{
|
{
|
||||||
return new JsonResponse([]);
|
$request->user()->securityKeys()->where('uuid', $securityKey)->delete();
|
||||||
|
|
||||||
|
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getServerRequest(Request $request): ServerRequestInterface
|
protected function getServerRequest(Request $request): ServerRequestInterface
|
||||||
|
|
74
app/Services/Users/SecurityKeys/StoreSecurityKeyService.php
Normal file
74
app/Services/Users/SecurityKeys/StoreSecurityKeyService.php
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Services\Users\SecurityKeys;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use Webmozart\Assert\Assert;
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
|
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||||
|
use Pterodactyl\Repositories\SecurityKeys\PublicKeyCredentialSourceRepository;
|
||||||
|
|
||||||
|
class StoreSecurityKeyService
|
||||||
|
{
|
||||||
|
protected WebauthnServerRepository $serverRepository;
|
||||||
|
|
||||||
|
protected ?ServerRequestInterface $request = null;
|
||||||
|
|
||||||
|
protected ?string $keyName = null;
|
||||||
|
|
||||||
|
public function __construct(WebauthnServerRepository $serverRepository)
|
||||||
|
{
|
||||||
|
$this->serverRepository = $serverRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the server request interface on the service, this is needed by the attestation
|
||||||
|
* checking service on the Webauthn server.
|
||||||
|
*/
|
||||||
|
public function setRequest(ServerRequestInterface $request): self
|
||||||
|
{
|
||||||
|
$this->request = $request;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the security key's name. If not provided a random string will be used.
|
||||||
|
*/
|
||||||
|
public function setKeyName(?string $name): self
|
||||||
|
{
|
||||||
|
$this->keyName = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and stores a new hardware security key on a user's account.
|
||||||
|
*
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function handle(User $user, array $registration, PublicKeyCredentialCreationOptions $options): SecurityKey
|
||||||
|
{
|
||||||
|
Assert::notNull($this->request, 'A request interface must be set on the service before it can be called.');
|
||||||
|
|
||||||
|
$source = $this->serverRepository->getServer($user)
|
||||||
|
->loadAndCheckAttestationResponse(json_encode($registration), $options, $this->request);
|
||||||
|
|
||||||
|
// Unfortunately this repository interface doesn't define a response — it is explicitly
|
||||||
|
// void — so we need to just query the database immediately after this to pull the information
|
||||||
|
// we just stored to return to the caller.
|
||||||
|
PublicKeyCredentialSourceRepository::factory($user)->saveCredentialSource($source);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Models\SecurityKey $created */
|
||||||
|
$created = $user->securityKeys()
|
||||||
|
->where('public_key_id', base64_encode($source->getPublicKeyCredentialId()))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$created->update(['name' => $this->keyName ?? 'Security Key (' . Str::random() . ')']);
|
||||||
|
|
||||||
|
return $created;
|
||||||
|
}
|
||||||
|
}
|
26
app/Transformers/Api/Client/SecurityKeyTransformer.php
Normal file
26
app/Transformers/Api/Client/SecurityKeyTransformer.php
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Transformers\Api\Client;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
|
use Pterodactyl\Transformers\Api\Transformer;
|
||||||
|
|
||||||
|
class SecurityKeyTransformer extends Transformer
|
||||||
|
{
|
||||||
|
public function getResourceName(): string
|
||||||
|
{
|
||||||
|
return SecurityKey::RESOURCE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transform(SecurityKey $key): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $key->uuid,
|
||||||
|
'name' => $key->name,
|
||||||
|
'type' => $key->type,
|
||||||
|
'public_key_id' => base64_encode($key->public_key_id),
|
||||||
|
'created_at' => self::formatTimestamp($key->created_at),
|
||||||
|
'updated_at' => self::formatTimestamp($key->updated_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default async (uuid: string): Promise<void> => {
|
||||||
|
await http.delete(`/api/client/account/security-keys/${uuid}`);
|
||||||
|
};
|
|
@ -1,9 +0,0 @@
|
||||||
import http from '@/api/http';
|
|
||||||
|
|
||||||
export default (id: number): Promise<void> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
http.delete(`/api/client/account/webauthn/${id}`)
|
|
||||||
.then(() => resolve())
|
|
||||||
.catch(reject);
|
|
||||||
});
|
|
||||||
};
|
|
25
resources/scripts/api/account/webauthn/getSecurityKeys.ts
Normal file
25
resources/scripts/api/account/webauthn/getSecurityKeys.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export interface SecurityKey {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
type: 'public-key';
|
||||||
|
publicKeyId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToSecurityKey = (data: any): SecurityKey => ({
|
||||||
|
uuid: data.uuid,
|
||||||
|
name: data.name,
|
||||||
|
type: data.type,
|
||||||
|
publicKeyId: data.public_key_id,
|
||||||
|
createdAt: new Date(data.created_at),
|
||||||
|
updatedAt: new Date(data.updated_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async (): Promise<SecurityKey[]> => {
|
||||||
|
const { data } = await http.get('/api/client/account/security-keys');
|
||||||
|
|
||||||
|
return (data.data || []).map((datum: any) => rawDataToSecurityKey(datum.attributes));
|
||||||
|
};
|
|
@ -1,23 +0,0 @@
|
||||||
import http from '@/api/http';
|
|
||||||
|
|
||||||
export interface WebauthnKey {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
createdAt: Date;
|
|
||||||
lastUsedAt: Date | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rawDataToWebauthnKey = (data: any): WebauthnKey => ({
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
createdAt: new Date(data.created_at),
|
|
||||||
lastUsedAt: data.last_used_at ? new Date(data.last_used_at) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default (): Promise<WebauthnKey[]> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
http.get('/api/client/account/security-keys')
|
|
||||||
.then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToWebauthnKey(d.attributes))))
|
|
||||||
.catch(reject);
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -1,4 +1,5 @@
|
||||||
import http from '@/api/http';
|
import http from '@/api/http';
|
||||||
|
import { rawDataToSecurityKey, SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
|
||||||
|
|
||||||
export const base64Decode = (input: string): string => {
|
export const base64Decode = (input: string): string => {
|
||||||
input = input.replace(/-/g, '+').replace(/_/g, '/');
|
input = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
@ -27,7 +28,7 @@ export const decodeCredentials = (credentials: PublicKeyCredentialDescriptor[])
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential) => {
|
const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential): Promise<SecurityKey> => {
|
||||||
const { data } = await http.post('/api/client/account/security-keys/register', {
|
const { data } = await http.post('/api/client/account/security-keys/register', {
|
||||||
name,
|
name,
|
||||||
token_id: tokenId,
|
token_id: tokenId,
|
||||||
|
@ -42,10 +43,10 @@ const registerCredentialForAccount = async (name: string, tokenId: string, crede
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(data.data);
|
return rawDataToSecurityKey(data.attributes);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const register = async (name: string): Promise<void> => {
|
export default async (name: string): Promise<SecurityKey> => {
|
||||||
const { data } = await http.get('/api/client/account/security-keys/register');
|
const { data } = await http.get('/api/client/account/security-keys/register');
|
||||||
|
|
||||||
const publicKey = data.data.credentials;
|
const publicKey = data.data.credentials;
|
||||||
|
@ -62,5 +63,5 @@ export const register = async (name: string): Promise<void> => {
|
||||||
throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`);
|
throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await registerCredentialForAccount(name, data.data.token_id, credentials);
|
return await registerCredentialForAccount(name, data.data.token_id, credentials);
|
||||||
};
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
import http from '@/api/http';
|
import http from '@/api/http';
|
||||||
import { LoginResponse } from '@/api/auth/login';
|
import { LoginResponse } from '@/api/auth/login';
|
||||||
import { base64Decode, bufferDecode, bufferEncode, decodeCredentials } from '@/api/account/webauthn/registerWebauthnKey';
|
import { base64Decode, bufferDecode, bufferEncode, decodeCredentials } from '@/api/account/webauthn/registerSecurityKey';
|
||||||
|
|
||||||
export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Promise<LoginResponse> => {
|
export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Promise<LoginResponse> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
|
@ -4,10 +4,10 @@ import { Form, Formik, FormikHelpers } from 'formik';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { object, string } from 'yup';
|
import { object, string } from 'yup';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faFingerprint, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
import deleteWebauthnKey from '@/api/account/webauthn/deleteWebauthnKey';
|
import deleteWebauthnKey from '@/api/account/webauthn/deleteSecurityKey';
|
||||||
import getWebauthnKeys, { WebauthnKey } from '@/api/account/webauthn/getWebauthnKeys';
|
import getWebauthnKeys, { SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
|
||||||
import { register } from '@/api/account/webauthn/registerWebauthnKey';
|
import registerSecurityKey from '@/api/account/webauthn/registerSecurityKey';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import ContentBox from '@/components/elements/ContentBox';
|
import ContentBox from '@/components/elements/ContentBox';
|
||||||
|
@ -22,15 +22,16 @@ interface Values {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: WebauthnKey) => void }) => {
|
const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) => void }) => {
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
|
||||||
const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
|
const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
|
||||||
clearFlashes('security_keys');
|
clearFlashes('security_keys');
|
||||||
|
|
||||||
register(name)
|
registerSecurityKey(name)
|
||||||
.then(() => {
|
.then(key => {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
onKeyAdded(key);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
@ -69,20 +70,20 @@ const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: WebauthnKey) =>
|
||||||
export default () => {
|
export default () => {
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
|
||||||
const [ keys, setKeys ] = useState<WebauthnKey[]>([]);
|
const [ keys, setKeys ] = useState<SecurityKey[]>([]);
|
||||||
const [ loading, setLoading ] = useState(true);
|
const [ loading, setLoading ] = useState(true);
|
||||||
const [ deleteId, setDeleteId ] = useState<number | null>(null);
|
const [ deleteId, setDeleteId ] = useState<string | null>(null);
|
||||||
|
|
||||||
const doDeletion = (id: number | null) => {
|
const doDeletion = (uuid: string | null) => {
|
||||||
if (id === null) {
|
if (uuid === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFlashes('security_keys');
|
clearFlashes('security_keys');
|
||||||
|
|
||||||
deleteWebauthnKey(id)
|
deleteWebauthnKey(uuid)
|
||||||
.then(() => setKeys(s => ([
|
.then(() => setKeys(s => ([
|
||||||
...(s || []).filter(key => key.id !== id),
|
...(s || []).filter(key => key.uuid !== uuid),
|
||||||
])))
|
])))
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -132,16 +133,19 @@ export default () => {
|
||||||
: null
|
: null
|
||||||
:
|
:
|
||||||
keys.map((key, index) => (
|
keys.map((key, index) => (
|
||||||
<GreyRowBox key={index} css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}>
|
<GreyRowBox
|
||||||
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
|
key={index}
|
||||||
|
css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFingerprint} css={tw`text-neutral-300`}/>
|
||||||
<div css={tw`ml-4 flex-1 overflow-hidden`}>
|
<div css={tw`ml-4 flex-1 overflow-hidden`}>
|
||||||
<p css={tw`text-sm break-words`}>{key.name}</p>
|
<p css={tw`text-sm break-words`}>{key.name}</p>
|
||||||
<p css={tw`text-2xs text-neutral-300 uppercase`}>
|
<p css={tw`text-2xs text-neutral-300 uppercase`}>
|
||||||
Last used:
|
Created at:
|
||||||
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'}
|
{key.createdAt ? format(key.createdAt, 'MMM do, yyyy HH:mm') : 'Never'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteId(key.id)}>
|
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteId(key.uuid)}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faTrashAlt}
|
icon={faTrashAlt}
|
||||||
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
|
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
|
||||||
|
|
Loading…
Reference in a new issue