Merge pull request #1918 from SkynetLabs/dashboard-v2-api-keys-mutations
Dashboard v2 api keys mutations
This commit is contained in:
commit
38377b4e66
|
@ -5,6 +5,7 @@ module.exports = {
|
||||||
title: `Accounts Dashboard`,
|
title: `Accounts Dashboard`,
|
||||||
siteUrl: `https://www.yourdomain.tld`,
|
siteUrl: `https://www.yourdomain.tld`,
|
||||||
},
|
},
|
||||||
|
trailingSlash: "never",
|
||||||
plugins: [
|
plugins: [
|
||||||
"gatsby-plugin-image",
|
"gatsby-plugin-image",
|
||||||
"gatsby-plugin-provide-react",
|
"gatsby-plugin-provide-react",
|
||||||
|
|
|
@ -0,0 +1,145 @@
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import cn from "classnames";
|
||||||
|
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,
|
||||||
|
addSkylink,
|
||||||
|
removeSkylink,
|
||||||
|
} = useAPIKeyEdit({
|
||||||
|
key: apiKey,
|
||||||
|
onSkylinkListUpdate: onSkylinkListEdited,
|
||||||
|
onSkylinkListUpdateFailure: onSkylinkListEditFailure,
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeEditModal = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
abortEdit();
|
||||||
|
}, [abortEdit]);
|
||||||
|
|
||||||
|
const skylinksNumber = skylinks?.length ?? 0;
|
||||||
|
const isNotConfigured = isPublic && skylinksNumber === 0;
|
||||||
|
const skylinksPhrasePrefix = skylinksNumber === 0 ? "No" : skylinksNumber;
|
||||||
|
const skylinksPhrase = `${skylinksPhrasePrefix} ${skylinksNumber === 1 ? "skylink" : "skylinks"} configured`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={promptEdit}
|
||||||
|
className={cn("text-xs hover:underline decoration-dotted", {
|
||||||
|
"text-error": isNotConfigured,
|
||||||
|
"text-palette-400": !isNotConfigured,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{skylinksPhrase}
|
||||||
|
</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>
|
||||||
|
<span className="flex items-center justify-end">
|
||||||
|
{isPublic && (
|
||||||
|
<button
|
||||||
|
title="Add or remove skylinks"
|
||||||
|
className="p-1 transition-colors hover:text-primary"
|
||||||
|
onClick={promptEdit}
|
||||||
|
>
|
||||||
|
<CogIcon size={22} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button title="Delete this API key" className="p-1 transition-colors hover:text-error" onClick={promptRemoval}>
|
||||||
|
<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 key={skylink} 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 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 addSkylink={addSkylink} />
|
||||||
|
</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,43 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import accountsService from "../../services/accountsService";
|
||||||
|
|
||||||
|
export const useAPIKeyEdit = ({ key, onSkylinkListUpdate, onSkylinkListUpdateFailure }) => {
|
||||||
|
const [editInitiated, setEditInitiated] = useState(false);
|
||||||
|
|
||||||
|
const prompt = () => setEditInitiated(true);
|
||||||
|
const abort = () => setEditInitiated(false);
|
||||||
|
const updateSkylinkList = useCallback(
|
||||||
|
async (action, skylink) => {
|
||||||
|
try {
|
||||||
|
await accountsService.patch(`user/apikeys/${key.id}`, {
|
||||||
|
json: {
|
||||||
|
[action]: [skylink],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onSkylinkListUpdate();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response) {
|
||||||
|
const { message } = await err.response.json();
|
||||||
|
onSkylinkListUpdateFailure(message);
|
||||||
|
} else {
|
||||||
|
onSkylinkListUpdateFailure("Unknown error occured, please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSkylinkListUpdate, onSkylinkListUpdateFailure, key]
|
||||||
|
);
|
||||||
|
const addSkylink = (skylink) => updateSkylinkList("add", skylink);
|
||||||
|
const removeSkylink = (skylink) => updateSkylinkList("remove", skylink);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editInitiated,
|
||||||
|
prompt,
|
||||||
|
abort,
|
||||||
|
addSkylink,
|
||||||
|
removeSkylink,
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,41 @@
|
||||||
|
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 = () => {
|
||||||
|
setRemovalError(null);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -3,6 +3,7 @@ import cn from "classnames";
|
||||||
|
|
||||||
export const Alert = styled.div.attrs(({ $variant }) => ({
|
export const Alert = styled.div.attrs(({ $variant }) => ({
|
||||||
className: cn("px-3 py-2 sm:px-6 sm:py-4 rounded border", {
|
className: cn("px-3 py-2 sm:px-6 sm:py-4 rounded border", {
|
||||||
|
"bg-blue-100 border-blue-200 text-palette-400": $variant === "info",
|
||||||
"bg-red-100 border-red-200 text-error": $variant === "error",
|
"bg-red-100 border-red-200 text-error": $variant === "error",
|
||||||
"bg-green-100 border-green-200 text-palette-400": $variant === "success",
|
"bg-green-100 border-green-200 text-palette-400": $variant === "success",
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { withIconProps } from "../withIconProps";
|
||||||
|
|
||||||
|
export const ImportantNoteIcon = withIconProps(({ size, ...props }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<g fill="none" fillRule="evenodd">
|
||||||
|
<g fill="currentColor" fillRule="nonzero">
|
||||||
|
<path d="M16.028 6c5.523 0 10 4.477 10 10s-4.477 10-10 10h-9c-.405.017-.78-.212-.95-.58-.156-.372-.074-.802.21-1.09l2-2C6.82 20.549 6.02 18.31 6.028 16c0-5.523 4.477-10 10-10zm3.05 2.607c-3.526-1.458-7.592-.222-9.71 2.953-2.119 3.174-1.7 7.403 1 10.1.189.185.296.436.3.7 0 .267-.109.523-.3.71l-.93.93h6.59c3.817-.003 7.1-2.701 7.841-6.445.742-3.744-1.264-7.49-4.79-8.948zM16.028 18c.552 0 1 .448 1 1s-.448 1-1 1-1-.448-1-1 .448-1 1-1zM17 12v5h-2v-5h2z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
));
|
|
@ -14,3 +14,4 @@ export * from "./icons/CopyIcon";
|
||||||
export * from "./icons/ShareIcon";
|
export * from "./icons/ShareIcon";
|
||||||
export * from "./icons/SimpleUploadIcon";
|
export * from "./icons/SimpleUploadIcon";
|
||||||
export * from "./icons/TrashIcon";
|
export * from "./icons/TrashIcon";
|
||||||
|
export * from "./icons/ImportantNoteIcon";
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Overlay } from "./Overlay";
|
||||||
export const Modal = ({ children, className, onClose }) => (
|
export const Modal = ({ children, className, onClose }) => (
|
||||||
<ModalPortal>
|
<ModalPortal>
|
||||||
<Overlay onClick={onClose}>
|
<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]">
|
<button onClick={onClose} className="absolute top-[20px] right-[20px]">
|
||||||
<PlusIcon size={14} className="rotate-45" />
|
<PlusIcon size={14} className="rotate-45" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from "./ModalPortal";
|
export * from "./ModalPortal";
|
||||||
|
export * from "./Modal";
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import styled, { keyframes } from "styled-components";
|
||||||
|
|
||||||
|
const fadeIn = keyframes`
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Popper = styled.div.attrs({
|
||||||
|
className: `absolute left-full top-1/2 z-10 px-2 py-1 text-xs
|
||||||
|
bg-black/90 text-white rounded`,
|
||||||
|
})`
|
||||||
|
transform: translateY(-50%);
|
||||||
|
animation: ${fadeIn} 0.2s ease-in-out;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Tooltip = ({ message, children, className }) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
const show = () => setVisible(true);
|
||||||
|
const hide = () => setVisible(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative" onMouseEnter={show} onMouseLeave={hide}>
|
||||||
|
{children}
|
||||||
|
{visible && <Popper className={className}>{message}</Popper>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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,198 @@
|
||||||
|
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={`Paste next skylink here`}
|
||||||
|
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,
|
||||||
|
};
|
|
@ -0,0 +1,63 @@
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Formik, Form } from "formik";
|
||||||
|
import { parseSkylink } from "skynet-js";
|
||||||
|
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { TextField } from "../Form/TextField";
|
||||||
|
import { CircledProgressIcon, PlusIcon } from "../Icons";
|
||||||
|
|
||||||
|
const newSkylinkSchema = Yup.object().shape({
|
||||||
|
skylink: Yup.string()
|
||||||
|
.required("Skylink is required")
|
||||||
|
.test("skylink", "Provide a valid Skylink", (value) => {
|
||||||
|
try {
|
||||||
|
return parseSkylink(value) !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddSkylinkToAPIKeyForm = ({ addSkylink }) => (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
skylink: "",
|
||||||
|
}}
|
||||||
|
validationSchema={newSkylinkSchema}
|
||||||
|
onSubmit={async ({ skylink }, { resetForm }) => {
|
||||||
|
if (await addSkylink(parseSkylink(skylink))) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ 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"
|
||||||
|
placeholder="Paste a new Skylink here"
|
||||||
|
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 = {
|
||||||
|
addSkylink: PropTypes.func.isRequired,
|
||||||
|
};
|
|
@ -1,63 +1,100 @@
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import dayjs from "dayjs";
|
import { useCallback, useRef } from "react";
|
||||||
|
|
||||||
import UserSettingsLayout from "../../layouts/UserSettingsLayout";
|
import UserSettingsLayout from "../../layouts/UserSettingsLayout";
|
||||||
|
|
||||||
import { TextInputBasic } from "../../components/TextInputBasic";
|
import { AddAPIKeyForm, APIKeyType } from "../../components/forms/AddAPIKeyForm";
|
||||||
import { Button } from "../../components/Button";
|
import { APIKeyList } from "../../components/APIKeyList/APIKeyList";
|
||||||
import { TrashIcon } from "../../components/Icons";
|
import { Alert } from "../../components/Alert";
|
||||||
|
import { AddPublicAPIKeyForm } from "../../components/forms/AddPublicAPIKeyForm";
|
||||||
|
|
||||||
const APIKeysPage = () => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col xl:flex-row">
|
<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]">
|
<div className="flex flex-col gap-10 lg:shrink-0 lg:max-w-[576px] xl:max-w-[524px]">
|
||||||
<section>
|
<div>
|
||||||
<h4>API Keys</h4>
|
<h4>API Keys</h4>
|
||||||
<p>
|
<p className="leading-relaxed">There are two types of API keys that you can generate for your account.</p>
|
||||||
At vero eos et caritatem, quae sine metu contineret, saluti prospexit civium, qua. Laudem et dolorem
|
<p>Make sure to use the appropriate type.</p>
|
||||||
aspernari ut ad naturam aut fu.
|
</div>
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
<hr />
|
<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">
|
<section className="flex flex-col gap-2">
|
||||||
<TextInputBasic label="API Key Name" placeholder="my_applications_statistics" />
|
<h5>Public keys</h5>
|
||||||
<div className="flex mt-2 sm:mt-0 justify-center">
|
<p className="text-palette-500">
|
||||||
<Button onClick={() => console.info("TODO: generate ky")}>Generate</Button>
|
Public keys provide read access to a selected list of skylinks. You can share them publicly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddPublicAPIKeyForm ref={publicFormRef} onSuccess={refreshState} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
{error ? (
|
||||||
{apiKeys?.length > 0 && (
|
<Alert $variant="error" className="mt-4">
|
||||||
<section className="mt-4">
|
An error occurred while loading your API keys. Please try again later.
|
||||||
<h6 className="text-palette-300">API Keys</h6>
|
</Alert>
|
||||||
<ul className="mt-4">
|
) : (
|
||||||
{apiKeys.map(({ id, name, createdAt }) => (
|
<div className="mt-4">
|
||||||
<li
|
{publicKeys?.length > 0 ? (
|
||||||
key={id}
|
<APIKeyList title="Your public keys" keys={publicKeys} reloadKeys={() => refreshState(true)} />
|
||||||
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"
|
) : (
|
||||||
>
|
<Alert $variant="info">No public API keys found.</Alert>
|
||||||
<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>
|
||||||
<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]" />
|
</section>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h5>General keys</h5>
|
||||||
|
<p className="text-palette-500">
|
||||||
|
These keys provide full access to <b>Accounts</b> service and are equivalent to using a JWT token.
|
||||||
|
</p>
|
||||||
|
<p className="underline">
|
||||||
|
This type of API keys needs to be kept secret and should never be 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>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 173 131.5"><defs><style>.cls-1{fill:#00c65e;fill-rule:evenodd;}.cls-2{fill:none;stroke:#0d0d0d;stroke-width:2px;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M112,50.39V80.61L142,99.5l30-18.89V50.39L142.94,31.5Z"/><path class="cls-2" d="M125.61,89.18,142,99.5l30-18.89V50.39l-13.27-8.63"/><path class="cls-2" d="M78,19.5l16-10,16,10v16l-16,10-16-10Z"/><path class="cls-2" d="M110,19.5l-16,10-16-10m16.5,10v16m20,42v44M92,70h4M84,70h4m12,0h4m-6,61.5v-24a4,4,0,0,0-8,0v24M71,5.5H66c-13.24,8.55-20,21.07-20,38a48.29,48.29,0,0,0,28,44v44"/><path class="cls-2" d="M94,91.5a48,48,0,0,0,48-48c0-16.93-6.76-29.45-20-38h-4v36l-24,16-24-16V5.5"/><path class="cls-2" d="M118,11.5a40,40,0,1,1-64,32"/><path class="cls-2" d="M29,12H0m34,9H15M44,1H25"/></g></g></svg>
|
After Width: | Height: | Size: 874 B |
|
@ -54,6 +54,7 @@ module.exports = {
|
||||||
wiggle: "wiggle 3s ease-in-out infinite",
|
wiggle: "wiggle 3s ease-in-out infinite",
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
|
modal: "500px",
|
||||||
page: "100%",
|
page: "100%",
|
||||||
"page-md": "640px",
|
"page-md": "640px",
|
||||||
"page-lg": "896px",
|
"page-lg": "896px",
|
||||||
|
@ -64,6 +65,9 @@ module.exports = {
|
||||||
minWidth: {
|
minWidth: {
|
||||||
button: "112px",
|
button: "112px",
|
||||||
},
|
},
|
||||||
|
maxWidth: {
|
||||||
|
modal: "calc(100vw - 1rem)",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
Reference in New Issue