Feature: Adds api keys to cal.com webapp (#2277)
* 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 <me@alexvanandel.com> * fix: item: any to pass build Co-authored-by: Agusti Fernandez Pardo <git@agusti.me> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
parent
ffebe8e901
commit
faa67e0bb6
15 changed files with 1611 additions and 25 deletions
|
@ -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 *****************************************************************************************
|
||||
|
|
|
@ -56,11 +56,11 @@ const ChangePasswordSection = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="py-6 lg:pb-5">
|
||||
<div className="my-3">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-1/2 ltr:mr-2 rtl:ml-2">
|
||||
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
|
||||
|
@ -99,9 +99,10 @@ const ChangePasswordSection = () => {
|
|||
</div>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
<div className="flex justify-end py-8">
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
<Button color="secondary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
|
|
@ -17,21 +17,25 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
<div className="flex flex-row justify-between truncate pt-9 pl-2">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
|
||||
<Button
|
||||
className="mt-6"
|
||||
type="submit"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")} {t("2fa")}
|
||||
</Button>
|
||||
|
||||
{enableModalOpen && (
|
||||
<EnableTwoFactorModal
|
||||
onEnable={() => {
|
||||
|
|
|
@ -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 (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
|
@ -22,6 +24,8 @@ export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
|||
clearIcon={null}
|
||||
calendarIcon={<CalendarIcon className="h-5 w-5 text-gray-500" />}
|
||||
value={date}
|
||||
minDate={minDate}
|
||||
disabled={disabled}
|
||||
onChange={onDatesChange}
|
||||
/>
|
||||
);
|
||||
|
|
150
apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
Normal file
150
apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
Normal file
|
@ -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<TApiKeys, "userId" | "createdAt" | "lastUsedAt"> & { 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 ? (
|
||||
<>
|
||||
<div className="mb-10">
|
||||
<h2 className="font-semi-bold font-cal mb-2 text-xl tracking-wide text-gray-900">
|
||||
{apiKeyDetails ? t("success_api_key_edited") : t("success_api_key_created")}
|
||||
</h2>
|
||||
<div className="text-sm text-gray-900">
|
||||
<span className="font-semibold">{t("success_api_key_created_bold_tagline")}</span>{" "}
|
||||
{t("you_will_only_view_it_once")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex">
|
||||
<code className="my-2 mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 align-middle font-mono text-gray-800">
|
||||
{apiKey}
|
||||
</code>
|
||||
<Tooltip content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
showToast(t("api_key_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className=" my-2 px-4 text-base">
|
||||
<ClipboardCopyIcon className="mr-2 h-5 w-5 text-neutral-100" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{apiKeyDetails.neverExpires
|
||||
? t("never_expire_key")
|
||||
: `${t("expires")} ${apiKeyDetails?.expiresAt?.toLocaleDateString()}`}
|
||||
</span>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<Form<Omit<TApiKeys, "userId" | "createdAt" | "lastUsedAt"> & { 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">
|
||||
<div className=" mb-10 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">{props.title}</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("api_key_modal_subtitle")}</p>
|
||||
</div>
|
||||
<TextField
|
||||
label={t("personal_note")}
|
||||
placeholder={t("personal_note_placeholder")}
|
||||
{...form.register("note")}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="block text-sm font-medium text-gray-700">{t("expire_date")}</span>
|
||||
<Controller
|
||||
name="neverExpires"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch label={t("never_expire_key")} onCheckedChange={onChange} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="expiresAt"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DatePicker
|
||||
disabled={watchNeverExpires}
|
||||
minDate={new Date()}
|
||||
date={value}
|
||||
onDatesChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{apiKeyDetails ? t("save") : t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
77
apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
Normal file
77
apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
Normal file
|
@ -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 (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
<div className="flex flex-row justify-between truncate pl-2 pr-1 ">
|
||||
<div className="mt-9">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("api_keys")}</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("api_keys_subtitle")}</p>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<Button StartIcon={PlusIcon} color="secondary" onClick={() => setNewApiKeyModal(true)}>
|
||||
{t("generate_new_api_key")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.length && (
|
||||
<List className="pb-6">
|
||||
{data.map((item: any) => (
|
||||
<ApiKeyListItem
|
||||
key={item.id}
|
||||
apiKey={item}
|
||||
onEditApiKey={() => {
|
||||
setApiKeyToEdit(item);
|
||||
setEditModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* New api key dialog */}
|
||||
<Dialog open={newApiKeyModal} onOpenChange={(isOpen) => !isOpen && setNewApiKeyModal(false)}>
|
||||
<DialogContent>
|
||||
<ApiKeyDialogForm title={t("create_api_key")} handleClose={() => setNewApiKeyModal(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit api key dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent>
|
||||
{apiKeyToEdit && (
|
||||
<ApiKeyDialogForm
|
||||
title={t("edit_api_key")}
|
||||
key={apiKeyToEdit.id}
|
||||
handleClose={() => setEditModalOpen(false)}
|
||||
defaultValues={apiKeyToEdit}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
107
apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
Normal file
107
apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
Normal file
|
@ -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 (
|
||||
<ListItem className="-mt-px flex w-full p-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex max-w-full flex-col truncate">
|
||||
<div className="flex space-x-2">
|
||||
<span className="text-gray-900">
|
||||
{props?.apiKey?.note ? props.apiKey.note : t("api_key_no_note")}
|
||||
</span>
|
||||
{!neverExpires && isExpired && (
|
||||
<Badge className="-p-2" variant="default">
|
||||
{t("expired")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex">
|
||||
<span
|
||||
className={classNames(
|
||||
"flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse",
|
||||
isExpired ? "text-red-600" : "text-gray-500",
|
||||
neverExpires ? "text-yellow-600" : ""
|
||||
)}>
|
||||
{neverExpires ? (
|
||||
<div className="flex flex-row space-x-3 text-gray-500">
|
||||
<ExclamationIcon className="w-4" />
|
||||
{t("api_key_never_expires")}
|
||||
</div>
|
||||
) : (
|
||||
`${isExpired ? t("expired") : t("expires")} ${dayjs(
|
||||
props?.apiKey?.expiresAt?.toString()
|
||||
).fromNow()}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Tooltip content={t("edit_api_key")}>
|
||||
<Button
|
||||
onClick={() => props.onEditApiKey()}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={PencilAltIcon}
|
||||
className="ml-4 w-full self-center p-2"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dialog>
|
||||
<Tooltip content={t("delete_api_key")}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={TrashIcon}
|
||||
className="ml-2 w-full self-center p-2"
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</Tooltip>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("confirm_delete_api_key")}
|
||||
confirmBtnText={t("revoke_api_key")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() =>
|
||||
deleteApiKey.mutate({
|
||||
id: props.apiKey.id,
|
||||
})
|
||||
}>
|
||||
{t("delete_api_key_confirm_title")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
|
@ -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() {
|
|||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2 divide-y">
|
||||
<ChangePasswordSection />
|
||||
<ApiKeyListContainer />
|
||||
<TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled || false} />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SAMLConfiguration teamsView={false} teamId={null} />
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
102
apps/web/server/routers/viewer/apiKeys.tsx
Normal file
102
apps/web/server/routers/viewer/apiKeys.tsx
Normal file
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
10
packages/ee/lib/api/apiKeys.ts
Normal file
10
packages/ee/lib/api/apiKeys.ts
Normal file
|
@ -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,
|
||||
];
|
1064
packages/prisma/json-schema/json-schema.json
Normal file
1064
packages/prisma/json-schema/json-schema.json
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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;
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue