feat(dashboard-v2): implement data mutations for API keys
This commit is contained in:
parent
ddd109ab0d
commit
4c8328c21f
|
@ -0,0 +1,123 @@
|
|||
import dayjs from "dayjs";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Alert } from "../Alert";
|
||||
import { Button } from "../Button";
|
||||
import { AddSkylinkToAPIKeyForm } from "../forms/AddSkylinkToAPIKeyForm";
|
||||
import { CogIcon, TrashIcon } from "../Icons";
|
||||
import { Modal } from "../Modal";
|
||||
import { useAPIKeyEdit } from "./useAPIKeyEdit";
|
||||
import { useAPIKeyRemoval } from "./useAPIKeyRemoval";
|
||||
|
||||
export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
|
||||
const { id, name, createdAt, skylinks } = apiKey;
|
||||
const isPublic = apiKey.public === "true";
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const onSkylinkListEdited = useCallback(() => {
|
||||
setError(null);
|
||||
onEdited();
|
||||
}, [onEdited]);
|
||||
|
||||
const onSkylinkListEditFailure = (errorMessage) => setError(errorMessage);
|
||||
|
||||
const {
|
||||
removalError,
|
||||
removalInitiated,
|
||||
prompt: promptRemoval,
|
||||
abort: abortRemoval,
|
||||
confirm: confirmRemoval,
|
||||
} = useAPIKeyRemoval({
|
||||
key: apiKey,
|
||||
onSuccess: onRemoved,
|
||||
onFailure: onRemovalError,
|
||||
});
|
||||
|
||||
const {
|
||||
editInitiated,
|
||||
prompt: promptEdit,
|
||||
abort: abortEdit,
|
||||
removeSkylink,
|
||||
} = useAPIKeyEdit({
|
||||
key: apiKey,
|
||||
onSkylinkRemoved: onSkylinkListEdited,
|
||||
onSkylinkRemovalFailure: onSkylinkListEditFailure,
|
||||
});
|
||||
|
||||
const closeEditModal = useCallback(() => {
|
||||
setError(null);
|
||||
abortEdit();
|
||||
}, [abortEdit]);
|
||||
|
||||
return (
|
||||
<li className="grid grid-cols-2 sm:grid-cols-[1fr_repeat(2,_max-content)] py-6 sm:py-4 px-4 gap-x-8 bg-white odd:bg-palette-100/50">
|
||||
<span className="truncate col-span-2 sm:col-span-1">{name || id}</span>
|
||||
<span className="col-span-2 my-4 border-t border-t-palette-200/50 sm:hidden" />
|
||||
<span className="text-palette-400">{dayjs(createdAt).format("MMM DD, YYYY")}</span>
|
||||
<span className="flex items-center justify-end">
|
||||
{isPublic && (
|
||||
<button
|
||||
title="Add or remove Skylinks"
|
||||
className="p-1 transition-colors hover:text-primary"
|
||||
onClick={() => promptEdit({ id, name, skylinks })}
|
||||
>
|
||||
<CogIcon size={22} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title="Delete this API key"
|
||||
className="p-1 transition-colors hover:text-error"
|
||||
onClick={() => promptRemoval({ id, name })}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
{removalInitiated && (
|
||||
<Modal onClose={abortRemoval} className="flex flex-col gap-4 text-center">
|
||||
<h4>Delete API key</h4>
|
||||
<div>
|
||||
<p>Are you sure you want to delete the following API key?</p>
|
||||
<p className="font-semibold">{name || id}</p>
|
||||
</div>
|
||||
{removalError && <Alert $variant="error">{removalError}</Alert>}
|
||||
|
||||
<div className="flex gap-4 justify-center mt-4">
|
||||
<Button $primary onClick={abortRemoval}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmRemoval}>Delete</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{editInitiated && (
|
||||
<Modal onClose={closeEditModal} className="flex flex-col gap-4 text-center sm:px-8 sm:py-6">
|
||||
<h4>Covered Skylinks</h4>
|
||||
{skylinks?.length > 0 ? (
|
||||
<ul className="text-xs flex flex-col gap-2">
|
||||
{skylinks.map((skylink) => (
|
||||
<li className="grid grid-cols-[1fr_min-content] w-full gap-4 items-center">
|
||||
<code className="whitespace-nowrap select-all truncate bg-palette-100 odd:bg-white rounded border border-palette-200/50 p-1">
|
||||
{skylink}
|
||||
</code>
|
||||
<button className="p-1 transition-colors hover:text-error" onClick={() => removeSkylink(skylink)}>
|
||||
<TrashIcon size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Alert $variant="info">No skylinks here yet. You can add the first one below 🙃</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <Alert $variant="error">{error}</Alert>}
|
||||
<AddSkylinkToAPIKeyForm onSuccess={onSkylinkListEdited} onFailure={onSkylinkListEditFailure} keyId={id} />
|
||||
</div>
|
||||
<div className="flex gap-4 justify-center mt-4">
|
||||
<Button onClick={closeEditModal}>Close</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
import { APIKey } from "./APIKey";
|
||||
|
||||
export const APIKeyList = ({ keys, reloadKeys, title }) => {
|
||||
return (
|
||||
<>
|
||||
<h6 className="text-palette-300 mb-4">{title}</h6>
|
||||
<ul className="mt-4">
|
||||
{keys.map((key) => (
|
||||
<APIKey key={key.id} apiKey={key} onEdited={reloadKeys} onRemoved={reloadKeys} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from "./APIKeyList";
|
|
@ -0,0 +1,31 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import accountsService from "../../services/accountsService";
|
||||
|
||||
export const useAPIKeyEdit = ({ key, onSkylinkRemoved, onSkylinkRemovalFailure }) => {
|
||||
const [editInitiated, setEditInitiated] = useState(false);
|
||||
|
||||
const prompt = () => setEditInitiated(true);
|
||||
const abort = () => setEditInitiated(false);
|
||||
const removeSkylink = useCallback(
|
||||
async (skylink) => {
|
||||
try {
|
||||
await accountsService.patch(`user/apikeys/${key.id}`, {
|
||||
json: {
|
||||
remove: [skylink],
|
||||
},
|
||||
});
|
||||
onSkylinkRemoved();
|
||||
} catch {
|
||||
onSkylinkRemovalFailure();
|
||||
}
|
||||
},
|
||||
[onSkylinkRemoved, onSkylinkRemovalFailure, key]
|
||||
);
|
||||
|
||||
return {
|
||||
editInitiated,
|
||||
prompt,
|
||||
abort,
|
||||
removeSkylink,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import accountsService from "../../services/accountsService";
|
||||
|
||||
export const useAPIKeyRemoval = ({ key, onSuccess }) => {
|
||||
const [removalInitiated, setRemovalInitiated] = useState(false);
|
||||
const [removalError, setRemovalError] = useState(null);
|
||||
|
||||
const prompt = () => setRemovalInitiated(true);
|
||||
const abort = () => setRemovalInitiated(false);
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
setRemovalError(null);
|
||||
try {
|
||||
await accountsService.delete(`user/apikeys/${key.id}`);
|
||||
setRemovalInitiated(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
let message = "There was an error processing your request. Please try again later.";
|
||||
|
||||
if (err.response) {
|
||||
const response = await err.response.json();
|
||||
|
||||
if (response.message) {
|
||||
message = response.message;
|
||||
}
|
||||
}
|
||||
|
||||
setRemovalError(message);
|
||||
}
|
||||
}, [onSuccess, key]);
|
||||
|
||||
return {
|
||||
removalInitiated,
|
||||
removalError,
|
||||
prompt,
|
||||
abort,
|
||||
confirm,
|
||||
};
|
||||
};
|
|
@ -10,7 +10,7 @@ import { Overlay } from "./Overlay";
|
|||
export const Modal = ({ children, className, onClose }) => (
|
||||
<ModalPortal>
|
||||
<Overlay onClick={onClose}>
|
||||
<div className="relative">
|
||||
<div className={cn("relative w-modal max-w-modal shadow-sm rounded")}>
|
||||
<button onClick={onClose} className="absolute top-[20px] right-[20px]">
|
||||
<PlusIcon size={14} className="rotate-45" />
|
||||
</button>
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from "./ModalPortal";
|
||||
export * from "./Modal";
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
import * as Yup from "yup";
|
||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Formik, Form } from "formik";
|
||||
|
||||
import accountsService from "../../services/accountsService";
|
||||
|
||||
import { Alert } from "../Alert";
|
||||
import { Button } from "../Button";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
import { TextField } from "../Form/TextField";
|
||||
import { CircledProgressIcon, PlusIcon } from "../Icons";
|
||||
|
||||
const newAPIKeySchema = Yup.object().shape({
|
||||
name: Yup.string(),
|
||||
});
|
||||
|
||||
const State = {
|
||||
Pure: "PURE",
|
||||
Success: "SUCCESS",
|
||||
Failure: "FAILURE",
|
||||
};
|
||||
|
||||
export const APIKeyType = {
|
||||
Public: "public",
|
||||
General: "general",
|
||||
};
|
||||
|
||||
export const AddAPIKeyForm = forwardRef(({ onSuccess, type }, ref) => {
|
||||
const [state, setState] = useState(State.Pure);
|
||||
const [generatedKey, setGeneratedKey] = useState(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => setState(State.Pure),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col gap-4">
|
||||
{state === State.Success && (
|
||||
<Alert $variant="success" className="text-center">
|
||||
<strong>Success!</strong>
|
||||
<p>Please copy your new API key below. We'll never show it again!</p>
|
||||
<div className="flex items-center gap-2 mt-4 justify-center">
|
||||
<code className="p-2 rounded border border-palette-200 text-xs selection:bg-primary/30 truncate">
|
||||
{generatedKey}
|
||||
</code>
|
||||
<CopyButton value={generatedKey} className="whitespace-nowrap" />
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{state === State.Failure && (
|
||||
<Alert $variant="error">We were not able to generate a new key. Please try again later.</Alert>
|
||||
)}
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: "",
|
||||
}}
|
||||
validationSchema={newAPIKeySchema}
|
||||
onSubmit={async ({ name }, { resetForm }) => {
|
||||
try {
|
||||
const { key } = await accountsService
|
||||
.post("user/apikeys", {
|
||||
json: {
|
||||
name,
|
||||
public: type === APIKeyType.Public ? "true" : "false",
|
||||
skylinks: type === APIKeyType.Public ? [] : null,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
||||
resetForm();
|
||||
setGeneratedKey(key);
|
||||
setState(State.Success);
|
||||
onSuccess();
|
||||
} catch {
|
||||
setState(State.Failure);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form className="grid grid-cols-[1fr_min-content] w-full gap-y-2 gap-x-4 items-start">
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
label="New API Key Name"
|
||||
placeholder="my_applications_statistics"
|
||||
error={errors.name}
|
||||
touched={touched.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex mt-5 justify-center">
|
||||
{isSubmitting ? (
|
||||
<CircledProgressIcon size={38} className="text-palette-300 animate-[spin_3s_linear_infinite]" />
|
||||
) : (
|
||||
<Button type="submit" className="px-2.5">
|
||||
<PlusIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AddAPIKeyForm.displayName = "AddAPIKeyForm";
|
||||
|
||||
AddAPIKeyForm.propTypes = {
|
||||
onSuccess: PropTypes.func.isRequired,
|
||||
type: PropTypes.oneOf([APIKeyType.Public, APIKeyType.General]).isRequired,
|
||||
};
|
|
@ -0,0 +1,73 @@
|
|||
import * as Yup from "yup";
|
||||
import PropTypes from "prop-types";
|
||||
import { Formik, Form } from "formik";
|
||||
|
||||
import accountsService from "../../services/accountsService";
|
||||
|
||||
import { Button } from "../Button";
|
||||
import { TextField } from "../Form/TextField";
|
||||
import { CircledProgressIcon, PlusIcon } from "../Icons";
|
||||
|
||||
const newSkylinkSchema = Yup.object().shape({
|
||||
skylink: Yup.string().required("Provide a valid Skylink"), // TODO: Comprehensive Skylink validation
|
||||
});
|
||||
|
||||
export const AddSkylinkToAPIKeyForm = ({ keyId, onSuccess, onFailure }) => (
|
||||
<Formik
|
||||
initialValues={{
|
||||
skylink: "",
|
||||
}}
|
||||
validationSchema={newSkylinkSchema}
|
||||
onSubmit={async ({ skylink }, { resetForm }) => {
|
||||
try {
|
||||
await accountsService
|
||||
.patch(`user/apikeys/${keyId}`, {
|
||||
json: {
|
||||
add: [skylink],
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
||||
resetForm();
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
if (err.response) {
|
||||
const { message } = await err.response.json();
|
||||
onFailure(message);
|
||||
} else {
|
||||
onFailure("Unknown error occured, please try again.");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form className="grid grid-cols-[1fr_min-content] w-full gap-y-2 gap-x-4 items-start">
|
||||
<div className="flex items-center text-left">
|
||||
<TextField
|
||||
type="text"
|
||||
id="skylink"
|
||||
name="skylink"
|
||||
label="New Skylink"
|
||||
error={errors.skylink}
|
||||
touched={touched.skylink}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex mt-5 justify-center">
|
||||
{isSubmitting ? (
|
||||
<CircledProgressIcon size={38} className="text-palette-300 animate-[spin_3s_linear_infinite]" />
|
||||
) : (
|
||||
<Button type="submit" className="px-2.5">
|
||||
<PlusIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
AddSkylinkToAPIKeyForm.propTypes = {
|
||||
onFailure: PropTypes.func.isRequired,
|
||||
onSuccess: PropTypes.func.isRequired,
|
||||
keyId: PropTypes.string.isRequired,
|
||||
};
|
|
@ -1,63 +1,97 @@
|
|||
import useSWR from "swr";
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import UserSettingsLayout from "../../layouts/UserSettingsLayout";
|
||||
|
||||
import { TextInputBasic } from "../../components/TextInputBasic";
|
||||
import { Button } from "../../components/Button";
|
||||
import { TrashIcon } from "../../components/Icons";
|
||||
import { AddAPIKeyForm, APIKeyType } from "../../components/forms/AddAPIKeyForm";
|
||||
import { APIKeyList } from "../../components/APIKeyList/APIKeyList";
|
||||
import { Alert } from "../../components/Alert";
|
||||
|
||||
const APIKeysPage = () => {
|
||||
const { data: apiKeys } = useSWR("user/apikeys");
|
||||
const { data: apiKeys = [], mutate: reloadKeys, error } = useSWR("user/apikeys");
|
||||
const generalKeys = apiKeys.filter(({ public: isPublic }) => isPublic === "false");
|
||||
const publicKeys = apiKeys.filter(({ public: isPublic }) => isPublic === "true");
|
||||
|
||||
const publicFormRef = useRef();
|
||||
const generalFormRef = useRef();
|
||||
|
||||
const refreshState = useCallback(
|
||||
(resetForms) => {
|
||||
if (resetForms) {
|
||||
publicFormRef.current?.reset();
|
||||
generalFormRef.current?.reset();
|
||||
}
|
||||
reloadKeys();
|
||||
},
|
||||
[reloadKeys]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<div className="flex flex-col gap-10 lg:shrink-0 lg:max-w-[576px] xl:max-w-[524px]">
|
||||
<section>
|
||||
<div>
|
||||
<h4>API Keys</h4>
|
||||
<p>
|
||||
At vero eos et caritatem, quae sine metu contineret, saluti prospexit civium, qua. Laudem et dolorem
|
||||
aspernari ut ad naturam aut fu.
|
||||
<p className="leading-relaxed">There are two types of API keys that you can generate for your account.</p>
|
||||
<p>Make sure to use the appropriate type.</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h5>Public keys</h5>
|
||||
<p className="text-palette-500">
|
||||
Give read access to a selected list of Skylinks. You can share them publicly.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<AddAPIKeyForm ref={publicFormRef} onSuccess={refreshState} type={APIKeyType.Public} />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert $variant="error" className="mt-4">
|
||||
An error occurred while loading your API keys. Please try again later.
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
{publicKeys?.length > 0 ? (
|
||||
<APIKeyList title="Your public keys" keys={publicKeys} reloadKeys={() => refreshState(true)} />
|
||||
) : (
|
||||
<Alert $variant="info">No public API keys found.</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<hr />
|
||||
<section className="flex flex-col gap-8">
|
||||
<div className="grid sm:grid-cols-[1fr_min-content] w-full gap-y-2 gap-x-4 items-end">
|
||||
<TextInputBasic label="API Key Name" placeholder="my_applications_statistics" />
|
||||
<div className="flex mt-2 sm:mt-0 justify-center">
|
||||
<Button onClick={() => console.info("TODO: generate ky")}>Generate</Button>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h5>General keys</h5>
|
||||
<p className="text-palette-500">
|
||||
Give full access to <b>Accounts</b> service and are equivalent to using a JWT token.
|
||||
</p>
|
||||
<p className="underline">This type of API keys need to be kept secret and never shared with anyone.</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<AddAPIKeyForm ref={generalFormRef} onSuccess={refreshState} type={APIKeyType.General} />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert $variant="error" className="mt-4">
|
||||
An error occurred while loading your API keys. Please try again later.
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
{generalKeys?.length > 0 ? (
|
||||
<APIKeyList title="Your general keys" keys={generalKeys} reloadKeys={() => refreshState(true)} />
|
||||
) : (
|
||||
<Alert $variant="info">No general API keys found.</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{apiKeys?.length > 0 && (
|
||||
<section className="mt-4">
|
||||
<h6 className="text-palette-300">API Keys</h6>
|
||||
<ul className="mt-4">
|
||||
{apiKeys.map(({ id, name, createdAt }) => (
|
||||
<li
|
||||
key={id}
|
||||
className="grid grid-cols-2 sm:grid-cols-[1fr_repeat(2,_max-content)] py-6 sm:py-4 px-4 gap-x-8 bg-white odd:bg-palette-100/50"
|
||||
>
|
||||
<span className="truncate col-span-2 sm:col-span-1">{name || id}</span>
|
||||
<span className="col-span-2 my-4 border-t border-t-palette-200/50 sm:hidden" />
|
||||
<span className="text-palette-400">{dayjs(createdAt).format("MMM DD, YYYY")}</span>
|
||||
<span className="text-right">
|
||||
<button
|
||||
className="p-1 transition-colors hover:text-error"
|
||||
onClick={() => window.confirm("TODO: confirmation modal")}
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden xl:block w-full text-right pt-20 pr-6">
|
||||
<img src="/images/import-export.svg" alt="" className="inline-block w-[200px]" />
|
||||
<div className="hidden xl:block w-full text-right pt-16 pr-5">
|
||||
<img src="/images/api-keys.svg" alt="" className="inline-block h-[150px]" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -54,6 +54,7 @@ module.exports = {
|
|||
wiggle: "wiggle 3s ease-in-out infinite",
|
||||
},
|
||||
width: {
|
||||
modal: "500px",
|
||||
page: "100%",
|
||||
"page-md": "640px",
|
||||
"page-lg": "896px",
|
||||
|
@ -64,6 +65,9 @@ module.exports = {
|
|||
minWidth: {
|
||||
button: "112px",
|
||||
},
|
||||
maxWidth: {
|
||||
modal: "calc(100vw - 1rem)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
|
Reference in New Issue