From 4c8328c21ff341e6e22471a3da2cba32b5499f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Thu, 24 Mar 2022 19:13:19 +0100 Subject: [PATCH] feat(dashboard-v2): implement data mutations for API keys --- .../src/components/APIKeyList/APIKey.js | 123 ++++++++++++++++++ .../src/components/APIKeyList/APIKeyList.js | 14 ++ .../src/components/APIKeyList/index.js | 1 + .../components/APIKeyList/useAPIKeyEdit.js | 31 +++++ .../components/APIKeyList/useAPIKeyRemoval.js | 39 ++++++ .../src/components/Modal/Modal.js | 2 +- .../src/components/Modal/index.js | 1 + .../src/components/forms/AddAPIKeyForm.js | 114 ++++++++++++++++ .../forms/AddSkylinkToAPIKeyForm.js | 73 +++++++++++ .../src/pages/settings/api-keys.js | 118 +++++++++++------ packages/dashboard-v2/tailwind.config.js | 4 + 11 files changed, 477 insertions(+), 43 deletions(-) create mode 100644 packages/dashboard-v2/src/components/APIKeyList/APIKey.js create mode 100644 packages/dashboard-v2/src/components/APIKeyList/APIKeyList.js create mode 100644 packages/dashboard-v2/src/components/APIKeyList/index.js create mode 100644 packages/dashboard-v2/src/components/APIKeyList/useAPIKeyEdit.js create mode 100644 packages/dashboard-v2/src/components/APIKeyList/useAPIKeyRemoval.js create mode 100644 packages/dashboard-v2/src/components/forms/AddAPIKeyForm.js create mode 100644 packages/dashboard-v2/src/components/forms/AddSkylinkToAPIKeyForm.js diff --git a/packages/dashboard-v2/src/components/APIKeyList/APIKey.js b/packages/dashboard-v2/src/components/APIKeyList/APIKey.js new file mode 100644 index 00000000..61170327 --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/APIKey.js @@ -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 ( +
  • + {name || id} + + {dayjs(createdAt).format("MMM DD, YYYY")} + + {isPublic && ( + + )} + + + + {removalInitiated && ( + +

    Delete API key

    +
    +

    Are you sure you want to delete the following API key?

    +

    {name || id}

    +
    + {removalError && {removalError}} + +
    + + +
    +
    + )} + {editInitiated && ( + +

    Covered Skylinks

    + {skylinks?.length > 0 ? ( +
      + {skylinks.map((skylink) => ( +
    • + + {skylink} + + +
    • + ))} +
    + ) : ( + No skylinks here yet. You can add the first one below 🙃 + )} + +
    + {error && {error}} + +
    +
    + +
    +
    + )} +
  • + ); +}; diff --git a/packages/dashboard-v2/src/components/APIKeyList/APIKeyList.js b/packages/dashboard-v2/src/components/APIKeyList/APIKeyList.js new file mode 100644 index 00000000..3d3e504d --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/APIKeyList.js @@ -0,0 +1,14 @@ +import { APIKey } from "./APIKey"; + +export const APIKeyList = ({ keys, reloadKeys, title }) => { + return ( + <> +
    {title}
    + + + ); +}; diff --git a/packages/dashboard-v2/src/components/APIKeyList/index.js b/packages/dashboard-v2/src/components/APIKeyList/index.js new file mode 100644 index 00000000..8ade7744 --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/index.js @@ -0,0 +1 @@ +export * from "./APIKeyList"; diff --git a/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyEdit.js b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyEdit.js new file mode 100644 index 00000000..052102af --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyEdit.js @@ -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, + }; +}; diff --git a/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyRemoval.js b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyRemoval.js new file mode 100644 index 00000000..46d5d424 --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyRemoval.js @@ -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, + }; +}; diff --git a/packages/dashboard-v2/src/components/Modal/Modal.js b/packages/dashboard-v2/src/components/Modal/Modal.js index 6fd8e00a..c183e190 100644 --- a/packages/dashboard-v2/src/components/Modal/Modal.js +++ b/packages/dashboard-v2/src/components/Modal/Modal.js @@ -10,7 +10,7 @@ import { Overlay } from "./Overlay"; export const Modal = ({ children, className, onClose }) => ( -
    +
    diff --git a/packages/dashboard-v2/src/components/Modal/index.js b/packages/dashboard-v2/src/components/Modal/index.js index 28d34710..00ce01f6 100644 --- a/packages/dashboard-v2/src/components/Modal/index.js +++ b/packages/dashboard-v2/src/components/Modal/index.js @@ -1 +1,2 @@ export * from "./ModalPortal"; +export * from "./Modal"; diff --git a/packages/dashboard-v2/src/components/forms/AddAPIKeyForm.js b/packages/dashboard-v2/src/components/forms/AddAPIKeyForm.js new file mode 100644 index 00000000..703d88a0 --- /dev/null +++ b/packages/dashboard-v2/src/components/forms/AddAPIKeyForm.js @@ -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 ( +
    + {state === State.Success && ( + + Success! +

    Please copy your new API key below. We'll never show it again!

    +
    + + {generatedKey} + + +
    +
    + )} + {state === State.Failure && ( + We were not able to generate a new key. Please try again later. + )} + { + 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 }) => ( +
    +
    + +
    +
    + {isSubmitting ? ( + + ) : ( + + )} +
    +
    + )} +
    +
    + ); +}); + +AddAPIKeyForm.displayName = "AddAPIKeyForm"; + +AddAPIKeyForm.propTypes = { + onSuccess: PropTypes.func.isRequired, + type: PropTypes.oneOf([APIKeyType.Public, APIKeyType.General]).isRequired, +}; diff --git a/packages/dashboard-v2/src/components/forms/AddSkylinkToAPIKeyForm.js b/packages/dashboard-v2/src/components/forms/AddSkylinkToAPIKeyForm.js new file mode 100644 index 00000000..fda27d36 --- /dev/null +++ b/packages/dashboard-v2/src/components/forms/AddSkylinkToAPIKeyForm.js @@ -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 }) => ( + { + 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 }) => ( +
    +
    + +
    +
    + {isSubmitting ? ( + + ) : ( + + )} +
    +
    + )} +
    +); + +AddSkylinkToAPIKeyForm.propTypes = { + onFailure: PropTypes.func.isRequired, + onSuccess: PropTypes.func.isRequired, + keyId: PropTypes.string.isRequired, +}; diff --git a/packages/dashboard-v2/src/pages/settings/api-keys.js b/packages/dashboard-v2/src/pages/settings/api-keys.js index 8ed8b1c8..3cbefb17 100644 --- a/packages/dashboard-v2/src/pages/settings/api-keys.js +++ b/packages/dashboard-v2/src/pages/settings/api-keys.js @@ -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 ( <>
    -
    +

    API Keys

    -

    - At vero eos et caritatem, quae sine metu contineret, saluti prospexit civium, qua. Laudem et dolorem - aspernari ut ad naturam aut fu. +

    There are two types of API keys that you can generate for your account.

    +

    Make sure to use the appropriate type.

    +
    + +
    + +
    +
    Public keys
    +

    + Give read access to a selected list of Skylinks. You can share them publicly.

    + +
    + +
    + + {error ? ( + + An error occurred while loading your API keys. Please try again later. + + ) : ( +
    + {publicKeys?.length > 0 ? ( + refreshState(true)} /> + ) : ( + No public API keys found. + )} +
    + )}

    -
    -
    - -
    - -
    + +
    +
    General keys
    +

    + Give full access to Accounts service and are equivalent to using a JWT token. +

    +

    This type of API keys need to be kept secret and never shared with anyone.

    + +
    +
    + + {error ? ( + + An error occurred while loading your API keys. Please try again later. + + ) : ( +
    + {generalKeys?.length > 0 ? ( + refreshState(true)} /> + ) : ( + No general API keys found. + )} +
    + )}
    - {apiKeys?.length > 0 && ( -
    -
    API Keys
    -
      - {apiKeys.map(({ id, name, createdAt }) => ( -
    • - {name || id} - - {dayjs(createdAt).format("MMM DD, YYYY")} - - - -
    • - ))} -
    -
    - )}
    -
    - +
    +
    diff --git a/packages/dashboard-v2/tailwind.config.js b/packages/dashboard-v2/tailwind.config.js index 076d6912..636cd40e 100644 --- a/packages/dashboard-v2/tailwind.config.js +++ b/packages/dashboard-v2/tailwind.config.js @@ -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: [