From c2cd32995d7ddfc80ded7044ad10c79af68b81b3 Mon Sep 17 00:00:00 2001 From: Agusti Fernandez Pardo Date: Thu, 7 Apr 2022 03:29:07 +0200 Subject: [PATCH] feat: api keys frontend in security page --- .../security/ChangePasswordSection.tsx | 12 +- .../security/TwoFactorAuthSection.tsx | 32 ++-- .../components/apiKeys/ApiKeyDialogForm.tsx | 110 ++++++++++++++ .../apiKeys/ApiKeyListContainer.tsx | 80 ++++++++++ .../ee/components/apiKeys/ApiKeyListItem.tsx | 85 +++++++++++ apps/web/pages/settings/security.tsx | 8 +- apps/web/public/static/locales/en/common.json | 7 +- apps/web/server/routers/viewer.tsx | 4 +- apps/web/server/routers/viewer/apiKeys.tsx | 138 ++++++++++++++++++ packages/prisma/schema.prisma | 17 ++- 10 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx create mode 100644 apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx create mode 100644 apps/web/ee/components/apiKeys/ApiKeyListItem.tsx create mode 100644 apps/web/server/routers/viewer/apiKeys.tsx diff --git a/apps/web/components/security/ChangePasswordSection.tsx b/apps/web/components/security/ChangePasswordSection.tsx index fdfa153153..48b078a9d3 100644 --- a/apps/web/components/security/ChangePasswordSection.tsx +++ b/apps/web/components/security/ChangePasswordSection.tsx @@ -56,11 +56,11 @@ const ChangePasswordSection = () => { return ( <> -
+ {/*

{t("change_password")}

-
+
*/}
-
+
{errorMessage &&

{errorMessage}

}
- +
-
+ {/*
*/}
diff --git a/apps/web/components/security/TwoFactorAuthSection.tsx b/apps/web/components/security/TwoFactorAuthSection.tsx index 00fd4adf0a..6cd07d942c 100644 --- a/apps/web/components/security/TwoFactorAuthSection.tsx +++ b/apps/web/components/security/TwoFactorAuthSection.tsx @@ -17,21 +17,25 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean return ( <> -
-

{t("2fa")}

- - {enabled ? t("enabled") : t("disabled")} - +
+
+
+

{t("2fa")}

+ + {enabled ? t("enabled") : t("disabled")} + +
+

{t("add_an_extra_layer_of_security")}

+
+
+ +
-

{t("add_an_extra_layer_of_security")}

- - - {enableModalOpen && ( { diff --git a/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx b/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx new file mode 100644 index 0000000000..28232895b4 --- /dev/null +++ b/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx @@ -0,0 +1,110 @@ +import dayjs from "dayjs"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { unknown } from "zod"; + +import { generateUniqueAPIKey } from "@calcom/ee/lib/api/apiKeys"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; +import Button from "@calcom/ui/Button"; +import { DialogFooter } from "@calcom/ui/Dialog"; +import Switch from "@calcom/ui/Switch"; +import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields"; + +import { trpc } from "@lib/trpc"; + +import { DatePicker } from "@components/ui/form/DatePicker"; + +import { TApiKeys } from "./ApiKeyListItem"; + +export default function ApiKeyDialogForm(props: { defaultValues?: TApiKeys; handleClose: () => void }) { + const { t } = useLocale(); + const utils = trpc.useContext(); + const handleNoteChange = (e) => { + form.setValue("note", e.target.value); + }; + const { + defaultValues = { + id: "", + note: "" as string | undefined, + expiresAt: "" as unknown as Date, + neverExpires: true, + hashedKey: "", + } as Omit, + } = props; + + const [selectedDate, setSelectedDate] = useState(props?.defaultValues?.expiresAt); + const [neverExpired, onNeverExpired] = useState(false); + + const handleDateChange = (e) => { + setSelectedDate(e); + form.setValue("expiresAt", e); + }; + const form = useForm({ + defaultValues, + }); + return ( +
{ + if (event.id) { + await utils.client.mutation("viewer.apiKeys.edit", event); + await utils.invalidateQueries(["viewer.apiKeys.list"]); + showToast(t("apiKeys_updated_successfully"), "success"); + } else { + await utils.client.mutation("viewer.apiKeys.create", e); + await utils.invalidateQueries(["viewer.apiKeys.list"]); + showToast(t("apiKeys_created_successfully"), "success"); + } + props.handleClose(); + }} + className="space-y-4"> + + + + +
+
+ Expire date + + ( + { + form.setValue("neverExpires", isChecked); + }} + /> + )} + /> +
+ +
+
+ + + + + + ); +} diff --git a/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx b/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx new file mode 100644 index 0000000000..21d0af0fc2 --- /dev/null +++ b/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx @@ -0,0 +1,80 @@ +import { PlusIcon } from "@heroicons/react/outline"; +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import Button from "@calcom/ui/Button"; +import { Dialog, DialogContent } from "@calcom/ui/Dialog"; +import ApiKeyDialogForm from "@ee/components/apiKeys/ApiKeyDialogForm"; +import ApiKeyListItem, { TApiKeys } from "@ee/components/apiKeys/ApiKeyListItem"; + +import { QueryCell } from "@lib/QueryCell"; +import { trpc } from "@lib/trpc"; + +import { List } from "@components/List"; + +export default function ApiKeyListContainer() { + const { t } = useLocale(); + const query = trpc.useQuery(["viewer.apiKeys.list"]); + + const [newApiKeyModal, setNewApiKeyModal] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + return ( + ( + <> +
+
+

{t("api_keys")}

+

{t("api_keys_subtitle")}

+
+
+ +
+
+ + {data.length ? ( + + {data.map((item) => ( + { + setEditing(item); + setEditModalOpen(true); + }} + /> + ))} + + ) : null} + + {/* New webhook dialog */} + !isOpen && setNewApiKeyModal(false)}> + + setNewApiKeyModal(false)} /> + + + {/* Edit webhook dialog */} + !isOpen && setEditModalOpen(false)}> + + {editing && ( + setEditModalOpen(false)} + defaultValues={editing} + /> + )} + + + + )} + /> + ); +} diff --git a/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx b/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx new file mode 100644 index 0000000000..808d511f62 --- /dev/null +++ b/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx @@ -0,0 +1,85 @@ +import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; + +import classNames from "@calcom/lib/classNames"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import Button from "@calcom/ui/Button"; +import { Dialog, DialogTrigger } from "@calcom/ui/Dialog"; + +import { inferQueryOutput, trpc } from "@lib/trpc"; + +import { ListItem } from "@components/List"; +import { Tooltip } from "@components/Tooltip"; +import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; +import Badge from "@components/ui/Badge"; + +export type TApiKeys = inferQueryOutput<"viewer.apiKeys.list">[number]; + +export default function ApiKeyListItem(props: { apiKey: TApiKeys; onEditApiKey: () => void }) { + const { t } = useLocale(); + const utils = trpc.useContext(); + const isExpired = props.apiKey.expiresAt < new Date(); + const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete", { + async onSuccess() { + await utils.invalidateQueries(["viewer.apiKeys.list"]); + }, + }); + return ( + +
+
+
+ + {props?.apiKey?.note} + + {isExpired ? Expired : null} +
+
+ + {props?.apiKey?.expiresAt.toLocaleDateString()} + +
+
+
+ + + + + + + + + + + deleteApiKey.mutate({ + id: props.apiKey.id, + }) + }> + {t("delete_api_key_confirmation_message")} + + +
+
+
+ ); +} diff --git a/apps/web/pages/settings/security.tsx b/apps/web/pages/settings/security.tsx index eeed2750fd..58e8e57ea3 100644 --- a/apps/web/pages/settings/security.tsx +++ b/apps/web/pages/settings/security.tsx @@ -1,10 +1,11 @@ import { IdentityProvider } from "@prisma/client"; import React from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import ApiKeyListContainer from "@ee/components/apiKeys/ApiKeyListContainer"; import SAMLConfiguration from "@ee/components/saml/Configuration"; import { identityProviderNameMap } from "@lib/auth"; -import { useLocale } from "@lib/hooks/useLocale"; import { trpc } from "@lib/trpc"; import SettingsShell from "@components/SettingsShell"; @@ -34,10 +35,11 @@ export default function Security() {

) : ( - <> +
+ - +
)} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 158a2145ca..a135231c37 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -708,5 +708,10 @@ "12_hour": "12 hour", "24_hour": "24 hour", "duplicate": "Duplicate", - "you_can_manage_your_schedules": "You can manage your schedules on the Availability page." + "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.", + "api_keys": "API Keys", + "api_keys_subtitle": "Generate API keys to use for accessing your own account.", + "generate_new_token": "Generate new token", + "personal_note": "Personal Note", + "delete_pat": "Delete personal access token" } diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index cf420afe60..dc5552fd25 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -20,6 +20,7 @@ import { } from "@lib/saml"; import slugify from "@lib/slugify"; +import { apiKeysRouter } from "@server/routers/viewer/apiKeys"; import { availabilityRouter } from "@server/routers/viewer/availability"; import { eventTypesRouter } from "@server/routers/viewer/eventTypes"; import { TRPCError } from "@trpc/server"; @@ -800,4 +801,5 @@ export const viewerRouter = createRouter() .merge("eventTypes.", eventTypesRouter) .merge("availability.", availabilityRouter) .merge("teams.", viewerTeamsRouter) - .merge("webhook.", webhookRouter); + .merge("webhook.", webhookRouter) + .merge("apiKeys.", apiKeysRouter); diff --git a/apps/web/server/routers/viewer/apiKeys.tsx b/apps/web/server/routers/viewer/apiKeys.tsx new file mode 100644 index 0000000000..92897200d0 --- /dev/null +++ b/apps/web/server/routers/viewer/apiKeys.tsx @@ -0,0 +1,138 @@ +import { v4 } from "uuid"; +import { z } from "zod"; + +import { getErrorFromUnknown } from "@calcom/lib/errors"; + +// import { WEBHOOK_TRIGGER_EVENTS } from "@lib/apiKeys/constants"; +// import sendPayload from "@lib/apiKeys/sendPayload"; +import { createProtectedRouter } from "@server/createRouter"; +import { getTranslation } from "@server/lib/i18n"; + +export const apiKeysRouter = createProtectedRouter() + .query("list", { + async resolve({ ctx }) { + return await ctx.prisma.apiKey.findMany({ + where: { + userId: ctx.user.id, + }, + orderBy: { createdAt: "desc" }, + }); + }, + }) + .mutation("create", { + input: z.object({ + note: z.string().optional().nullish(), + hashedKey: z.string(), + neverExpires: z.boolean(), + expiresAt: z.date().optional(), + }), + async resolve({ ctx, input }) { + return await ctx.prisma.apiKey.create({ + data: { + id: v4(), + userId: ctx.user.id, + ...input, + }, + }); + }, + }) + .mutation("edit", { + input: z.object({ + id: z.string(), + neverExpires: z.boolean(), + note: z.string().optional().nullish(), + expiresAt: z.date().optional(), + }), + async resolve({ ctx, input }) { + const { id, ...data } = input; + const apiKey = await ctx.prisma.apiKey.findFirst({ + where: { + userId: ctx.user.id, + id, + }, + }); + if (!apiKey) { + // user does not own this apiKey + return null; + } + return await ctx.prisma.apiKey.update({ + where: { + id, + }, + data, + }); + }, + }) + .mutation("delete", { + input: z.object({ + id: z.string(), + eventTypeId: z.number().optional(), + }), + async resolve({ ctx, input }) { + const { id } = input; + await ctx.prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + apiKeys: { + delete: { + id, + }, + }, + }, + }); + return { + id, + }; + }, + }); +// .mutation("testTrigger", { +// input: z.object({ +// url: z.string().url(), +// type: z.string(), +// payloadTemplate: z.string().optional().nullable(), +// }), +// async resolve({ input }) { +// const { url, type, payloadTemplate } = input; +// const translation = await getTranslation("en", "common"); +// const language = { +// locale: "en", +// translate: translation, +// }; + +// const data = { +// triggerEvent: "PING", +// type: "Test", +// title: "Test trigger event", +// description: "", +// startTime: new Date().toISOString(), +// endTime: new Date().toISOString(), +// attendees: [ +// { +// email: "jdoe@example.com", +// name: "John Doe", +// timeZone: "Europe/London", +// language, +// }, +// ], +// organizer: { +// name: "Cal", +// email: "no-reply@cal.com", +// timeZone: "Europe/London", +// language, +// }, +// }; + +// try { +// return await sendPayload(type, new Date().toISOString(), url, data, payloadTemplate); +// } catch (_err) { +// const error = getErrorFromUnknown(_err); +// return { +// ok: false, +// status: 500, +// message: error.message, +// }; +// } +// }, +// }); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f2f83b5279..162977568f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -366,12 +366,13 @@ model Webhook { } model ApiKey { - id String @id @unique @default(cuid()) - userId Int - note String? - createdAt DateTime @default(now()) - expiresAt DateTime @default(dbgenerated("NOW() + interval '30 days'")) - lastUsedAt DateTime @default(now()) - hashedKey String @unique() - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @unique @default(cuid()) + userId Int + note String? + createdAt DateTime @default(now()) + neverExpires Boolean @default(false) + expiresAt DateTime @default(dbgenerated("NOW() + interval '30 days'")) + lastUsedAt DateTime @default(now()) + hashedKey String @unique() + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) }