feat: api keys frontend in security page

pull/2277/head
Agusti Fernandez Pardo 2022-04-07 03:29:07 +02:00
parent d66fd6a6c8
commit c2cd32995d
10 changed files with 461 additions and 32 deletions

View File

@ -56,11 +56,11 @@ const ChangePasswordSection = () => {
return ( 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> <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}> <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="flex">
<div className="w-1/2 ltr:mr-2 rtl:ml-2"> <div className="w-1/2 ltr:mr-2 rtl:ml-2">
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700"> <label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
@ -99,9 +99,11 @@ const ChangePasswordSection = () => {
</div> </div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>} {errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
<div className="flex justify-end py-8"> <div className="flex justify-end py-8">
<Button type="submit">{t("save")}</Button> <Button color="secondary" type="submit">
{t("save")}
</Button>
</div> </div>
<hr className="mt-4" /> {/* <hr className="mt-4" /> */}
</div> </div>
</form> </form>
</> </>

View File

@ -17,6 +17,8 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
return ( return (
<> <>
<div className="flex flex-row justify-between truncate pt-9 pl-2">
<div className="">
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2> <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"}> <Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
@ -24,14 +26,16 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
</Badge> </Badge>
</div> </div>
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p> <p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
</div>
<div className="self-center">
<Button <Button
className="mt-6"
type="submit" type="submit"
color="secondary"
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}> onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
{enabled ? t("disable") : t("enable")} {t("2fa")} {enabled ? t("disable") : t("enable")}
</Button> </Button>
</div>
</div>
{enableModalOpen && ( {enableModalOpen && (
<EnableTwoFactorModal <EnableTwoFactorModal
onEnable={() => { onEnable={() => {

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import { IdentityProvider } from "@prisma/client"; import { IdentityProvider } from "@prisma/client";
import React from "react"; 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 SAMLConfiguration from "@ee/components/saml/Configuration";
import { identityProviderNameMap } from "@lib/auth"; import { identityProviderNameMap } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
import SettingsShell from "@components/SettingsShell"; import SettingsShell from "@components/SettingsShell";
@ -34,10 +35,11 @@ export default function Security() {
</p> </p>
</> </>
) : ( ) : (
<> <div className="space-y-2 divide-y">
<ChangePasswordSection /> <ChangePasswordSection />
<ApiKeyListContainer />
<TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled || false} /> <TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled || false} />
</> </div>
)} )}
<SAMLConfiguration teamsView={false} teamId={null} /> <SAMLConfiguration teamsView={false} teamId={null} />

View File

@ -708,5 +708,10 @@
"12_hour": "12 hour", "12_hour": "12 hour",
"24_hour": "24 hour", "24_hour": "24 hour",
"duplicate": "Duplicate", "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"
} }

View File

@ -20,6 +20,7 @@ import {
} from "@lib/saml"; } from "@lib/saml";
import slugify from "@lib/slugify"; import slugify from "@lib/slugify";
import { apiKeysRouter } from "@server/routers/viewer/apiKeys";
import { availabilityRouter } from "@server/routers/viewer/availability"; import { availabilityRouter } from "@server/routers/viewer/availability";
import { eventTypesRouter } from "@server/routers/viewer/eventTypes"; import { eventTypesRouter } from "@server/routers/viewer/eventTypes";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@ -800,4 +801,5 @@ export const viewerRouter = createRouter()
.merge("eventTypes.", eventTypesRouter) .merge("eventTypes.", eventTypesRouter)
.merge("availability.", availabilityRouter) .merge("availability.", availabilityRouter)
.merge("teams.", viewerTeamsRouter) .merge("teams.", viewerTeamsRouter)
.merge("webhook.", webhookRouter); .merge("webhook.", webhookRouter)
.merge("apiKeys.", apiKeysRouter);

View File

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

View File

@ -370,6 +370,7 @@ model ApiKey {
userId Int userId Int
note String? note String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
neverExpires Boolean @default(false)
expiresAt DateTime @default(dbgenerated("NOW() + interval '30 days'")) expiresAt DateTime @default(dbgenerated("NOW() + interval '30 days'"))
lastUsedAt DateTime @default(now()) lastUsedAt DateTime @default(now())
hashedKey String @unique() hashedKey String @unique()