feat: api keys frontend in security page
parent
d66fd6a6c8
commit
c2cd32995d
|
@ -56,11 +56,11 @@ const ChangePasswordSection = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
{/* <div className="mt-6">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
</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="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,11 @@ 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" />
|
||||
{/* <hr className="mt-4" /> */}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
|
|
@ -17,6 +17,8 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row justify-between truncate pt-9 pl-2">
|
||||
<div className="">
|
||||
<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"}>
|
||||
|
@ -24,14 +26,16 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
|||
</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
|
||||
className="mt-6"
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")} {t("2fa")}
|
||||
{enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{enableModalOpen && (
|
||||
<EnableTwoFactorModal
|
||||
onEnable={() => {
|
||||
|
|
|
@ -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<TApiKeys, "userId" | "createdAt" | "lastUsedAt">,
|
||||
} = 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 (
|
||||
<Form
|
||||
data-testid="ApiKeyDialogForm"
|
||||
form={form}
|
||||
handleSubmit={async (event) => {
|
||||
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">
|
||||
<input type="hidden" {...form.register("id")} />
|
||||
|
||||
<TextField
|
||||
label={t("personal_note")}
|
||||
{...form.register("note")}
|
||||
required
|
||||
type="text"
|
||||
onChange={handleNoteChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-md text-gray-600">Expire date</span>
|
||||
<Switch label={"Never expire"} onCheckedChange={onNeverExpired} checked={neverExpired} />
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="neverExpires"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
label={field.value ? t("never_expire_key_enabled") : t("never_expire_key_disabled")}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
form.setValue("neverExpires", isChecked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DatePicker
|
||||
// disabled={neverExpired}
|
||||
minDate={new Date()}
|
||||
// {...form.register("expiresAt")}
|
||||
date={selectedDate as Date}
|
||||
onDatesChange={handleDateChange}
|
||||
/>
|
||||
</div>
|
||||
<div></div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -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<TApiKeys | 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)}
|
||||
data-testid="new_token">
|
||||
{t("generate_new_token")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.length ? (
|
||||
<List className="pb-6">
|
||||
{data.map((item) => (
|
||||
<ApiKeyListItem
|
||||
key={item.id}
|
||||
apiKey={item}
|
||||
onEditApiKey={() => {
|
||||
setEditing(item);
|
||||
setEditModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
) : null}
|
||||
|
||||
{/* New webhook dialog */}
|
||||
<Dialog open={newApiKeyModal} onOpenChange={(isOpen) => !isOpen && setNewApiKeyModal(false)}>
|
||||
<DialogContent>
|
||||
<ApiKeyDialogForm handleClose={() => setNewApiKeyModal(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit webhook dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<ApiKeyDialogForm
|
||||
key={editing.id}
|
||||
handleClose={() => setEditModalOpen(false)}
|
||||
defaultValues={editing}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<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={classNames("truncate text-sm", isExpired ? "text-gray-500" : "text-gray-900")}>
|
||||
{props?.apiKey?.note}
|
||||
</span>
|
||||
{isExpired ? <Badge variant="default">Expired</Badge> : null}
|
||||
</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-500" : "text-gray-900"
|
||||
)}>
|
||||
{props?.apiKey?.expiresAt.toLocaleDateString()}
|
||||
</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"></Button>
|
||||
</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"></Button>
|
||||
</DialogTrigger>
|
||||
</Tooltip>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_api-key")}
|
||||
confirmBtnText={t("confirm_delete_api_key")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() =>
|
||||
deleteApiKey.mutate({
|
||||
id: props.apiKey.id,
|
||||
})
|
||||
}>
|
||||
{t("delete_api_key_confirmation_message")}
|
||||
</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} />
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
// };
|
||||
// }
|
||||
// },
|
||||
// });
|
|
@ -370,6 +370,7 @@ model ApiKey {
|
|||
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()
|
||||
|
|
Loading…
Reference in New Issue