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>
pull/2525/head
Agusti Fernandez 2022-04-16 04:58:34 +02:00 committed by GitHub
parent ffebe8e901
commit faa67e0bb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1611 additions and 25 deletions

View File

@ -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 *****************************************************************************************

View File

@ -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>
</>

View File

@ -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={() => {

View File

@ -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}
/>
);

View 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>
)}
</>
);
}

View 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>
</>
)}
/>
);
}

View 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>
);
}

View File

@ -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} />

View File

@ -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",

View File

@ -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);

View 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,
};
},
});

View 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,
];

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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)
}