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 Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\Contracts\HasAbilities;
|
use Laravel\Sanctum\Contracts\HasAbilities;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
class PersonalAccessToken extends Model implements HasAbilities
|
class PersonalAccessToken extends Model implements HasAbilities
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
public const RESOURCE_NAME = 'personal_access_token';
|
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 useSWR, { SWRConfiguration, SWRResponse } from 'swr';
|
||||||
import { useStoreState } from '@/state/hooks';
|
|
||||||
import http, { FractalResponseList } from '@/api/http';
|
import http, { FractalResponseList } from '@/api/http';
|
||||||
import Transformers from '@transformers';
|
import Transformers from '@transformers';
|
||||||
import { SecurityKey } from '@models';
|
import { SecurityKey } from '@models';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { base64Decode, bufferDecode, bufferEncode, decodeSecurityKeyCredentials } from '@/helpers';
|
import { base64Decode, bufferDecode, bufferEncode, decodeSecurityKeyCredentials } from '@/helpers';
|
||||||
import { LoginResponse } from '@/api/auth/login';
|
import { LoginResponse } from '@/api/auth/login';
|
||||||
|
import useUserSWRContextKey from '@/plugins/useUserSWRContextKey';
|
||||||
|
|
||||||
const useSecurityKeys = (config?: SWRConfiguration<SecurityKey[], AxiosError>): SWRResponse<SecurityKey[], AxiosError> => {
|
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>(
|
return useSWR<SecurityKey[], AxiosError>(
|
||||||
[ 'account', uuid, 'security-keys' ],
|
key,
|
||||||
async (): Promise<SecurityKey[]> => {
|
async (): Promise<SecurityKey[]> => {
|
||||||
const { data } = await http.get('/api/client/account/security-keys');
|
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;
|
createdAt: Date;
|
||||||
updatedAt: 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),
|
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 ContentBox from '@/components/elements/ContentBox';
|
||||||
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
|
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
|
||||||
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
|
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faKey } 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 FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
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 () => {
|
export default () => {
|
||||||
const [ deleteIdentifier, setDeleteIdentifier ] = useState('');
|
const { clearAndAddHttpError } = useFlashKey('account');
|
||||||
const [ keys, setKeys ] = useState<ApiKey[]>([]);
|
const { data: keys, isValidating, error } = useAPIKeys({
|
||||||
const [ loading, setLoading ] = useState(true);
|
revalidateOnMount: true,
|
||||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearFlashes('account');
|
clearAndAddHttpError(error);
|
||||||
getApiKeys()
|
}, [ error ]);
|
||||||
.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 (
|
return (
|
||||||
<PageContentBlock title={'Account API'}>
|
<PageContentBlock title={'Account API'}>
|
||||||
<FlashMessageRender byKey={'account'}/>
|
<FlashMessageRender byKey={'account'}/>
|
||||||
<div css={tw`md:flex flex-nowrap my-10`}>
|
<div css={tw`md:flex flex-nowrap my-10`}>
|
||||||
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
|
<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>
|
||||||
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
|
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
|
||||||
<SpinnerOverlay visible={loading}/>
|
<SpinnerOverlay visible={!keys && isValidating}/>
|
||||||
<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>
|
|
||||||
{
|
{
|
||||||
keys.length === 0 ?
|
!keys || !keys.length ?
|
||||||
<p css={tw`text-center text-sm`}>
|
<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>
|
</p>
|
||||||
:
|
:
|
||||||
keys.map((key, index) => (
|
keys.map((key, index) => (
|
||||||
|
@ -93,15 +57,7 @@ export default () => {
|
||||||
{key.identifier}
|
{key.identifier}
|
||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
<button
|
<DeleteAPIKeyButton identifier={key.identifier}/>
|
||||||
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>
|
|
||||||
</GreyRowBox>
|
</GreyRowBox>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,41 +2,41 @@ import React, { useState } from 'react';
|
||||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||||
import { object, string } from 'yup';
|
import { object, string } from 'yup';
|
||||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
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 SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import { ApiKey } from '@/api/account/getApiKeys';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import Input from '@/components/elements/Input';
|
import Input from '@/components/elements/Input';
|
||||||
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
||||||
|
import { createAPIKey, useAPIKeys } from '@/api/account/api-keys';
|
||||||
|
import { useFlashKey } from '@/plugins/useFlash';
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
description: string;
|
description: string;
|
||||||
allowedIps: string;
|
allowedIps: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
export default () => {
|
||||||
const [ apiKey, setApiKey ] = useState('');
|
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>) => {
|
const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
|
||||||
clearFlashes('account');
|
clearAndAddHttpError();
|
||||||
createApiKey(values.description, values.allowedIps)
|
|
||||||
.then(({ secretToken, ...key }) => {
|
|
||||||
resetForm();
|
|
||||||
setSubmitting(false);
|
|
||||||
setApiKey(secretToken);
|
|
||||||
onKeyCreated(key);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
addError({ key: 'account', message: httpErrorToHuman(error) });
|
createAPIKey(values.description)
|
||||||
setSubmitting(false);
|
.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 (
|
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