diff --git a/packages/plausible/index.tsx b/packages/plausible/index.tsx index 6e17d9b..245a48a 100644 --- a/packages/plausible/index.tsx +++ b/packages/plausible/index.tsx @@ -4,15 +4,17 @@ import { RouteObject } from "react-router-dom"; import { InjectionZoneEntry, InjectionZone, - CuratorConfig, + CuratorPlugin, MainMenuItem, } from "@curatorjs/studio"; import PlausibleDashboard from "./routes/PlausibleDashboard"; import icon from "./assets/icon.png"; -export default function plausiblePlugin({ sharedLink }: PluginOptions) { - return (config: CuratorConfig): CuratorConfig => { +export default function plausiblePlugin({ + sharedLink, +}: PluginOptions): CuratorPlugin { + return (config) => { return R.evolve({ zones: R.append({ zone: InjectionZone.MainMenuBottom, diff --git a/packages/studio/app/locales/en/translation.json b/packages/studio/app/locales/en/translation.json index 6d84f56..f3cb217 100644 --- a/packages/studio/app/locales/en/translation.json +++ b/packages/studio/app/locales/en/translation.json @@ -185,6 +185,9 @@ "common.apply": "Apply", "common.update": "Update", "common.versioning": "Version history", + "common.never": "Never", + "common.description": "Description", + "common.permissions": "Permissions", "secrets.title": "Secrets", "secrets.description": "Key-values with role scoped access.", @@ -212,5 +215,24 @@ "versioning.field": "Field", "versioning.current": "Current", "versioning.selected": "Selected", - "versioning.restore": "Restore this version" + "versioning.restore": "Restore this version", + + "api_tokens.title": "API tokens", + "api_tokens.description": "Generated tokens can consume the API.", + "api_tokens.delete_warning": "Once deleted the API key can no longer be used.", + "api_tokens.deleted": "API key has been deleted.", + "api_tokens.last_used": "Last used", + "api_tokens.expires_at": "Expires", + "api_tokens.create_api_token": "Create API token", + "api_tokens.lifespan": "Token duration", + "api_tokens.lifespan_days": "{{days}} days", + "api_tokens.unlimited": "Unlimited", + "api_tokens.read_only": "Read-only", + "api_tokens.full_access": "Full access", + "api_tokens.custom": "Custom", + "api_tokens.token_not_accessible_anymore": "For security reasons, you can only see your token once.", + "api_tokens.generated_description": "Make sure to copy this token, you won't be able to see it again!", + "api_tokens.generated_title": "Here is your API key", + "api_tokens.regenerate": "Regenerate", + "api_tokens.confirm_regeneration": "This will invalidate the current API token." } diff --git a/packages/studio/app/locales/nl/translation.json b/packages/studio/app/locales/nl/translation.json index dbc3eef..3c67253 100644 --- a/packages/studio/app/locales/nl/translation.json +++ b/packages/studio/app/locales/nl/translation.json @@ -187,6 +187,9 @@ "common.default": "Standaard", "common.apply": "Toepassen", "common.versioning": "Versiegeschiedenis", + "common.never": "Nooit", + "common.description": "Omschrijving", + "common.permissions": "Rechten", "secrets.title": "Geheimen", "secrets.description": "Sleutel-waarden met toegang op rolbasis.", @@ -214,5 +217,24 @@ "versioning.field": "Veld", "versioning.current": "Huidig", "versioning.selected": "Geselecteerd", - "versioning.restore": "Herstel deze versie" + "versioning.restore": "Herstel deze versie", + + "api_tokens.title": "API-sleutels", + "api_tokens.description": "API-sleutels geven toegang tot de API.", + "api_tokens.delete_warning": "Verwijderde API-sleutels kunnen niet meer worden gebruikt.", + "api_tokens.deleted": "API-sleutel is verwijderd.", + "api_tokens.last_used": "Laatst gebruikt", + "api_tokens.expires_at": "Vervalt", + "api_tokens.create_api_token": "Maak API-sleutel aan", + "api_tokens.lifespan": "Geldigheidsduur sleutel", + "api_tokens.lifespan_days": "{{days}} dagen", + "api_tokens.unlimited": "Onbeperkt", + "api_tokens.read_only": "Alleen-lezen", + "api_tokens.full_access": "Volledige toegang", + "api_tokens.custom": "Aangepast", + "api_tokens.token_not_accessible_anymore": "Om veiligheidsredenen wordt de sleutel niet meer getoond.", + "api_tokens.generated_description": "Dit is een goed moment om de sleutel te kopiëren, want hij wordt slechts eenmaal weergegeven!", + "api_tokens.generated_title": "Hier is je API-sleutel", + "api_tokens.regenerate": "Opnieuw genereren", + "api_tokens.confirm_regeneration": "Hierdoor wordt de huidige API-sleutel ongeldig." } diff --git a/packages/studio/app/plugins/api-tokens/index.tsx b/packages/studio/app/plugins/api-tokens/index.tsx new file mode 100644 index 0000000..7a13290 --- /dev/null +++ b/packages/studio/app/plugins/api-tokens/index.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import * as R from "ramda"; +import { RouteObject } from "react-router-dom"; + +import type { InjectionZoneEntry, CuratorConfig } from "@/types/config"; +import { InjectionZone } from "@/types/config"; + +import MainMenuItem from "./ui/MainMenuItem"; +import ApiTokens from "./routes/ApiTokens"; + +/** + * Plugin for managing available API tokens. + */ +export default function apiTokensPlugin({ weight }: { weight?: number } = {}) { + return (config: CuratorConfig): CuratorConfig => { + return R.evolve({ + zones: R.append({ + zone: InjectionZone.MainMenuSettings, + weight: weight ?? 30, + render() { + return ; + }, + }), + routes: R.concat([ + { + path: "/settings/api-tokens", + element: , + }, + ]), + })(config); + }; +} + +interface PluginOptions { + sharedLink: string; +} diff --git a/packages/studio/app/plugins/api-tokens/routes/ApiTokens/ApiTokenForm.tsx b/packages/studio/app/plugins/api-tokens/routes/ApiTokens/ApiTokenForm.tsx new file mode 100644 index 0000000..84f8827 --- /dev/null +++ b/packages/studio/app/plugins/api-tokens/routes/ApiTokens/ApiTokenForm.tsx @@ -0,0 +1,222 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Button, + Input, + message, + Modal, + Radio, + Result, + Typography, +} from "antd"; +import { Form, Formik } from "formik"; +import * as R from "ramda"; +import dayjs from "dayjs"; + +import { ApiToken } from "@/types/apiToken"; +import useStrapi from "@/hooks/useStrapi"; +import Field from "@/ui/Field"; +import FormField from "@/ui/FormField"; + +import PermissionsSelect from "./PermissionsSelect"; + +const ApiTokenForm: React.FC<{ + item: ApiToken | { id: null }; + onClose: VoidFunction; +}> = ({ item, onClose }) => { + const { t } = useTranslation(); + const { sdk, permissions } = useStrapi(); + const [accessKey, setAccessKey] = useState(null); + const [modal, contextHolder] = Modal.useModal(); + + return ( + <> + {contextHolder} + & { id: number | null } + > + initialValues={ + item.id + ? item + : { + id: null, + name: "", + description: "", + lifespan: String(7 * 24 * 60 * 60 * 1_000), + type: "read-only", + permissions: null, + } + } + onSubmit={async (values) => { + try { + if (values.id) { + await sdk.updateApiToken(values.id, values); + onClose(); + } else { + const { accessKey } = await sdk.createApiToken( + R.evolve({ + lifespan: R.ifElse(R.isEmpty, R.always(null), Number), + })(values), + ); + setAccessKey(accessKey); + } + } catch (e: any) { + message.error(e.message); + } + }} + > + {({ values, submitForm, isSubmitting }) => ( + {t("common.done")} + ) : undefined + } + okButtonProps={{ ghost: !R.isNil(item.id) }} + confirmLoading={isSubmitting} + > + {!accessKey ? ( +
+ {item.id && ( + 🔐} + showIcon + message={t("api_tokens.token_not_accessible_anymore")} + action={ + + } + /> + )} + + + + + + + + + + + + + + + + + + + + + + {values.type === "custom" && ( + + + + )} + + ) : ( +
+ 🔑} + title={t("api_tokens.generated_title")} + subTitle={t("api_tokens.generated_description")} + > +
+ {accessKey} +
+
+
+ )} +
+ )} + + + ); +}; + +export default ApiTokenForm; diff --git a/packages/studio/app/plugins/api-tokens/routes/ApiTokens/ApiTokens.tsx b/packages/studio/app/plugins/api-tokens/routes/ApiTokens/ApiTokens.tsx new file mode 100644 index 0000000..2ae7108 --- /dev/null +++ b/packages/studio/app/plugins/api-tokens/routes/ApiTokens/ApiTokens.tsx @@ -0,0 +1,173 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEllipsisV, faUnlock } from "@fortawesome/free-solid-svg-icons"; +import { Button, Dropdown, Modal, notification, Tag } from "antd"; +import * as R from "ramda"; +import { useAsyncRetry } from "react-use"; +import dayjs from "dayjs"; + +import { ApiToken } from "@/types/apiToken"; +import useStrapi from "@/hooks/useStrapi"; +import Table from "@/ui/Table"; + +import CreateButton from "./CreateButton"; +import ApiTokenForm from "./ApiTokenForm"; +import CalendarTime from "@/ui/CalendarTime"; + +export default function ApiTokens() { + const { t } = useTranslation(); + const { sdk, permissions } = useStrapi(); + const [edit, setEdit] = useState(null); + const [modal, contextHolder] = Modal.useModal(); + const canUpdate = permissions.some( + R.whereEq({ action: "admin::api-tokens.update" }), + ); + const canDelete = permissions.some( + R.whereEq({ action: "admin::api-tokens.delete" }), + ); + + const { value: items = [], retry } = useAsyncRetry(async () => { + try { + return await sdk.getApiTokens(); + } catch (e) {} + }, [sdk]); + + return ( + <> + {contextHolder} + {edit && ( + { + setEdit(null); + retry(); + }} + /> + )} +
+
+
+

{t("api_tokens.title")}

+
+ {t("api_tokens.description")} +
+
+ +
+ +
+ {name} +
+
{description}
+
+ + } + color={ + { + "read-only": "geekblue", + "full-access": "orange", + custom: "purple", + }[type] + } + > + {t(`api_tokens.${type.replaceAll("-", "_")}`)} + +
+ + ); + }, + }, + { + key: "lastUsedAt", + dataIndex: "lastUsedAt", + title: t("api_tokens.last_used"), + render(lastUsedAt) { + return lastUsedAt ? ( + {lastUsedAt} + ) : ( + t("common.never") + ); + }, + }, + { + key: "expiresAt", + dataIndex: "expiresAt", + title: t("api_tokens.expires_at"), + render(expiresAt) { + return expiresAt + ? dayjs(expiresAt).format("l LT") + : t("common.never"); + }, + }, + { + key: "settings", + render(item) { + return ( +
+ +
+ ); + }, + }, + ]} + /> + + + ); +} diff --git a/packages/studio/app/plugins/api-tokens/routes/ApiTokens/CreateButton.tsx b/packages/studio/app/plugins/api-tokens/routes/ApiTokens/CreateButton.tsx new file mode 100644 index 0000000..313f58e --- /dev/null +++ b/packages/studio/app/plugins/api-tokens/routes/ApiTokens/CreateButton.tsx @@ -0,0 +1,36 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "antd"; +import * as R from "ramda"; + +import useStrapi from "@/hooks/useStrapi"; + +import ApiTokenForm from "./ApiTokenForm"; + +const CreateButton: React.FC<{ onCreate: VoidFunction }> = ({ onCreate }) => { + const { t } = useTranslation(); + const [form, setForm] = useState(false); + const { permissions } = useStrapi(); + const canCreate = permissions.some( + R.whereEq({ action: "admin::api-tokens.create" }), + ); + + return canCreate ? ( + <> + + {form && ( + { + setForm(false); + onCreate(); + }} + /> + )} + + ) : null; +}; + +export default CreateButton; diff --git a/packages/studio/app/plugins/api-tokens/routes/ApiTokens/PermissionsSelect.tsx b/packages/studio/app/plugins/api-tokens/routes/ApiTokens/PermissionsSelect.tsx new file mode 100644 index 0000000..efa8eb5 --- /dev/null +++ b/packages/studio/app/plugins/api-tokens/routes/ApiTokens/PermissionsSelect.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Select } from "antd"; +import useStrapi from "@/hooks/useStrapi"; +import { useAsync } from "react-use"; +import { PermissionConfig } from "@/types/permission"; +import * as R from "ramda"; + +const PermissionsSelect: React.FC<{ + onChange?(): void; + value?: string[]; +}> = ({ value, onChange }) => { + const { sdk } = useStrapi(); + + const { value: items = [] } = useAsync(async () => { + try { + const permissions = await sdk.getAllPermissions(); + + return R.sortBy( + R.identity, + Object.entries(permissions).flatMap( + ([namespace, config]: [string, PermissionConfig]) => + Object.entries(config.controllers).flatMap(([scope, perms]) => + perms.flatMap((perm) => `${namespace}.${scope}.${perm}`), + ), + ), + ); + } catch (e) {} + }, [sdk]); + + return ( +