From faa67e0bb62da3658527c83f2e940583aa122eb7 Mon Sep 17 00:00:00 2001 From: Agusti Fernandez Date: Sat, 16 Apr 2022 04:58:34 +0200 Subject: [PATCH] Feature: Adds api keys to cal.com webapp (#2277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add ApiKey model for new Api auth, owned by a user * fix: remove metadata:Json and add note:String instead in new apiKey model * fix: rename apiKey to apiKeys in moder User relation in schema.prisma * feat: add hashedKey to apiKey and lastUsedAt datetime to keep track of usage of keys and makiung them securely stored in db * fix 30 day -> 30 days in expiresAt * feat: api keys frontend in security page * adds hashedKey to api key model, add frontend api keys in security page * Make frontend work to create api keys with or without expiry, note, defaults to 1 month expiry * remove migration for now, add env.example to swagger, sync api * feat: hashed api keys * fix: minor refactor and cleanup in apiKeys generator * add api key success modal * sync apps/api * feat: We have API Keys in Security =) * remove swagger env from pr * apps api sync * remove comments in password section * feat: migration for api keys schema * sync api w main * delete apps/api * add back apps/api * make min date and disabled optional props in datepicker * feat fix type check errors * fix : types * fix: rmeove renaming of verificationrequest token indexes in migration * fix: remove extra div * Fixes for feedback in PR * fix button /> * fix: rename weird naming of translation for you_will_only_view_it_once * fix: remove ternary and use && to avoid null for false * fix sync apps/api with main not old commit * fix empty className * fix: remove unused imports * fix remove commented jsx fragment close * fix rename editing * improve translations * feat: adds beta tag in security tab under api keys * fix: use api keys everywhere * fix: cleanup code in api keys * fix: use watch and controller for neverexpires/datepicker * Fixes: improve api key never expires * add back change password h2 title section in security page * fix update env API_KEY_ prefix default to cal_ * fix: improve eidt api keys modal * fix: update edit mutation in viewer.apiKeys * Update apps/web/ee/components/apiKeys/ApiKeyListItem.tsx Co-authored-by: Alex van Andel * fix: item: any to pass build Co-authored-by: Agusti Fernandez Pardo Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Omar López Co-authored-by: Alex van Andel --- .env.example | 3 + .../security/ChangePasswordSection.tsx | 13 +- .../security/TwoFactorAuthSection.tsx | 32 +- apps/web/components/ui/form/DatePicker.tsx | 6 +- .../components/apiKeys/ApiKeyDialogForm.tsx | 150 +++ .../apiKeys/ApiKeyListContainer.tsx | 77 ++ .../ee/components/apiKeys/ApiKeyListItem.tsx | 107 ++ apps/web/pages/settings/security.tsx | 8 +- apps/web/public/static/locales/en/common.json | 27 + apps/web/server/routers/viewer.tsx | 4 +- apps/web/server/routers/viewer/apiKeys.tsx | 102 ++ packages/ee/lib/api/apiKeys.ts | 10 + packages/prisma/json-schema/json-schema.json | 1064 +++++++++++++++++ .../migration.sql | 21 + packages/prisma/schema.prisma | 12 + 15 files changed, 1611 insertions(+), 25 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 create mode 100644 packages/ee/lib/api/apiKeys.ts create mode 100644 packages/prisma/json-schema/json-schema.json create mode 100644 packages/prisma/migrations/20220413002425_adds_api_keys/migration.sql diff --git a/.env.example b/.env.example index c61a95761b..39d1ca8ee5 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,9 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT= NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE= NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE= NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE= + +# Use for internal Public API Keys and optional +API_KEY_PREFIX=cal_ # *********************************************************************************************************** # - E-MAIL SETTINGS ***************************************************************************************** diff --git a/apps/web/components/security/ChangePasswordSection.tsx b/apps/web/components/security/ChangePasswordSection.tsx index 6ea976fc97..3c8684ad81 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")}

-
-
+
+
+

{t("change_password")}

+
{errorMessage &&

{errorMessage}

}
- +
-
diff --git a/apps/web/components/security/TwoFactorAuthSection.tsx b/apps/web/components/security/TwoFactorAuthSection.tsx index 00fd4adf0a..72aaeb8872 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/components/ui/form/DatePicker.tsx b/apps/web/components/ui/form/DatePicker.tsx index f45d97bd51..6edb71dfb3 100644 --- a/apps/web/components/ui/form/DatePicker.tsx +++ b/apps/web/components/ui/form/DatePicker.tsx @@ -10,9 +10,11 @@ type Props = { date: Date; onDatesChange?: ((date: Date) => void) | undefined; className?: string; + disabled?: boolean; + minDate?: Date; }; -export const DatePicker = ({ date, onDatesChange, className }: Props) => { +export const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => { return ( { clearIcon={null} calendarIcon={} value={date} + minDate={minDate} + disabled={disabled} onChange={onDatesChange} /> ); diff --git a/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx b/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx new file mode 100644 index 0000000000..b3b168bff6 --- /dev/null +++ b/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx @@ -0,0 +1,150 @@ +import { ClipboardCopyIcon } from "@heroicons/react/solid"; +import dayjs from "dayjs"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; + +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 { Form, TextField } from "@calcom/ui/form/fields"; + +import { trpc } from "@lib/trpc"; + +import { Tooltip } from "@components/Tooltip"; +import { DatePicker } from "@components/ui/form/DatePicker"; + +import { TApiKeys } from "./ApiKeyListItem"; + +export default function ApiKeyDialogForm(props: { + title: string; + defaultValues?: Omit & { neverExpires: boolean }; + handleClose: () => void; +}) { + const { t } = useLocale(); + const utils = trpc.useContext(); + + const { + defaultValues = { + note: "", + neverExpires: false, + expiresAt: dayjs().add(1, "month").toDate(), + }, + } = props; + + const [apiKey, setApiKey] = useState(""); + const [successfulNewApiKeyModal, setSuccessfulNewApiKeyModal] = useState(false); + const [apiKeyDetails, setApiKeyDetails] = useState({ + id: "", + hashedKey: "", + expiresAt: null as Date | null, + note: "" as string | null, + neverExpires: false, + }); + + const form = useForm({ + defaultValues, + }); + const watchNeverExpires = form.watch("neverExpires"); + + return ( + <> + {successfulNewApiKeyModal ? ( + <> +
+

+ {apiKeyDetails ? t("success_api_key_edited") : t("success_api_key_created")} +

+
+ {t("success_api_key_created_bold_tagline")}{" "} + {t("you_will_only_view_it_once")} +
+
+
+
+ + {apiKey} + + + + +
+ + {apiKeyDetails.neverExpires + ? t("never_expire_key") + : `${t("expires")} ${apiKeyDetails?.expiresAt?.toLocaleDateString()}`} + +
+ + + + + ) : ( + & { neverExpires: boolean }> + form={form} + handleSubmit={async (event) => { + const apiKey = await utils.client.mutation("viewer.apiKeys.create", event); + setApiKey(apiKey); + setApiKeyDetails({ ...event }); + await utils.invalidateQueries(["viewer.apiKeys.list"]); + setSuccessfulNewApiKeyModal(true); + }} + className="space-y-4"> +
+

{props.title}

+

{t("api_key_modal_subtitle")}

+
+ + +
+
+ {t("expire_date")} + ( + + )} + /> +
+ ( + + )} + /> +
+ + + + + + )} + + ); +} diff --git a/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx b/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx new file mode 100644 index 0000000000..2fee9c2f28 --- /dev/null +++ b/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx @@ -0,0 +1,77 @@ +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 [apiKeyToEdit, setApiKeyToEdit] = useState<(TApiKeys & { neverExpires: boolean }) | null>(null); + return ( + ( + <> +
+
+

{t("api_keys")}

+

{t("api_keys_subtitle")}

+
+
+ +
+
+ + {data.length && ( + + {data.map((item: any) => ( + { + setApiKeyToEdit(item); + setEditModalOpen(true); + }} + /> + ))} + + )} + + {/* New api key dialog */} + !isOpen && setNewApiKeyModal(false)}> + + setNewApiKeyModal(false)} /> + + + {/* Edit api key dialog */} + !isOpen && setEditModalOpen(false)}> + + {apiKeyToEdit && ( + setEditModalOpen(false)} + defaultValues={apiKeyToEdit} + /> + )} + + + + )} + /> + ); +} diff --git a/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx b/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx new file mode 100644 index 0000000000..6c74f9fdbb --- /dev/null +++ b/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx @@ -0,0 +1,107 @@ +import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; +import { ExclamationIcon } from "@heroicons/react/solid"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +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"; + +dayjs.extend(relativeTime); + +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 ? props.apiKey.expiresAt < new Date() : null; + const neverExpires = props?.apiKey?.expiresAt === null; + const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete", { + async onSuccess() { + await utils.invalidateQueries(["viewer.apiKeys.list"]); + }, + }); + return ( + +
+
+
+ + {props?.apiKey?.note ? props.apiKey.note : t("api_key_no_note")} + + {!neverExpires && isExpired && ( + + {t("expired")} + + )} +
+
+ + {neverExpires ? ( +
+ + {t("api_key_never_expires")} +
+ ) : ( + `${isExpired ? t("expired") : t("expires")} ${dayjs( + props?.apiKey?.expiresAt?.toString() + ).fromNow()}` + )} +
+
+
+
+ +
+
+
+ ); +} 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 ab12bd5cdf..5254f363a3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -727,6 +727,33 @@ "redirect_url_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.", "duplicate": "Duplicate", "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.", + "api_keys": "API Keys", + "api_key_modal_subtitle": "API keys allow you to make API calls for your own account.", + "api_keys_subtitle": "Generate API keys to use for accessing your own account.", + "generate_new_api_key": "Generate new API key", + "create_api_key": "Create an API key", + "personal_note": "Name this key", + "personal_note_placeholder": "E.g. Development", + "api_key_no_note": "Nameless API key", + "api_key_never_expires":"This API key has no expiration date", + "edit_api_key": "Edit API key", + "never_expire_key": "Never expires", + "delete_api_key": "Revoke API key", + "success_api_key_created": "API key created successfully", + "success_api_key_edited": "API key updated successfully", + "create": "Create", + "success_api_key_created_bold_tagline": "Save this API key somewhere safe.", + "you_will_only_view_it_once": "You will not be able to view it again once you close this modal.", + "copy_to_clipboard": "Copy to clipboard", + "confirm_delete_api_key": "Revoke this API key", + "revoke_api_key": "Revoke API key", + "api_key_copied": "API key copied!", + "delete_api_key_confirm_title": "Permanently remove this API key from your account?", + "copy": "Copy", + "expire_date": "Expiration date", + "expired": "Expired", + "never_expires": "Never expires", + "expires": "Expires", "request_reschedule_booking": "Request to reschedule your booking", "reason_for_reschedule": "Reason for reschedule", "book_a_new_time": "Book a new time", diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index 36375d465f..30dd6f4031 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"; @@ -851,4 +852,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..0b95986ff6 --- /dev/null +++ b/apps/web/server/routers/viewer/apiKeys.tsx @@ -0,0 +1,102 @@ +import { v4 } from "uuid"; +import { z } from "zod"; + +import { generateUniqueAPIKey } from "@calcom/ee/lib/api/apiKeys"; + +import { createProtectedRouter } from "@server/createRouter"; + +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(), + expiresAt: z.date().optional().nullable(), + neverExpires: z.boolean().optional(), + }), + async resolve({ ctx, input }) { + const [hashedApiKey, apiKey] = generateUniqueAPIKey(); + // Here we snap never expires before deleting it so it's not passed to prisma create call. + const neverExpires = input.neverExpires; + delete input.neverExpires; + await ctx.prisma.apiKey.create({ + data: { + id: v4(), + userId: ctx.user.id, + ...input, + // And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input + expiresAt: neverExpires ? null : input.expiresAt, + hashedKey: hashedApiKey, + }, + }); + const prefixedApiKey = `${process.env.API_KEY_PREFIX ?? "cal_"}${apiKey}`; + return prefixedApiKey; + }, + }) + .mutation("edit", { + input: z.object({ + id: z.string(), + note: z.string().optional().nullish(), + expiresAt: z.date().optional(), + }), + async resolve({ ctx, input }) { + const { id, ...data } = input; + const { + apiKeys: [updatedApiKey], + } = await ctx.prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + apiKeys: { + update: { + where: { + id, + }, + data, + }, + }, + }, + select: { + apiKeys: { + where: { + id, + }, + }, + }, + }); + return updatedApiKey; + }, + }) + .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, + }; + }, + }); diff --git a/packages/ee/lib/api/apiKeys.ts b/packages/ee/lib/api/apiKeys.ts new file mode 100644 index 0000000000..ec0b4f109e --- /dev/null +++ b/packages/ee/lib/api/apiKeys.ts @@ -0,0 +1,10 @@ +import { randomBytes, createHash } from "crypto"; + +// Hash the API key to check against when veriying it. so we don't have to store the key in plain text. +export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); + +// Generate a random API key. Prisma already makes sure it's unique. So no need to add salts like with passwords. +export const generateUniqueAPIKey = (apiKey = randomBytes(16).toString("hex")) => [ + hashAPIKey(apiKey), + apiKey, +]; diff --git a/packages/prisma/json-schema/json-schema.json b/packages/prisma/json-schema/json-schema.json new file mode 100644 index 0000000000..1ae113fddf --- /dev/null +++ b/packages/prisma/json-schema/json-schema.json @@ -0,0 +1,1064 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "EventType": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string", + "description": "@zod.nonempty()" + }, + "slug": { + "type": "string", + "description": "@zod.custom(imports.eventTypeSlug)" + }, + "description": { + "type": ["string", "null"] + }, + "position": { + "type": "integer", + "default": 0 + }, + "locations": { + "type": ["number", "string", "boolean", "object", "array", "null"], + "description": "@zod.custom(imports.eventTypeLocations)" + }, + "length": { + "type": "integer" + }, + "hidden": { + "type": "boolean", + "default": false + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/User" + } + }, + "userId": { + "type": ["integer", "null"] + }, + "team": { + "anyOf": [ + { + "$ref": "#/definitions/Team" + }, + { + "type": "null" + } + ] + }, + "bookings": { + "type": "array", + "items": { + "$ref": "#/definitions/Booking" + } + }, + "availability": { + "type": "array", + "items": { + "$ref": "#/definitions/Availability" + } + }, + "webhooks": { + "type": "array", + "items": { + "$ref": "#/definitions/Webhook" + } + }, + "destinationCalendar": { + "anyOf": [ + { + "$ref": "#/definitions/DestinationCalendar" + }, + { + "type": "null" + } + ] + }, + "eventName": { + "type": ["string", "null"] + }, + "customInputs": { + "type": "array", + "items": { + "$ref": "#/definitions/EventTypeCustomInput" + } + }, + "timeZone": { + "type": ["string", "null"] + }, + "periodType": { + "type": "string", + "default": "UNLIMITED", + "enum": ["UNLIMITED", "ROLLING", "RANGE"] + }, + "periodStartDate": { + "type": ["string", "null"], + "format": "date-time" + }, + "periodEndDate": { + "type": ["string", "null"], + "format": "date-time" + }, + "periodDays": { + "type": ["integer", "null"] + }, + "periodCountCalendarDays": { + "type": ["boolean", "null"] + }, + "requiresConfirmation": { + "type": "boolean", + "default": false + }, + "disableGuests": { + "type": "boolean", + "default": false + }, + "minimumBookingNotice": { + "type": "integer", + "default": 120 + }, + "beforeEventBuffer": { + "type": "integer", + "default": 0 + }, + "afterEventBuffer": { + "type": "integer", + "default": 0 + }, + "schedulingType": { + "type": ["string", "null"], + "enum": ["ROUND_ROBIN", "COLLECTIVE"] + }, + "schedule": { + "anyOf": [ + { + "$ref": "#/definitions/Schedule" + }, + { + "type": "null" + } + ] + }, + "price": { + "type": "integer", + "default": 0 + }, + "currency": { + "type": "string", + "default": "usd" + }, + "slotInterval": { + "type": ["integer", "null"] + }, + "metadata": { + "type": ["number", "string", "boolean", "object", "array", "null"] + } + } + }, + "Credential": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "key": { + "type": ["number", "string", "boolean", "object", "array", "null"] + }, + "user": { + "anyOf": [ + { + "$ref": "#/definitions/User" + }, + { + "type": "null" + } + ] + } + } + }, + "DestinationCalendar": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "integration": { + "type": "string" + }, + "externalId": { + "type": "string" + }, + "user": { + "anyOf": [ + { + "$ref": "#/definitions/User" + }, + { + "type": "null" + } + ] + }, + "booking": { + "anyOf": [ + { + "$ref": "#/definitions/Booking" + }, + { + "type": "null" + } + ] + }, + "eventType": { + "anyOf": [ + { + "$ref": "#/definitions/EventType" + }, + { + "type": "null" + } + ] + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": ["string", "null"] + }, + "name": { + "type": ["string", "null"] + }, + "email": { + "type": "string", + "description": "@zod.email()" + }, + "emailVerified": { + "type": ["string", "null"], + "format": "date-time" + }, + "password": { + "type": ["string", "null"] + }, + "bio": { + "type": ["string", "null"] + }, + "avatar": { + "type": ["string", "null"] + }, + "timeZone": { + "type": "string", + "default": "Europe/London" + }, + "weekStart": { + "type": "string", + "default": "Sunday" + }, + "startTime": { + "type": "integer", + "default": 0 + }, + "endTime": { + "type": "integer", + "default": 1440 + }, + "bufferTime": { + "type": "integer", + "default": 0 + }, + "hideBranding": { + "type": "boolean", + "default": false + }, + "theme": { + "type": ["string", "null"] + }, + "createdDate": { + "type": "string", + "format": "date-time" + }, + "trialEndsAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "eventTypes": { + "type": "array", + "items": { + "$ref": "#/definitions/EventType" + } + }, + "credentials": { + "type": "array", + "items": { + "$ref": "#/definitions/Credential" + } + }, + "teams": { + "type": "array", + "items": { + "$ref": "#/definitions/Membership" + } + }, + "bookings": { + "type": "array", + "items": { + "$ref": "#/definitions/Booking" + } + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/definitions/Schedule" + } + }, + "defaultScheduleId": { + "type": ["integer", "null"] + }, + "selectedCalendars": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedCalendar" + } + }, + "completedOnboarding": { + "type": "boolean", + "default": false + }, + "locale": { + "type": ["string", "null"] + }, + "timeFormat": { + "type": ["integer", "null"], + "default": 12 + }, + "twoFactorSecret": { + "type": ["string", "null"] + }, + "twoFactorEnabled": { + "type": "boolean", + "default": false + }, + "identityProvider": { + "type": "string", + "default": "CAL", + "enum": ["CAL", "GOOGLE", "SAML"] + }, + "identityProviderId": { + "type": ["string", "null"] + }, + "availability": { + "type": "array", + "items": { + "$ref": "#/definitions/Availability" + } + }, + "invitedTo": { + "type": ["integer", "null"] + }, + "plan": { + "type": "string", + "default": "TRIAL", + "enum": ["FREE", "TRIAL", "PRO"] + }, + "webhooks": { + "type": "array", + "items": { + "$ref": "#/definitions/Webhook" + } + }, + "brandColor": { + "type": "string", + "default": "#292929" + }, + "darkBrandColor": { + "type": "string", + "default": "#fafafa" + }, + "destinationCalendar": { + "anyOf": [ + { + "$ref": "#/definitions/DestinationCalendar" + }, + { + "type": "null" + } + ] + }, + "away": { + "type": "boolean", + "default": false + }, + "metadata": { + "type": ["number", "string", "boolean", "object", "array", "null"] + }, + "verified": { + "type": ["boolean", "null"], + "default": false + }, + "apiKeys": { + "type": "array", + "items": { + "$ref": "#/definitions/ApiKey" + } + } + } + }, + "Team": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": ["string", "null"] + }, + "slug": { + "type": ["string", "null"] + }, + "logo": { + "type": ["string", "null"] + }, + "bio": { + "type": ["string", "null"] + }, + "hideBranding": { + "type": "boolean", + "default": false + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/Membership" + } + }, + "eventTypes": { + "type": "array", + "items": { + "$ref": "#/definitions/EventType" + } + } + } + }, + "Membership": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean", + "default": false + }, + "role": { + "type": "string", + "enum": ["MEMBER", "ADMIN", "OWNER"] + }, + "team": { + "$ref": "#/definitions/Team" + }, + "user": { + "$ref": "#/definitions/User" + } + } + }, + "VerificationRequest": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "identifier": { + "type": "string" + }, + "token": { + "type": "string" + }, + "expires": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "BookingReference": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "meetingId": { + "type": ["string", "null"] + }, + "meetingPassword": { + "type": ["string", "null"] + }, + "meetingUrl": { + "type": ["string", "null"] + }, + "booking": { + "anyOf": [ + { + "$ref": "#/definitions/Booking" + }, + { + "type": "null" + } + ] + } + } + }, + "Attendee": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "locale": { + "type": ["string", "null"], + "default": "en" + }, + "booking": { + "anyOf": [ + { + "$ref": "#/definitions/Booking" + }, + { + "type": "null" + } + ] + } + } + }, + "DailyEventReference": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "dailyurl": { + "type": "string", + "default": "dailycallurl" + }, + "dailytoken": { + "type": "string", + "default": "dailytoken" + }, + "booking": { + "anyOf": [ + { + "$ref": "#/definitions/Booking" + }, + { + "type": "null" + } + ] + } + } + }, + "Booking": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "uid": { + "type": "string" + }, + "user": { + "anyOf": [ + { + "$ref": "#/definitions/User" + }, + { + "type": "null" + } + ] + }, + "references": { + "type": "array", + "items": { + "$ref": "#/definitions/BookingReference" + } + }, + "eventType": { + "anyOf": [ + { + "$ref": "#/definitions/EventType" + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "endTime": { + "type": "string", + "format": "date-time" + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/definitions/Attendee" + } + }, + "location": { + "type": ["string", "null"] + }, + "dailyRef": { + "anyOf": [ + { + "$ref": "#/definitions/DailyEventReference" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "confirmed": { + "type": "boolean", + "default": true + }, + "rejected": { + "type": "boolean", + "default": false + }, + "status": { + "type": "string", + "default": "ACCEPTED", + "enum": ["CANCELLED", "ACCEPTED", "REJECTED", "PENDING"] + }, + "paid": { + "type": "boolean", + "default": false + }, + "payment": { + "type": "array", + "items": { + "$ref": "#/definitions/Payment" + } + }, + "destinationCalendar": { + "anyOf": [ + { + "$ref": "#/definitions/DestinationCalendar" + }, + { + "type": "null" + } + ] + }, + "cancellationReason": { + "type": ["string", "null"] + }, + "rejectionReason": { + "type": ["string", "null"] + } + } + }, + "Schedule": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "$ref": "#/definitions/User" + }, + "eventType": { + "anyOf": [ + { + "$ref": "#/definitions/EventType" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "timeZone": { + "type": ["string", "null"] + }, + "availability": { + "type": "array", + "items": { + "$ref": "#/definitions/Availability" + } + } + } + }, + "Availability": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "anyOf": [ + { + "$ref": "#/definitions/User" + }, + { + "type": "null" + } + ] + }, + "eventType": { + "anyOf": [ + { + "$ref": "#/definitions/EventType" + }, + { + "type": "null" + } + ] + }, + "days": { + "type": "array", + "items": { + "type": "integer" + } + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "endTime": { + "type": "string", + "format": "date-time" + }, + "date": { + "type": ["string", "null"], + "format": "date-time" + }, + "Schedule": { + "anyOf": [ + { + "$ref": "#/definitions/Schedule" + }, + { + "type": "null" + } + ] + } + } + }, + "SelectedCalendar": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/User" + }, + "integration": { + "type": "string" + }, + "externalId": { + "type": "string" + } + } + }, + "EventTypeCustomInput": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "eventType": { + "$ref": "#/definitions/EventType" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["TEXT", "TEXTLONG", "NUMBER", "BOOL"] + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "default": "" + } + } + }, + "ResetPasswordRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, + "expires": { + "type": "string", + "format": "date-time" + } + } + }, + "ReminderMail": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "referenceId": { + "type": "integer" + }, + "reminderType": { + "type": "string", + "enum": ["PENDING_BOOKING_CONFIRMATION"] + }, + "elapsedMinutes": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "Payment": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "uid": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["STRIPE"] + }, + "booking": { + "anyOf": [ + { + "$ref": "#/definitions/Booking" + }, + { + "type": "null" + } + ] + }, + "amount": { + "type": "integer" + }, + "fee": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "refunded": { + "type": "boolean" + }, + "data": { + "type": ["number", "string", "boolean", "object", "array", "null"] + }, + "externalId": { + "type": "string" + } + } + }, + "Webhook": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "subscriberUrl": { + "type": "string" + }, + "payloadTemplate": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "active": { + "type": "boolean", + "default": true + }, + "eventTriggers": { + "type": "array", + "enum": ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"] + }, + "user": { + "anyOf": [ + { + "$ref": "#/definitions/User" + }, + { + "type": "null" + } + ] + }, + "eventType": { + "anyOf": [ + { + "$ref": "#/definitions/EventType" + }, + { + "type": "null" + } + ] + } + } + }, + "ApiKey": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "user": { + "anyOf": [ + { + "$ref": "#/definitions/User" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "note": { + "type": ["string", "null"] + } + } + } + }, + "type": "object", + "properties": { + "eventType": { + "$ref": "#/definitions/EventType" + }, + "credential": { + "$ref": "#/definitions/Credential" + }, + "destinationCalendar": { + "$ref": "#/definitions/DestinationCalendar" + }, + "user": { + "$ref": "#/definitions/User" + }, + "team": { + "$ref": "#/definitions/Team" + }, + "membership": { + "$ref": "#/definitions/Membership" + }, + "verificationRequest": { + "$ref": "#/definitions/VerificationRequest" + }, + "bookingReference": { + "$ref": "#/definitions/BookingReference" + }, + "attendee": { + "$ref": "#/definitions/Attendee" + }, + "dailyEventReference": { + "$ref": "#/definitions/DailyEventReference" + }, + "booking": { + "$ref": "#/definitions/Booking" + }, + "schedule": { + "$ref": "#/definitions/Schedule" + }, + "availability": { + "$ref": "#/definitions/Availability" + }, + "selectedCalendar": { + "$ref": "#/definitions/SelectedCalendar" + }, + "eventTypeCustomInput": { + "$ref": "#/definitions/EventTypeCustomInput" + }, + "resetPasswordRequest": { + "$ref": "#/definitions/ResetPasswordRequest" + }, + "reminderMail": { + "$ref": "#/definitions/ReminderMail" + }, + "payment": { + "$ref": "#/definitions/Payment" + }, + "webhook": { + "$ref": "#/definitions/Webhook" + }, + "apiKey": { + "$ref": "#/definitions/ApiKey" + } + } +} diff --git a/packages/prisma/migrations/20220413002425_adds_api_keys/migration.sql b/packages/prisma/migrations/20220413002425_adds_api_keys/migration.sql new file mode 100644 index 0000000000..4d5f1086bf --- /dev/null +++ b/packages/prisma/migrations/20220413002425_adds_api_keys/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3), + "lastUsedAt" TIMESTAMP(3), + "hashedKey" TEXT NOT NULL, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_id_key" ON "ApiKey"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9e206f2d74..602dcccbd7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -154,6 +154,7 @@ model User { allowDynamicBooking Boolean? @default(true) metadata Json? verified Boolean? @default(false) + apiKeys ApiKey[] @@map(name: "users") } @@ -373,3 +374,14 @@ model Webhook { user User? @relation(fields: [userId], references: [id], onDelete: Cascade) eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) } + +model ApiKey { + id String @id @unique @default(cuid()) + userId Int + note String? + createdAt DateTime @default(now()) + expiresAt DateTime? + lastUsedAt DateTime? + hashedKey String @unique() + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) +}