feat(dashboard-v2): add dedicated form for public api keys

This commit is contained in:
Michał Leszczyk 2022-03-25 17:47:44 +01:00
parent 46d295e54c
commit 45dc78ed19
No known key found for this signature in database
GPG Key ID: FA123CA8BAA2FBF4
4 changed files with 217 additions and 20 deletions

View File

@ -5,6 +5,7 @@ module.exports = {
title: `Accounts Dashboard`,
siteUrl: `https://www.yourdomain.tld`,
},
trailingSlash: "never",
plugins: [
"gatsby-plugin-image",
"gatsby-plugin-provide-react",

View File

@ -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={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>
)}
<span className="flex flex-col">
<span className={cn("truncate", { "text-palette-300": !name })}>{name || "unnamed key"}</span>
<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>

View File

@ -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,
};

View File

@ -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 ? (