diff --git a/packages/dashboard-v2/gatsby-config.js b/packages/dashboard-v2/gatsby-config.js index 160a7784..ce35de3a 100644 --- a/packages/dashboard-v2/gatsby-config.js +++ b/packages/dashboard-v2/gatsby-config.js @@ -5,6 +5,7 @@ module.exports = { title: `Accounts Dashboard`, siteUrl: `https://www.yourdomain.tld`, }, + trailingSlash: "never", plugins: [ "gatsby-plugin-image", "gatsby-plugin-provide-react", 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..5cb6680a --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/APIKey.js @@ -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 ( +
  • + + + {name || "unnamed key"} + + + + + {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..a821ca02 --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyEdit.js @@ -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, + }; +}; 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..b9c53bd9 --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyRemoval.js @@ -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, + }; +}; diff --git a/packages/dashboard-v2/src/components/Alert/Alert.js b/packages/dashboard-v2/src/components/Alert/Alert.js index 0e6ab345..4db72620 100644 --- a/packages/dashboard-v2/src/components/Alert/Alert.js +++ b/packages/dashboard-v2/src/components/Alert/Alert.js @@ -3,6 +3,7 @@ import cn from "classnames"; export const Alert = styled.div.attrs(({ $variant }) => ({ 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-green-100 border-green-200 text-palette-400": $variant === "success", }), diff --git a/packages/dashboard-v2/src/components/Icons/icons/ImportantNoteIcon.js b/packages/dashboard-v2/src/components/Icons/icons/ImportantNoteIcon.js new file mode 100644 index 00000000..94514716 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/ImportantNoteIcon.js @@ -0,0 +1,11 @@ +import { withIconProps } from "../withIconProps"; + +export const ImportantNoteIcon = withIconProps(({ size, ...props }) => ( + + + + + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/index.js b/packages/dashboard-v2/src/components/Icons/index.js index e2af85e9..0bb96694 100644 --- a/packages/dashboard-v2/src/components/Icons/index.js +++ b/packages/dashboard-v2/src/components/Icons/index.js @@ -14,3 +14,4 @@ export * from "./icons/CopyIcon"; export * from "./icons/ShareIcon"; export * from "./icons/SimpleUploadIcon"; export * from "./icons/TrashIcon"; +export * from "./icons/ImportantNoteIcon"; 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/Tooltip/Tooltip.js b/packages/dashboard-v2/src/components/Tooltip/Tooltip.js new file mode 100644 index 00000000..d7bfde39 --- /dev/null +++ b/packages/dashboard-v2/src/components/Tooltip/Tooltip.js @@ -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 ( + + {children} + {visible && {message}} + + ); +}; 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/AddPublicAPIKeyForm.js b/packages/dashboard-v2/src/components/forms/AddPublicAPIKeyForm.js new file mode 100644 index 00000000..2184c513 --- /dev/null +++ b/packages/dashboard-v2/src/components/forms/AddPublicAPIKeyForm.js @@ -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 ( +
    + {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: "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 }) => ( +
    +
    + +
    +
    +
    Skylinks accessible with the new key
    + { + 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 ( +
    + {skylinks.map((_, index) => ( +
    + + + + +
    + ))} + +
    + { + if (event.key === "Enter" && isValid) { + event.preventDefault(); + appendSkylink(values.nextSkylink); + } + }} + /> + +
    +
    + ); + }} + /> +
    + +
    + +
    +
    + )} +
    +
    + ); +}); + +AddPublicAPIKeyForm.displayName = "AddAPIKeyForm"; + +AddPublicAPIKeyForm.propTypes = { + onSuccess: PropTypes.func.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..55f27a01 --- /dev/null +++ b/packages/dashboard-v2/src/components/forms/AddSkylinkToAPIKeyForm.js @@ -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 }) => ( + { + if (await addSkylink(parseSkylink(skylink))) { + resetForm(); + } + }} + > + {({ errors, touched, isSubmitting }) => ( +
    +
    + +
    +
    + {isSubmitting ? ( + + ) : ( + + )} +
    +
    + )} +
    +); + +AddSkylinkToAPIKeyForm.propTypes = { + addSkylink: PropTypes.func.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..1f6c93c5 100644 --- a/packages/dashboard-v2/src/pages/settings/api-keys.js +++ b/packages/dashboard-v2/src/pages/settings/api-keys.js @@ -1,63 +1,100 @@ 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"; +import { AddPublicAPIKeyForm } from "../../components/forms/AddPublicAPIKeyForm"; 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
    +

    + Public keys provide 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
    +

    + These keys provide full access to Accounts service and are equivalent to using a JWT token. +

    +

    + This type of API keys needs to be kept secret and should never be 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/static/images/api-keys.svg b/packages/dashboard-v2/static/images/api-keys.svg new file mode 100644 index 00000000..35fe3744 --- /dev/null +++ b/packages/dashboard-v2/static/images/api-keys.svg @@ -0,0 +1 @@ + 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: [