feat(dashboard-v2): add dedicated form for public api keys
This commit is contained in:
parent
46d295e54c
commit
45dc78ed19
|
@ -5,6 +5,7 @@ module.exports = {
|
|||
title: `Accounts Dashboard`,
|
||||
siteUrl: `https://www.yourdomain.tld`,
|
||||
},
|
||||
trailingSlash: "never",
|
||||
plugins: [
|
||||
"gatsby-plugin-image",
|
||||
"gatsby-plugin-provide-react",
|
||||
|
|
|
@ -5,12 +5,11 @@ import { useCallback, useState } from "react";
|
|||
import { Alert } from "../Alert";
|
||||
import { Button } from "../Button";
|
||||
import { AddSkylinkToAPIKeyForm } from "../forms/AddSkylinkToAPIKeyForm";
|
||||
import { CogIcon, ImportantNoteIcon, TrashIcon } from "../Icons";
|
||||
import { CogIcon, TrashIcon } from "../Icons";
|
||||
import { Modal } from "../Modal";
|
||||
|
||||
import { useAPIKeyEdit } from "./useAPIKeyEdit";
|
||||
import { useAPIKeyRemoval } from "./useAPIKeyRemoval";
|
||||
import { Tooltip } from "../Tooltip/Tooltip";
|
||||
|
||||
export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
|
||||
const { id, name, createdAt, skylinks } = apiKey;
|
||||
|
@ -53,22 +52,28 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
|
|||
abortEdit();
|
||||
}, [abortEdit]);
|
||||
|
||||
const needsAttention = isPublic && skylinks?.length === 0;
|
||||
const skylinksNumber = skylinks?.length ?? 0;
|
||||
const isNotConfigured = isPublic && skylinksNumber === 0;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn("grid grid-cols-2 sm:grid-cols-[1fr_repeat(2,_max-content)] py-6 sm:py-4 px-4 gap-x-8", {
|
||||
"bg-red-100": needsAttention,
|
||||
"bg-white odd:bg-palette-100/50": !needsAttention,
|
||||
})}
|
||||
className={cn(
|
||||
"grid grid-cols-2 sm:grid-cols-[1fr_repeat(2,_max-content)] py-3 px-4 gap-x-8 items-center bg-white odd:bg-palette-100/50"
|
||||
)}
|
||||
>
|
||||
<span className="col-span-2 sm:col-span-1 flex items-center">
|
||||
<span className="flex flex-col">
|
||||
<span className={cn("truncate", { "text-palette-300": !name })}>{name || "unnamed key"}</span>
|
||||
{needsAttention && (
|
||||
<Tooltip message="This key has no Skylinks configured.">
|
||||
<ImportantNoteIcon className="text-error -mt-2" size={24} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={promptEdit}
|
||||
className={cn("text-xs hover:underline decoration-dotted", {
|
||||
"text-error": isNotConfigured,
|
||||
"text-palette-400": !isNotConfigured,
|
||||
})}
|
||||
>
|
||||
{skylinksNumber} {skylinksNumber === 1 ? "Skylink" : "Skylinks"} configured
|
||||
</button>
|
||||
</span>
|
||||
</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>
|
||||
|
@ -77,16 +82,12 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
|
|||
<button
|
||||
title="Add or remove Skylinks"
|
||||
className="p-1 transition-colors hover:text-primary"
|
||||
onClick={() => promptEdit({ id, name, skylinks })}
|
||||
onClick={promptEdit}
|
||||
>
|
||||
<CogIcon size={22} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title="Delete this API key"
|
||||
className="p-1 transition-colors hover:text-error"
|
||||
onClick={() => promptRemoval({ id, name })}
|
||||
>
|
||||
<button title="Delete this API key" className="p-1 transition-colors hover:text-error" onClick={promptRemoval}>
|
||||
<TrashIcon size={16} />
|
||||
</button>
|
||||
</span>
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
import * as Yup from "yup";
|
||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Formik, Form, FieldArray } from "formik";
|
||||
import { parseSkylink } from "skynet-js";
|
||||
import cn from "classnames";
|
||||
|
||||
import accountsService from "../../services/accountsService";
|
||||
|
||||
import { Alert } from "../Alert";
|
||||
import { Button } from "../Button";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
import { TextField } from "../Form/TextField";
|
||||
import { PlusIcon, TrashIcon } from "../Icons";
|
||||
|
||||
const skylinkValidator = (optional) => (value) => {
|
||||
if (!value) {
|
||||
return optional;
|
||||
}
|
||||
|
||||
try {
|
||||
return parseSkylink(value) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const newPublicAPIKeySchema = Yup.object().shape({
|
||||
name: Yup.string(),
|
||||
skylinks: Yup.array().of(Yup.string().test("skylink", "Provide a valid Skylink", skylinkValidator(false))),
|
||||
nextSkylink: Yup.string().when("skylinks", {
|
||||
is: (skylinks) => skylinks.length === 0,
|
||||
then: (schema) => schema.test("skylink", "Provide a valid Skylink", skylinkValidator(true)),
|
||||
otherwise: (schema) => schema.test("skylink", "Provide a valid Skylink", skylinkValidator(true)),
|
||||
}),
|
||||
});
|
||||
|
||||
const State = {
|
||||
Pure: "PURE",
|
||||
Success: "SUCCESS",
|
||||
Failure: "FAILURE",
|
||||
};
|
||||
|
||||
export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, 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: "",
|
||||
skylinks: [],
|
||||
nextSkylink: "",
|
||||
}}
|
||||
validationSchema={newPublicAPIKeySchema}
|
||||
onSubmit={async ({ name, skylinks, nextSkylink }, { resetForm }) => {
|
||||
try {
|
||||
const { key } = await accountsService
|
||||
.post("user/apikeys", {
|
||||
json: {
|
||||
name,
|
||||
public: "true",
|
||||
skylinks: [...skylinks, nextSkylink].filter(Boolean).map(parseSkylink),
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
||||
resetForm();
|
||||
setGeneratedKey(key);
|
||||
setState(State.Success);
|
||||
onSuccess();
|
||||
} catch {
|
||||
setState(State.Failure);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, values, isValid, setFieldValue, setFieldTouched }) => (
|
||||
<Form className="flex flex-col gap-4">
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
label="Public API Key Name"
|
||||
placeholder="my_applications_statistics"
|
||||
error={errors.name}
|
||||
touched={touched.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-palette-300 mb-2">Skylinks accessible with the new key</h6>
|
||||
<FieldArray
|
||||
name="skylinks"
|
||||
render={({ push, remove }) => {
|
||||
const { skylinks = [] } = values;
|
||||
const { skylinks: skylinksErrors = [] } = errors;
|
||||
const { skylinks: skylinksTouched = [] } = touched;
|
||||
|
||||
const appendSkylink = (skylink) => {
|
||||
push(skylink);
|
||||
setFieldValue("nextSkylink", "", false);
|
||||
setFieldTouched("nextSkylink", false);
|
||||
};
|
||||
const isNextSkylinkInvalid = Boolean(
|
||||
errors.nextSkylink || !touched.nextSkylink || !values.nextSkylink
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{skylinks.map((_, index) => (
|
||||
<div key={index} className="flex gap-4 items-start">
|
||||
<TextField
|
||||
type="text"
|
||||
name={`skylinks.${index}`}
|
||||
placeholder={`${index + 1}. Skylink`}
|
||||
error={skylinksErrors[index]}
|
||||
touched={skylinksTouched[index]}
|
||||
/>
|
||||
<span className="w-[24px] shrink-0 mt-3">
|
||||
<button type="button" onClick={() => remove(index)}>
|
||||
<TrashIcon size={16} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex gap-4 items-start">
|
||||
<TextField
|
||||
type="text"
|
||||
name="nextSkylink"
|
||||
placeholder={`${skylinks.length + 1}. Skylink`}
|
||||
error={errors.nextSkylink}
|
||||
touched={touched.nextSkylink}
|
||||
onKeyPress={(event) => {
|
||||
if (event.key === "Enter" && isValid) {
|
||||
event.preventDefault();
|
||||
appendSkylink(values.nextSkylink);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => appendSkylink(values.nextSkylink)}
|
||||
className={cn("shrink-0 mt-1.5 w-[24px] h-[24px]", {
|
||||
"text-palette-300 cursor-not-allowed": isNextSkylinkInvalid,
|
||||
})}
|
||||
disabled={isNextSkylinkInvalid}
|
||||
>
|
||||
<PlusIcon size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex mt-5 justify-center">
|
||||
<Button type="submit" className={cn("px-2.5", { "cursor-wait": isSubmitting })} disabled={!isValid || isSubmitting}>
|
||||
{isSubmitting ? "Generating" : "Generate"} your public key
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AddPublicAPIKeyForm.displayName = "AddAPIKeyForm";
|
||||
|
||||
AddPublicAPIKeyForm.propTypes = {
|
||||
onSuccess: PropTypes.func.isRequired,
|
||||
};
|
|
@ -6,6 +6,7 @@ import UserSettingsLayout from "../../layouts/UserSettingsLayout";
|
|||
import { AddAPIKeyForm, APIKeyType } from "../../components/forms/AddAPIKeyForm";
|
||||
import { APIKeyList } from "../../components/APIKeyList/APIKeyList";
|
||||
import { Alert } from "../../components/Alert";
|
||||
import { AddPublicAPIKeyForm } from "../../components/forms/AddPublicAPIKeyForm";
|
||||
|
||||
const APIKeysPage = () => {
|
||||
const { data: apiKeys = [], mutate: reloadKeys, error } = useSWR("user/apikeys");
|
||||
|
@ -45,7 +46,7 @@ const APIKeysPage = () => {
|
|||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<AddAPIKeyForm ref={publicFormRef} onSuccess={refreshState} type={APIKeyType.Public} />
|
||||
<AddPublicAPIKeyForm ref={publicFormRef} onSuccess={refreshState} />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
|
Reference in New Issue