Skip to content

Commit

Permalink
feat: implement api tokens management (#71)
Browse files Browse the repository at this point in the history
  • Loading branch information
StructuralRealist committed Sep 18, 2023
1 parent 65d0c93 commit 1713284
Show file tree
Hide file tree
Showing 17 changed files with 698 additions and 10 deletions.
8 changes: 5 additions & 3 deletions packages/plausible/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<InjectionZoneEntry>({
zone: InjectionZone.MainMenuBottom,
Expand Down
24 changes: 23 additions & 1 deletion packages/studio/app/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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."
}
24 changes: 23 additions & 1 deletion packages/studio/app/locales/nl/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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."
}
36 changes: 36 additions & 0 deletions packages/studio/app/plugins/api-tokens/index.tsx
Original file line number Diff line number Diff line change
@@ -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<InjectionZoneEntry>({
zone: InjectionZone.MainMenuSettings,
weight: weight ?? 30,
render() {
return <MainMenuItem />;
},
}),
routes: R.concat<RouteObject[]>([
{
path: "/settings/api-tokens",
element: <ApiTokens />,
},
]),
})(config);
};
}

interface PluginOptions {
sharedLink: string;
}
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [modal, contextHolder] = Modal.useModal();

return (
<>
{contextHolder}
<Formik<
Pick<
ApiToken,
"name" | "description" | "lifespan" | "type" | "permissions"
> & { 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 }) => (
<Modal
open
title={
accessKey
? ""
: item.id
? item.name
: t("api_tokens.create_api_token")
}
closeIcon={!accessKey}
onCancel={onClose}
onOk={submitForm}
cancelText={t("common.cancel")}
okText={item.id ? t("common.update") : t("common.create")}
footer={
accessKey ? (
<Button onClick={onClose}>{t("common.done")}</Button>
) : undefined
}
okButtonProps={{ ghost: !R.isNil(item.id) }}
confirmLoading={isSubmitting}
>
{!accessKey ? (
<Form className="py-8 space-y-4">
{item.id && (
<Alert
type="warning"
icon={<span>🔐</span>}
showIcon
message={t("api_tokens.token_not_accessible_anymore")}
action={
<Button
size="small"
onClick={() => {
modal.confirm({
title: t("phrases.are_you_sure"),
content: t("api_tokens.confirm_regeneration"),
cancelText: t("common.cancel"),
okText: t("api_tokens.regenerate"),
okButtonProps: { danger: true },
onOk: async () => {
try {
const accessKey = await sdk.regenerateApiToken(
item.id,
);
setAccessKey(accessKey);
} catch (e) {}
},
});
}}
>
{t("api_tokens.regenerate")}
</Button>
}
/>
)}
<FormField label={t("common.name")}>
<Field name="name">
<Input />
</Field>
</FormField>
<FormField label={t("common.description")}>
<Field name="description">
<Input.TextArea />
</Field>
</FormField>
<FormField
label={t("api_tokens.lifespan")}
help={
item.id &&
item.expiresAt &&
`${t("api_tokens.expires_at")} ${dayjs(
item.expiresAt,
).format("l LT")}`
}
>
<Field name="lifespan">
<Radio.Group
buttonStyle="solid"
optionType="button"
disabled={!R.isNil(item.id)}
options={[
{
label: t("api_tokens.lifespan_days", { days: 7 }),
value: String(7 * 24 * 60 * 60 * 1_000),
},
{
label: t("api_tokens.lifespan_days", { days: 30 }),
value: String(30 * 24 * 60 * 60 * 1_000),
},
{
label: t("api_tokens.lifespan_days", { days: 90 }),
value: String(90 * 24 * 60 * 60 * 1_000),
},
{
label: t("api_tokens.unlimited"),
value: "",
},
]}
/>
</Field>
</FormField>
<FormField label={t("common.permissions")}>
<Field name="type">
<Radio.Group
options={[
{
label: t("api_tokens.read_only"),
value: "read-only",
},
{
label: t("api_tokens.full_access"),
value: "full-access",
},
{
label: t("api_tokens.custom"),
value: "custom",
},
]}
/>
</Field>
</FormField>

{values.type === "custom" && (
<Field name="permissions">
<PermissionsSelect />
</Field>
)}
</Form>
) : (
<div>
<Result
icon={<span className="text-5xl">🔑</span>}
title={t("api_tokens.generated_title")}
subTitle={t("api_tokens.generated_description")}
>
<div className="font-mono">
<Typography.Text copyable>{accessKey}</Typography.Text>
</div>
</Result>
</div>
)}
</Modal>
)}
</Formik>
</>
);
};

export default ApiTokenForm;
Loading

0 comments on commit 1713284

Please sign in to comment.