`/integrations` facelift (#858)
parent
7dc4a55319
commit
c3dc18643e
|
@ -11,6 +11,8 @@ NEXT_PUBLIC_LICENSE_CONSENT=''
|
|||
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
|
||||
|
||||
GOOGLE_API_CREDENTIALS='secret'
|
||||
GOOGLE_REDIRECT_URL='https://localhost:3000/integrations/googlecalendar/callback'
|
||||
|
||||
BASE_URL='http://localhost:3000'
|
||||
NEXT_PUBLIC_APP_URL='http://localhost:3000'
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
|
||||
export type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
|
||||
export function Dialog(props: DialogProps) {
|
||||
const { children, ...other } = props;
|
||||
return (
|
||||
|
@ -35,9 +35,15 @@ export function DialogHeader({ title, subtitle }: DialogHeaderProps) {
|
|||
<h3 className="font-cal text-gray-900 text-lg font-bold leading-6" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">{subtitle}</p>
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogFooter(props: { children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-5 flex space-x-2 justify-end">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import Link from "next/link";
|
||||
import { createElement } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export function List(props: JSX.IntrinsicElements["ul"]) {
|
||||
return (
|
||||
<ul {...props} className={classNames("overflow-hidden rounded-sm sm:mx-0", props.className)}>
|
||||
{props.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export type ListItemProps = { expanded?: boolean } & ({ href?: never } & JSX.IntrinsicElements["li"]);
|
||||
|
||||
export function ListItem(props: ListItemProps) {
|
||||
const { href, expanded, ...passThroughProps } = props;
|
||||
|
||||
const elementType = href ? "a" : "li";
|
||||
|
||||
const element = createElement(
|
||||
elementType,
|
||||
{
|
||||
...passThroughProps,
|
||||
className: classNames(
|
||||
"items-center bg-white min-w-0 flex-1 flex border-gray-200",
|
||||
expanded ? "my-2 border" : "border -mb-px last:mb-0",
|
||||
props.className,
|
||||
(props.onClick || href) && "hover:bg-neutral-50"
|
||||
),
|
||||
},
|
||||
props.children
|
||||
);
|
||||
|
||||
return href ? (
|
||||
<Link passHref href={href}>
|
||||
{element}
|
||||
</Link>
|
||||
) : (
|
||||
element
|
||||
);
|
||||
}
|
||||
|
||||
export function ListItemTitle<TComponent extends keyof JSX.IntrinsicElements = "span">(
|
||||
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
|
||||
) {
|
||||
const { component = "span", ...passThroughProps } = props;
|
||||
|
||||
return createElement(
|
||||
component,
|
||||
{
|
||||
...passThroughProps,
|
||||
className: classNames("text-sm font-medium text-neutral-900 truncate", props.className),
|
||||
},
|
||||
props.children
|
||||
);
|
||||
}
|
||||
|
||||
export function ListItemText<TComponent extends keyof JSX.IntrinsicElements = "span">(
|
||||
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
|
||||
) {
|
||||
const { component = "span", ...passThroughProps } = props;
|
||||
|
||||
return createElement(
|
||||
component,
|
||||
{
|
||||
...passThroughProps,
|
||||
className: classNames("text-sm text-gray-500", props.className),
|
||||
},
|
||||
props.children
|
||||
);
|
||||
}
|
|
@ -19,6 +19,7 @@ import LicenseBanner from "@ee/components/LicenseBanner";
|
|||
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
|
@ -29,10 +30,7 @@ import Logo from "./Logo";
|
|||
|
||||
function useMeQuery() {
|
||||
const [session] = useSession();
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
// refetch max once per 5s
|
||||
staleTime: 5000,
|
||||
});
|
||||
const meQuery = trpc.useQuery(["viewer.me"]);
|
||||
|
||||
useEffect(() => {
|
||||
// refetch if sesion changes
|
||||
|
@ -59,6 +57,26 @@ function useRedirectToLoginIfUnauthenticated() {
|
|||
}, [loading, session, router]);
|
||||
}
|
||||
|
||||
export function ShellSubHeading(props: {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("block sm:flex justify-between mb-3", props.className)}>
|
||||
<div>
|
||||
{/* TODO should be Roboto */}
|
||||
<h2 className="text-lg font-bold text-gray-900 flex items-center content-center space-x-2">
|
||||
{props.title}
|
||||
</h2>
|
||||
{props.subtitle && <p className="text-sm text-neutral-500 mr-4">{props.subtitle}</p>}
|
||||
</div>
|
||||
{props.actions && <div className="mb-4 flex-shrink-0">{props.actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Shell(props: {
|
||||
centered?: boolean;
|
||||
title?: string;
|
||||
|
@ -74,6 +92,15 @@ export default function Shell(props: {
|
|||
const telemetry = useTelemetry();
|
||||
const query = useMeQuery();
|
||||
|
||||
useEffect(
|
||||
function redirectToOnboardingIfNeeded() {
|
||||
if (query.data && shouldShowOnboarding(query.data)) {
|
||||
router.push("/getting-started");
|
||||
}
|
||||
},
|
||||
[query.data, router]
|
||||
);
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: "Event Types",
|
||||
|
@ -209,7 +236,6 @@ export default function Shell(props: {
|
|||
<div className="mb-4 flex-shrink-0">{props.CTA}</div>
|
||||
</div>
|
||||
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
|
||||
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="bottom-nav md:hidden flex fixed bottom-0 bg-white w-full shadow">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
|
@ -239,7 +265,6 @@ export default function Shell(props: {
|
|||
)
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* add padding to content for mobile navigation*/}
|
||||
<div className="block md:hidden pt-12" />
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { useId } from "@radix-ui/react-id";
|
||||
import { forwardRef, ReactNode } from "react";
|
||||
import { FormProvider, UseFormReturn } from "react-hook-form";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, JSX.IntrinsicElements["input"]>(function Input(props, ref) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export function Label(props: JSX.IntrinsicElements["label"]) {
|
||||
return (
|
||||
<label {...props} className={classNames("block text-sm font-medium text-gray-700", props.className)}>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const TextField = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
label: ReactNode;
|
||||
} & React.ComponentProps<typeof Input> & {
|
||||
labelProps?: React.ComponentProps<typeof Label>;
|
||||
}
|
||||
>(function TextField(props, ref) {
|
||||
const id = useId();
|
||||
const { label, ...passThroughToInput } = props;
|
||||
|
||||
// TODO: use `useForm()` from RHF and get error state here too!
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id} {...props.labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
<Input id={id} {...passThroughToInput} ref={ref} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Form = forwardRef<HTMLFormElement, { form: UseFormReturn<any> } & JSX.IntrinsicElements["form"]>(
|
||||
function Form(props, ref) {
|
||||
const { form, ...passThrough } = props;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form ref={ref} {...passThrough}>
|
||||
{props.children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -5,6 +5,7 @@ import { ReactNode } from "react";
|
|||
export interface AlertProps {
|
||||
title?: ReactNode;
|
||||
message?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
severity: "success" | "warning" | "error";
|
||||
}
|
||||
|
@ -36,6 +37,7 @@ export function Alert(props: AlertProps) {
|
|||
<h3 className="text-sm font-medium">{props.title}</h3>
|
||||
<div className="text-sm">{props.message}</div>
|
||||
</div>
|
||||
{props.actions && <div className="text-sm">{props.actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -13,7 +13,7 @@ export const Badge = function Badge(props: BadgeProps) {
|
|||
<span
|
||||
{...passThroughProps}
|
||||
className={classNames(
|
||||
"font-bold px-2 py-0.5 inline-block rounded-sm",
|
||||
"font-bold px-2 py-0.5 inline-block rounded-sm text-xs",
|
||||
variant === "default" && "bg-yellow-100 text-yellow-800",
|
||||
variant === "success" && "bg-green-100 text-green-800",
|
||||
variant === "gray" && "bg-gray-200 text-gray-800",
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, { forwardRef } from "react";
|
|||
import classNames from "@lib/classNames";
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
export type ButtonProps = {
|
||||
export type ButtonBaseProps = {
|
||||
color?: "primary" | "secondary" | "minimal" | "warn";
|
||||
size?: "base" | "sm" | "lg" | "fab" | "icon";
|
||||
loading?: boolean;
|
||||
|
@ -13,10 +13,12 @@ export type ButtonProps = {
|
|||
StartIcon?: SVGComponent;
|
||||
EndIcon?: SVGComponent;
|
||||
shallow?: boolean;
|
||||
} & (
|
||||
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
|
||||
| (JSX.IntrinsicElements["button"] & { href?: never })
|
||||
);
|
||||
};
|
||||
export type ButtonProps = ButtonBaseProps &
|
||||
(
|
||||
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
|
||||
| (JSX.IntrinsicElements["button"] & { href?: never })
|
||||
);
|
||||
|
||||
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
|
||||
props: ButtonProps,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useId } from "@radix-ui/react-id";
|
||||
import * as Label from "@radix-ui/react-label";
|
||||
import * as PrimitiveSwitch from "@radix-ui/react-switch";
|
||||
import { useState } from "react";
|
||||
|
@ -16,7 +17,7 @@ export default function Switch(props) {
|
|||
}
|
||||
setChecked(change);
|
||||
};
|
||||
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex items-center h-[20px]">
|
||||
<PrimitiveSwitch.Root
|
||||
|
@ -25,6 +26,7 @@ export default function Switch(props) {
|
|||
onCheckedChange={onPrimitiveCheckedChange}
|
||||
{...primitiveProps}>
|
||||
<PrimitiveSwitch.Thumb
|
||||
id={id}
|
||||
className={classNames(
|
||||
"bg-white w-[16px] h-[16px] block transition-transform",
|
||||
checked ? "translate-x-[16px]" : "translate-x-0"
|
||||
|
@ -32,7 +34,9 @@ export default function Switch(props) {
|
|||
/>
|
||||
</PrimitiveSwitch.Root>
|
||||
{label && (
|
||||
<Label.Root className="text-neutral-700 text-sm align-text-top ml-3 font-medium cursor-pointer">
|
||||
<Label.Root
|
||||
htmlFor={id}
|
||||
className="text-neutral-700 text-sm align-text-top ml-3 font-medium cursor-pointer">
|
||||
{label}
|
||||
</Label.Root>
|
||||
)}
|
||||
|
|
|
@ -3,6 +3,7 @@ declare namespace NodeJS {
|
|||
readonly CALENDSO_ENCRYPTION_KEY: string | undefined;
|
||||
readonly DATABASE_URL: string | undefined;
|
||||
readonly GOOGLE_API_CREDENTIALS: string | undefined;
|
||||
readonly GOOGLE_REDIRECT_URL: string | undefined;
|
||||
readonly BASE_URL: string | undefined;
|
||||
readonly NEXT_PUBLIC_BASE_URL: string | undefined;
|
||||
readonly NEXT_PUBLIC_APP_URL: string | undefined;
|
||||
|
|
|
@ -517,8 +517,26 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
};
|
||||
};
|
||||
|
||||
// factory
|
||||
const calendars = (withCredentials): CalendarApiAdapter[] =>
|
||||
function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null {
|
||||
switch (credential.type) {
|
||||
case "google_calendar":
|
||||
return GoogleCalendar(credential);
|
||||
case "office365_calendar":
|
||||
return MicrosoftOffice365Calendar(credential);
|
||||
case "caldav_calendar":
|
||||
// FIXME types wrong & type casting should not be needed
|
||||
return new CalDavCalendar(credential) as never as CalendarApiAdapter;
|
||||
case "apple_calendar":
|
||||
// FIXME types wrong & type casting should not be needed
|
||||
return new AppleCalendar(credential) as never as CalendarApiAdapter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] =>
|
||||
withCredentials
|
||||
.map((cred) => {
|
||||
switch (cred.type) {
|
||||
|
@ -534,7 +552,7 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
|
|||
return; // unknown credential, could be legacy? In any case, ignore
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
.flatMap((item) => (item ? [item as CalendarApiAdapter] : []));
|
||||
|
||||
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
|
||||
Promise.all(
|
||||
|
@ -543,6 +561,11 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda
|
|||
return results.reduce((acc, availability) => acc.concat(availability), []);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param withCredentials
|
||||
* @deprecated
|
||||
*/
|
||||
const listCalendars = (withCredentials) =>
|
||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
||||
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
|
||||
|
@ -650,4 +673,11 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
|||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };
|
||||
export {
|
||||
getBusyCalendarTimes,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
listCalendars,
|
||||
getCalendarAdapterOrNull,
|
||||
};
|
||||
|
|
|
@ -255,10 +255,11 @@ export class AppleCalendar implements CalendarApiAdapter {
|
|||
.filter((calendar) => {
|
||||
return calendar.components?.includes("VEVENT");
|
||||
})
|
||||
.map((calendar) => ({
|
||||
.map((calendar, index) => ({
|
||||
externalId: calendar.url,
|
||||
name: calendar.displayName ?? "",
|
||||
primary: false,
|
||||
// FIXME Find a better way to set the primary calendar
|
||||
primary: index === 0,
|
||||
integration: this.integrationName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
|
|
|
@ -1,4 +1,17 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
DialogHeader,
|
||||
DialogProps,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
} from "@components/Dialog";
|
||||
import { Form, TextField } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
|
@ -6,6 +19,95 @@ type Props = {
|
|||
|
||||
export const ADD_APPLE_INTEGRATION_FORM_TITLE = "addAppleIntegration";
|
||||
|
||||
export function AddAppleIntegrationModal(props: DialogProps) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Connect to Apple Server"
|
||||
subtitle={
|
||||
<>
|
||||
Generate an app specific password to use with Cal.com at{" "}
|
||||
<a
|
||||
className="text-indigo-400"
|
||||
href="https://appleid.apple.com/account/manage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
https://appleid.apple.com/account/manage
|
||||
</a>
|
||||
. Your credentials will be stored and encrypted.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
setErrorMessage("");
|
||||
const res = await fetch("/api/integrations/apple/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(values),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setErrorMessage(json?.message || "Something went wrong");
|
||||
} else {
|
||||
props.onOpenChange?.(false);
|
||||
}
|
||||
})}>
|
||||
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
|
||||
<TextField
|
||||
required
|
||||
type="text"
|
||||
{...form.register("username")}
|
||||
label="Username"
|
||||
placeholder="rickroll"
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
type="password"
|
||||
{...form.register("password")}
|
||||
label="Password"
|
||||
placeholder="•••••••••••••"
|
||||
autoComplete="password"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
|
||||
<DialogFooter>
|
||||
<DialogClose
|
||||
onClick={() => {
|
||||
props.onOpenChange?.(false);
|
||||
}}
|
||||
asChild>
|
||||
<Button type="button" color="secondary" tabIndex={-1}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const AddAppleIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
||||
const onSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
|
|
|
@ -79,6 +79,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
|
|||
const { error, value: iCalString } = await createEvent({
|
||||
uid,
|
||||
startInputType: "utc",
|
||||
// FIXME types
|
||||
start: this.convertDate(event.startTime),
|
||||
duration: this.getDuration(event.startTime, event.endTime),
|
||||
title: event.title,
|
||||
|
@ -137,6 +138,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
|
|||
const { error, value: iCalString } = await createEvent({
|
||||
uid,
|
||||
startInputType: "utc",
|
||||
// FIXME - types wrong
|
||||
start: this.convertDate(event.startTime),
|
||||
duration: this.getDuration(event.startTime, event.endTime),
|
||||
title: event.title,
|
||||
|
@ -203,6 +205,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
// FIXME - types wrong
|
||||
async getAvailability(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
|
@ -258,10 +261,11 @@ export class CalDavCalendar implements CalendarApiAdapter {
|
|||
.filter((calendar) => {
|
||||
return calendar.components?.includes("VEVENT");
|
||||
})
|
||||
.map((calendar) => ({
|
||||
.map((calendar, index) => ({
|
||||
externalId: calendar.url,
|
||||
name: calendar.displayName ?? "",
|
||||
primary: false,
|
||||
// FIXME Find a better way to set the primary calendar
|
||||
primary: index === 0,
|
||||
integration: this.integrationName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
|
|
|
@ -1,4 +1,17 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
DialogHeader,
|
||||
DialogProps,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
} from "@components/Dialog";
|
||||
import { Form, TextField } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
|
@ -11,8 +24,93 @@ export type AddCalDavIntegrationRequest = {
|
|||
password: string;
|
||||
};
|
||||
|
||||
export function AddCalDavIntegrationModal(props: DialogProps) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
url: "",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Connect to CalDav Server"
|
||||
subtitle="Your credentials will be stored and encrypted."
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
setErrorMessage("");
|
||||
const res = await fetch("/api/integrations/caldav/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(values),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setErrorMessage(json?.message || "Something went wrong");
|
||||
} else {
|
||||
props.onOpenChange?.(false);
|
||||
}
|
||||
})}>
|
||||
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
|
||||
<TextField
|
||||
required
|
||||
type="text"
|
||||
{...form.register("url")}
|
||||
label="Calendar URL"
|
||||
placeholder="https://example.com/calendar"
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
type="text"
|
||||
{...form.register("username")}
|
||||
label="Username"
|
||||
placeholder="rickroll"
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
type="password"
|
||||
{...form.register("password")}
|
||||
label="Password"
|
||||
placeholder="•••••••••••••"
|
||||
autoComplete="password"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
|
||||
<DialogFooter>
|
||||
<DialogClose
|
||||
onClick={() => {
|
||||
props.onOpenChange?.(false);
|
||||
}}
|
||||
asChild>
|
||||
<Button type="button" color="secondary" tabIndex={-1}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
||||
const onSubmit = (event) => {
|
||||
const onSubmit = (event: any) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
|
|
@ -8,65 +8,75 @@ const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
|
|||
|
||||
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
|
||||
|
||||
export const ALL_INTEGRATIONS = [
|
||||
{
|
||||
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
||||
type: "google_calendar",
|
||||
title: "Google Calendar",
|
||||
imageSrc: "integrations/google-calendar.svg",
|
||||
description: "For personal and business calendars",
|
||||
variant: "calendar",
|
||||
},
|
||||
{
|
||||
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
|
||||
type: "office365_calendar",
|
||||
title: "Office 365 / Outlook.com Calendar",
|
||||
imageSrc: "integrations/outlook.svg",
|
||||
description: "For personal and business calendars",
|
||||
variant: "calendar",
|
||||
},
|
||||
{
|
||||
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
|
||||
type: "zoom_video",
|
||||
title: "Zoom",
|
||||
imageSrc: "integrations/zoom.svg",
|
||||
description: "Video Conferencing",
|
||||
variant: "conferencing",
|
||||
},
|
||||
{
|
||||
installed: true,
|
||||
type: "caldav_calendar",
|
||||
title: "CalDav Server",
|
||||
imageSrc: "integrations/caldav.svg",
|
||||
description: "For personal and business calendars",
|
||||
variant: "calendar",
|
||||
},
|
||||
{
|
||||
installed: true,
|
||||
type: "apple_calendar",
|
||||
title: "Apple Calendar",
|
||||
imageSrc: "integrations/apple-calendar.svg",
|
||||
description: "For personal and business calendars",
|
||||
variant: "calendar",
|
||||
},
|
||||
{
|
||||
installed: !!(
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||
process.env.STRIPE_PRIVATE_KEY
|
||||
),
|
||||
type: "stripe_payment",
|
||||
title: "Stripe",
|
||||
imageSrc: "integrations/stripe.svg",
|
||||
description: "Receive payments",
|
||||
variant: "payment",
|
||||
},
|
||||
] as const;
|
||||
function getIntegrations(credentials: CredentialData[]) {
|
||||
const integrations = [
|
||||
{
|
||||
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
||||
credential: credentials.find((integration) => integration.type === "google_calendar") || null,
|
||||
type: "google_calendar",
|
||||
title: "Google Calendar",
|
||||
imageSrc: "integrations/google-calendar.svg",
|
||||
description: "For personal and business calendars",
|
||||
},
|
||||
{
|
||||
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
|
||||
type: "office365_calendar",
|
||||
credential: credentials.find((integration) => integration.type === "office365_calendar") || null,
|
||||
title: "Office 365 / Outlook.com Calendar",
|
||||
imageSrc: "integrations/outlook.svg",
|
||||
description: "For personal and business calendars",
|
||||
},
|
||||
{
|
||||
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
|
||||
type: "zoom_video",
|
||||
credential: credentials.find((integration) => integration.type === "zoom_video") || null,
|
||||
title: "Zoom",
|
||||
imageSrc: "integrations/zoom.svg",
|
||||
description: "Video Conferencing",
|
||||
},
|
||||
{
|
||||
installed: true,
|
||||
type: "caldav_calendar",
|
||||
credential: credentials.find((integration) => integration.type === "caldav_calendar") || null,
|
||||
title: "CalDav Server",
|
||||
imageSrc: "integrations/caldav.svg",
|
||||
description: "For personal and business calendars",
|
||||
},
|
||||
{
|
||||
installed: true,
|
||||
type: "apple_calendar",
|
||||
credential: credentials.find((integration) => integration.type === "apple_calendar") || null,
|
||||
title: "Apple Calendar",
|
||||
imageSrc: "integrations/apple-calendar.svg",
|
||||
description: "For personal and business calendars",
|
||||
},
|
||||
{
|
||||
installed: !!(
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||
process.env.STRIPE_PRIVATE_KEY
|
||||
),
|
||||
type: "stripe_payment",
|
||||
credential: credentials.find((integration) => integration.type === "stripe_payment") || null,
|
||||
title: "Stripe",
|
||||
imageSrc: "integrations/stripe.svg",
|
||||
description: "Receive payments",
|
||||
},
|
||||
];
|
||||
const integrations = ALL_INTEGRATIONS.map((integration) => ({
|
||||
...integration,
|
||||
/**
|
||||
* @deprecated use `credentials.
|
||||
*/
|
||||
credential: credentials.find((credential) => credential.type === integration.type) || null,
|
||||
credentials: credentials.filter((credential) => credential.type === integration.type) || null,
|
||||
}));
|
||||
|
||||
return integrations;
|
||||
}
|
||||
|
||||
export type IntegraionMeta = ReturnType<typeof getIntegrations>;
|
||||
|
||||
export function hasIntegration(integrations: ReturnType<typeof getIntegrations>, type: string): boolean {
|
||||
return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential);
|
||||
}
|
||||
|
|
|
@ -8,12 +8,12 @@ import prisma from "../../../lib/prisma";
|
|||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session) {
|
||||
if (!session?.user?.id) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = await prisma.user.findFirst({
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
|
@ -24,17 +24,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
},
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method == "POST") {
|
||||
await prisma.selectedCalendar.create({
|
||||
data: {
|
||||
user: {
|
||||
connect: {
|
||||
id: currentUser.id,
|
||||
},
|
||||
await prisma.selectedCalendar.upsert({
|
||||
where: {
|
||||
userId_integration_externalId: {
|
||||
userId: currentUser.id,
|
||||
integration: req.body.integration,
|
||||
externalId: req.body.externalId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: currentUser.id,
|
||||
integration: req.body.integration,
|
||||
externalId: req.body.externalId,
|
||||
},
|
||||
// already exists
|
||||
update: {},
|
||||
});
|
||||
res.status(200).json({ message: "Calendar Selection Saved" });
|
||||
}
|
||||
|
|
|
@ -21,7 +21,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
// Get token from Google Calendar API
|
||||
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web;
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||
const redirect_uri = process.env.GOOGLE_REDIRECT_URL || redirect_uris[0];
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
|
|
|
@ -2,10 +2,9 @@ import { google } from "googleapis";
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import prisma from "../../../../lib/prisma";
|
||||
|
||||
const credentials = process.env.GOOGLE_API_CREDENTIALS!;
|
||||
const credentials = process.env.GOOGLE_API_CREDENTIALS;
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { code } = req.query;
|
||||
|
@ -13,7 +12,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
// Check that user is authenticated
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session) {
|
||||
if (!session?.user?.id) {
|
||||
res.status(401).json({ message: "You must be logged in to do this" });
|
||||
return;
|
||||
}
|
||||
|
@ -21,9 +20,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
res.status(400).json({ message: "`code` must be a string" });
|
||||
return;
|
||||
}
|
||||
if (!credentials) {
|
||||
res.status(400).json({ message: "There are no Google Credentials installed." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web;
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||
const redirect_uri = process.env.GOOGLE_REDIRECT_URL || redirect_uris[0];
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||
const token = await oAuth2Client.getToken(code);
|
||||
const key = token.res?.data;
|
||||
await prisma.credential.create({
|
||||
|
|
|
@ -16,7 +16,7 @@ import { NextPageContext } from "next";
|
|||
import { useSession } from "next-auth/client";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { Integration } from "pages/integrations";
|
||||
import { Integration } from "pages/integrations/_new";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
|
||||
|
|
|
@ -1,103 +1,9 @@
|
|||
import { useSession } from "next-auth/client";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { getIntegrationName, getIntegrationType } from "@lib/integrations";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
|
||||
export default function Integration(props) {
|
||||
const router = useRouter();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
async function deleteIntegrationHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
/*eslint-disable */
|
||||
const response = await fetch("/api/integrations", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.integration.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
/*eslint-enable */
|
||||
|
||||
router.push("/integrations");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell
|
||||
heading={`${getIntegrationName(props.integration.type)} App`}
|
||||
subtitle="Manage and delete this app.">
|
||||
<div className="block sm:grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 bg-white border border-gray-200 mb-6 overflow-hidden rounded-sm">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Integration Details</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Information about your {getIntegrationName(props.integration.type)} App.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<dl className="grid gap-y-8">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">App name</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{getIntegrationName(props.integration.type)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">App Category</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{getIntegrationType(props.integration.type)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-white border border-gray-200 mb-6 rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete this app</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>Once you delete this app, it will be permanently removed.</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<button
|
||||
onClick={deleteIntegrationHandler}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-sm text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm">
|
||||
Delete App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
function RedirectPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const session = await getSession(context);
|
||||
|
||||
const integration = await prisma.credential.findFirst({
|
||||
where: {
|
||||
id: parseInt(context.query.integration),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
props: { session, integration },
|
||||
};
|
||||
export async function getServerSideProps() {
|
||||
return { redirect: { permanent: false, destination: "/integrations" } };
|
||||
}
|
||||
|
||||
export default RedirectPage;
|
||||
|
|
|
@ -1,510 +1,410 @@
|
|||
import { InformationCircleIcon } from "@heroicons/react/outline";
|
||||
import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useSession } from "next-auth/client";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Maybe } from "@trpc/server";
|
||||
import Image from "next/image";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { ONBOARDING_NEXT_REDIRECT, shouldShowOnboarding } from "@lib/getting-started";
|
||||
import AddAppleIntegration, {
|
||||
ADD_APPLE_INTEGRATION_FORM_TITLE,
|
||||
} from "@lib/integrations/Apple/components/AddAppleIntegration";
|
||||
import AddCalDavIntegration, {
|
||||
ADD_CALDAV_INTEGRATION_FORM_TITLE,
|
||||
} from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
||||
import getIntegrations from "@lib/integrations/getIntegrations";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import classNames from "@lib/classNames";
|
||||
import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
|
||||
import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
||||
import showToast from "@lib/notification";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import Button from "@components/ui/Button";
|
||||
import { Dialog } from "@components/Dialog";
|
||||
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||
import Shell, { ShellSubHeading } from "@components/Shell";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Badge from "@components/ui/Badge";
|
||||
import Button, { ButtonBaseProps } from "@components/ui/Button";
|
||||
import Switch from "@components/ui/Switch";
|
||||
|
||||
export default function Home({ integrations }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const [, loading] = useSession();
|
||||
type IntegrationCalendar = inferQueryOutput<"viewer.integrations">["calendar"]["items"][number];
|
||||
|
||||
const [selectableCalendars, setSelectableCalendars] = useState([]);
|
||||
const addCalDavIntegrationRef = useRef<HTMLFormElement>(null);
|
||||
const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false);
|
||||
const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null);
|
||||
function pluralize(opts: { num: number; plural: string; singular: string }) {
|
||||
if (opts.num === 0) {
|
||||
return opts.singular;
|
||||
}
|
||||
return opts.singular;
|
||||
}
|
||||
|
||||
const addAppleIntegrationRef = useRef<HTMLFormElement>(null);
|
||||
const [isAddAppleIntegrationDialogOpen, setIsAddAppleIntegrationDialogOpen] = useState(false);
|
||||
const [addAppleError, setAddAppleError] = useState<{ message: string } | null>(null);
|
||||
function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) {
|
||||
const num = props.numConnections;
|
||||
return (
|
||||
<>
|
||||
<span>{props.title}</span>
|
||||
{num ? (
|
||||
<Badge variant="success">
|
||||
{num}{" "}
|
||||
{pluralize({
|
||||
num,
|
||||
singular: "connection",
|
||||
plural: "connections",
|
||||
})}
|
||||
</Badge>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(loadCalendars, [integrations]);
|
||||
function ConnectIntegration(props: {
|
||||
type: IntegrationCalendar["type"];
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
}) {
|
||||
const { type } = props;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const mutation = useMutation(async () => {
|
||||
const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add");
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
const json = await res.json();
|
||||
window.location.href = json.url;
|
||||
setIsLoading(true);
|
||||
});
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
function loadCalendars() {
|
||||
fetch("api/availability/calendar")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setSelectableCalendars(data);
|
||||
// refetch intergrations when modal closes
|
||||
const utils = trpc.useContext();
|
||||
useEffect(() => {
|
||||
utils.invalidateQueries(["viewer.integrations"]);
|
||||
}, [isModalOpen, utils]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.render({
|
||||
onClick() {
|
||||
if (["caldav_calendar", "apple_calendar"].includes(type)) {
|
||||
// special handlers
|
||||
setIsModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate();
|
||||
},
|
||||
loading: mutation.isLoading || isLoading,
|
||||
disabled: isModalOpen,
|
||||
})}
|
||||
{type === "caldav_calendar" && (
|
||||
<AddCalDavIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||
)}
|
||||
|
||||
{type === "apple_calendar" && (
|
||||
<AddAppleIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DisconnectIntegration(props: {
|
||||
/**
|
||||
* Integration credential id
|
||||
*/
|
||||
id: number;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
}) {
|
||||
const utils = trpc.useContext();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
async () => {
|
||||
const res = await fetch("/api/integrations", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function integrationHandler(type) {
|
||||
if (type === "caldav_calendar") {
|
||||
setAddCalDavError(null);
|
||||
setIsAddCalDavIntegrationDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "apple_calendar") {
|
||||
setAddAppleError(null);
|
||||
setIsAddAppleIntegrationDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/integrations/" + type.replace("_", "") + "/add")
|
||||
.then((response) => response.json())
|
||||
.then((data) => (window.location.href = data.url));
|
||||
}
|
||||
|
||||
const handleAddCalDavIntegration = async ({ url, username, password }) => {
|
||||
const requestBody = JSON.stringify({
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
return await fetch("/api/integrations/caldav/add", {
|
||||
method: "POST",
|
||||
body: requestBody,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddAppleIntegration = async ({ username, password }) => {
|
||||
const requestBody = JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
return await fetch("/api/integrations/apple/add", {
|
||||
method: "POST",
|
||||
body: requestBody,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
onSuccess() {
|
||||
setModalOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title="Disconnect Integration"
|
||||
confirmBtnText="Yes, delete integration"
|
||||
cancelBtnText="Cancel"
|
||||
onConfirm={() => {
|
||||
mutation.mutate();
|
||||
}}>
|
||||
Are you sure you want to disconnect this integration?
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
{props.render({
|
||||
onClick() {
|
||||
setModalOpen(true);
|
||||
},
|
||||
disabled: modalOpen,
|
||||
loading: mutation.isLoading,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function calendarSelectionHandler(calendar) {
|
||||
return (selected) => {
|
||||
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
|
||||
selectableCalendars[i].selected = selected;
|
||||
if (selected) {
|
||||
fetch("api/availability/calendar", {
|
||||
function ConnectOrDisconnectIntegrationButton(props: {
|
||||
//
|
||||
credential: Maybe<{ id: number }>;
|
||||
type: IntegrationCalendar["type"];
|
||||
installed: boolean;
|
||||
}) {
|
||||
if (props.credential) {
|
||||
return (
|
||||
<DisconnectIntegration
|
||||
id={props.credential.id}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn">
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!props.installed) {
|
||||
return <Alert severity="warning" title="Not installed" />;
|
||||
}
|
||||
return (
|
||||
<ConnectIntegration type={props.type} render={(btnProps) => <Button {...btnProps}>Connect</Button>} />
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationListItem(props: {
|
||||
imageSrc: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
|
||||
<div className={classNames("flex flex-1 space-x-2 w-full p-4")}>
|
||||
<div>
|
||||
<Image width={40} height={40} src={`/${props.imageSrc}`} alt={props.title} />
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<ListItemTitle component="h3">{props.title}</ListItemTitle>
|
||||
<ListItemText component="p">{props.description}</ListItemText>
|
||||
</div>
|
||||
<div>{props.actions}</div>
|
||||
</div>
|
||||
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarSwitch(props: {
|
||||
type: IntegrationCalendar["type"];
|
||||
externalId: string;
|
||||
title: string;
|
||||
defaultSelected: boolean;
|
||||
}) {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const mutation = useMutation<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
isOn: boolean;
|
||||
}
|
||||
>(
|
||||
async ({ isOn }) => {
|
||||
const body = {
|
||||
integration: props.type,
|
||||
externalId: props.externalId,
|
||||
};
|
||||
if (isOn) {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(selectableCalendars[i]),
|
||||
}).then((response) => response.json());
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
} else {
|
||||
fetch("api/availability/calendar", {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(selectableCalendars[i]),
|
||||
}).then((response) => response.json());
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getCalendarIntegrationImage(integrationType: string) {
|
||||
switch (integrationType) {
|
||||
case "google_calendar":
|
||||
return "integrations/google-calendar.svg";
|
||||
case "office365_calendar":
|
||||
return "integrations/outlook.svg";
|
||||
case "caldav_calendar":
|
||||
return "integrations/caldav.svg";
|
||||
case "apple_calendar":
|
||||
return "integrations/apple-calendar.svg";
|
||||
default:
|
||||
return "";
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function onCloseSelectCalendar() {
|
||||
setSelectableCalendars([...selectableCalendars]);
|
||||
}
|
||||
|
||||
const ConnectNewAppDialog = () => (
|
||||
<Dialog>
|
||||
<DialogTrigger className="px-4 py-2 mt-6 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
<PlusIcon className="inline w-5 h-5 mr-1" />
|
||||
Connect a new App
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader title="Connect a new App" subtitle="Integrate your account with other services." />
|
||||
<div className="my-4">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{integrations
|
||||
.filter((integration) => integration.installed)
|
||||
.map((integration) => {
|
||||
return (
|
||||
<li key={integration.type} className="flex py-4">
|
||||
<div className="w-1/12 pt-2 mr-4">
|
||||
<img className="w-8 h-8 mr-2" src={integration.imageSrc} alt={integration.title} />
|
||||
</div>
|
||||
<div className="w-10/12">
|
||||
<h2 className="font-medium text-gray-800 font-cal">{integration.title}</h2>
|
||||
<p className="text-sm text-gray-400">{integration.description}</p>
|
||||
</div>
|
||||
<div className="w-2/12 pt-2 text-right">
|
||||
<button
|
||||
onClick={() => integrationHandler(integration.type)}
|
||||
className="font-medium text-neutral-900 hover:text-neutral-500">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const SelectCalendarDialog = () => (
|
||||
<Dialog onOpenChange={(open) => !open && onCloseSelectCalendar()}>
|
||||
<DialogTrigger className="px-4 py-2 mt-6 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
Select calendars
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Select calendars"
|
||||
subtitle="If no entry is selected, all calendars will be checked"
|
||||
/>
|
||||
<div className="my-4">
|
||||
<ul className="overflow-y-auto divide-y divide-gray-200 max-h-96">
|
||||
{selectableCalendars.map((calendar) => (
|
||||
<li key={calendar.name} className="flex py-4">
|
||||
<div className="w-1/12 pt-2 mr-4">
|
||||
<img
|
||||
className="w-8 h-8 mr-2"
|
||||
src={getCalendarIntegrationImage(calendar.integration)}
|
||||
alt={calendar.integration}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-10/12 pt-3">
|
||||
<h2 className="font-medium text-gray-800">{calendar.name}</h2>
|
||||
</div>
|
||||
<div className="w-2/12 pt-3 text-right">
|
||||
<Switch
|
||||
defaultChecked={calendar.selected}
|
||||
onCheckedChange={calendarSelectionHandler(calendar)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">Confirm</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
return (
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
label={props.title}
|
||||
defaultChecked={props.defaultSelected}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
mutation.mutate({ isOn });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAddCalDavIntegrationSaveButtonPress = async () => {
|
||||
const form = addCalDavIntegrationRef.current.elements;
|
||||
const url = form.url.value;
|
||||
const password = form.password.value;
|
||||
const username = form.username.value;
|
||||
|
||||
try {
|
||||
setAddCalDavError(null);
|
||||
const addCalDavIntegrationResponse = await handleAddCalDavIntegration({ username, password, url });
|
||||
if (addCalDavIntegrationResponse.ok) {
|
||||
setIsAddCalDavIntegrationDialogOpen(false);
|
||||
} else {
|
||||
const j = await addCalDavIntegrationResponse.json();
|
||||
setAddCalDavError({ message: j.message });
|
||||
}
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAppleIntegrationSaveButtonPress = async () => {
|
||||
const form = addAppleIntegrationRef.current.elements;
|
||||
const password = form.password.value;
|
||||
const username = form.username.value;
|
||||
|
||||
try {
|
||||
setAddAppleError(null);
|
||||
const addAppleIntegrationResponse = await handleAddAppleIntegration({ username, password });
|
||||
if (addAppleIntegrationResponse.ok) {
|
||||
setIsAddAppleIntegrationDialogOpen(false);
|
||||
} else {
|
||||
const j = await addAppleIntegrationResponse.json();
|
||||
setAddAppleError({ message: j.message });
|
||||
}
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const ConnectCalDavServerDialog = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isAddCalDavIntegrationDialogOpen}
|
||||
onOpenChange={(isOpen) => setIsAddCalDavIntegrationDialogOpen(isOpen)}>
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Connect to CalDav Server"
|
||||
subtitle="Your credentials will be stored and encrypted."
|
||||
/>
|
||||
<div className="my-4">
|
||||
{addCalDavError && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{addCalDavError.message}
|
||||
</p>
|
||||
)}
|
||||
<AddCalDavIntegration
|
||||
ref={addCalDavIntegrationRef}
|
||||
onSubmit={handleAddCalDavIntegrationSaveButtonPress}
|
||||
/>
|
||||
</div>
|
||||
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button
|
||||
type="submit"
|
||||
form={ADD_CALDAV_INTEGRATION_FORM_TITLE}
|
||||
className="flex justify-center px-4 py-2 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
Save
|
||||
</Button>
|
||||
<DialogClose
|
||||
onClick={() => {
|
||||
setIsAddCalDavIntegrationDialogOpen(false);
|
||||
}}
|
||||
asChild>
|
||||
<Button color="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}, [isAddCalDavIntegrationDialogOpen, addCalDavError]);
|
||||
|
||||
const ConnectAppleServerDialog = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isAddAppleIntegrationDialogOpen}
|
||||
onOpenChange={(isOpen) => setIsAddAppleIntegrationDialogOpen(isOpen)}>
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Connect to Apple Server"
|
||||
subtitle={
|
||||
<p>
|
||||
Generate an app specific password to use with Cal.com at{" "}
|
||||
<a
|
||||
className="text-indigo-400"
|
||||
href="https://appleid.apple.com/account/manage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
https://appleid.apple.com/account/manage
|
||||
</a>
|
||||
. Your credentials will be stored and encrypted.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<div className="my-4">
|
||||
{addAppleError && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{addAppleError.message}
|
||||
</p>
|
||||
)}
|
||||
<AddAppleIntegration
|
||||
ref={addAppleIntegrationRef}
|
||||
onSubmit={handleAddAppleIntegrationSaveButtonPress}
|
||||
/>
|
||||
</div>
|
||||
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="submit"
|
||||
form={ADD_APPLE_INTEGRATION_FORM_TITLE}
|
||||
className="flex justify-center px-4 py-2 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
Save
|
||||
</button>
|
||||
<DialogClose
|
||||
onClick={() => {
|
||||
setIsAddAppleIntegrationDialogOpen(false);
|
||||
}}
|
||||
asChild>
|
||||
<Button color="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}, [isAddAppleIntegrationDialogOpen, addAppleError]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
export default function IntegrationsPage() {
|
||||
const query = trpc.useQuery(["viewer.integrations"]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell heading="Integrations" subtitle="Connect your favourite apps." CTA={<ConnectNewAppDialog />}>
|
||||
<div className="mb-8 overflow-hidden bg-white border border-gray-200 rounded-sm">
|
||||
{integrations.filter((ig) => ig.credential).length !== 0 ? (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{integrations
|
||||
.filter((ig) => ig.credential)
|
||||
.map((ig) => (
|
||||
<li key={ig.credential.id}>
|
||||
<Link href={"/integrations/" + ig.credential.id}>
|
||||
<a className="block hover:bg-gray-50">
|
||||
<div className="flex items-center px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
<img className="w-10 h-10 mr-2" src={ig.imageSrc} alt={ig.title} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 px-4 md:grid md:grid-cols-2 md:gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium truncate text-neutral-900">{ig.title}</p>
|
||||
<p className="flex items-center text-sm text-gray-500">
|
||||
{ig.type.endsWith("_calendar") && (
|
||||
<span className="truncate">Calendar Integration</span>
|
||||
)}
|
||||
{ig.type.endsWith("_video") && (
|
||||
<span className="truncate">Video Conferencing</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
{ig.credential.key && (
|
||||
<p className="flex items-center mt-2 text-gray-500 text">
|
||||
<CheckCircleIcon className="flex-shrink-0 mr-1.5 h-5 w-5 text-green-400" />
|
||||
Connected
|
||||
</p>
|
||||
)}
|
||||
{!ig.credential.key && (
|
||||
<p className="flex items-center mt-3 text-gray-500 text">
|
||||
<XCircleIcon className="flex-shrink-0 mr-1.5 h-5 w-5 text-yellow-400" />
|
||||
Not connected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ChevronRightIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<Shell heading="Integrations" subtitle="Connect your favourite apps.">
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
return (
|
||||
<>
|
||||
<ShellSubHeading
|
||||
title={
|
||||
<SubHeadingTitleWithConnections
|
||||
title="Conferencing"
|
||||
numConnections={data.conferencing.numActive}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<List>
|
||||
{data.conferencing.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
key={item.title}
|
||||
{...item}
|
||||
actions={<ConnectOrDisconnectIntegrationButton {...item} />}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="bg-white rounded-sm shadow">
|
||||
<div className="flex">
|
||||
<div className="pl-8 py-9">
|
||||
<InformationCircleIcon className="w-16 text-neutral-900" />
|
||||
</div>
|
||||
<div className="py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
You don't have any apps connected.
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
You currently do not have any apps connected. Connect your first app to get started.
|
||||
</p>
|
||||
</div>
|
||||
<ConnectNewAppDialog />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-8 bg-white border border-gray-200 rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900 font-cal">Select calendars</h3>
|
||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
||||
<p>Select which calendars are checked for availability to prevent double bookings.</p>
|
||||
</div>
|
||||
<SelectCalendarDialog />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900 font-cal">Launch your own App</h3>
|
||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
||||
<p>If you want to add your own App here, get in touch with us.</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<a href="mailto:apps@cal.com" className="btn btn-white">
|
||||
Contact us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectCalDavServerDialog />
|
||||
<ConnectAppleServerDialog />
|
||||
</Shell>
|
||||
</div>
|
||||
</List>
|
||||
|
||||
<ShellSubHeading
|
||||
className="mt-6"
|
||||
title={
|
||||
<SubHeadingTitleWithConnections title="Payment" numConnections={data.payment.numActive} />
|
||||
}
|
||||
/>
|
||||
<List>
|
||||
{data.payment.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
key={item.title}
|
||||
{...item}
|
||||
actions={<ConnectOrDisconnectIntegrationButton {...item} />}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<ShellSubHeading
|
||||
className="mt-6"
|
||||
title={
|
||||
<SubHeadingTitleWithConnections
|
||||
title="Calendars"
|
||||
numConnections={data.calendar.numActive}
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
Configure how your links integrate with your calendars.
|
||||
<br />
|
||||
You can override these settings on a per event basis.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{data.connectedCalendars.length > 0 && (
|
||||
<>
|
||||
<List>
|
||||
{data.connectedCalendars.map((item, index) => (
|
||||
<li key={index}>
|
||||
{item.calendars ? (
|
||||
<IntegrationListItem
|
||||
{...item.integration}
|
||||
description={item.primary.externalId}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn">
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
}>
|
||||
<ul className="space-y-2 p-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</IntegrationListItem>
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Something went wrong"
|
||||
message={item.error.message}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn">
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
<h2 className="font-bold text-gray-900 flex items-center content-center mb-2 mt-4">
|
||||
Connect an additional calendar
|
||||
</h2>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
<List>
|
||||
{data.calendar.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
key={item.title}
|
||||
{...item}
|
||||
actions={
|
||||
<ConnectIntegration
|
||||
type={item.type}
|
||||
render={(btnProps) => <Button {...btnProps}>Connect</Button>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const session = await getSession(context);
|
||||
if (!session?.user?.email) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
credentials: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
completedOnboarding: true,
|
||||
createdDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user)
|
||||
return {
|
||||
redirect: { permanent: false, destination: "/auth/login" },
|
||||
};
|
||||
|
||||
if (
|
||||
shouldShowOnboarding({ completedOnboarding: user.completedOnboarding, createdDate: user.createdDate })
|
||||
) {
|
||||
return ONBOARDING_NEXT_REDIRECT;
|
||||
}
|
||||
|
||||
const integrations = getIntegrations(user.credentials);
|
||||
|
||||
return {
|
||||
props: { session, integrations },
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import Head from "next/head";
|
||||
import React from "react";
|
||||
|
||||
import { Alert, AlertProps } from "@components/ui/Alert";
|
||||
|
||||
export default function AlertPage() {
|
||||
import { sandboxPage } from ".";
|
||||
|
||||
const page = sandboxPage(function AlertPage() {
|
||||
const list: AlertProps[] = [
|
||||
{ title: "Something went wrong", severity: "error" },
|
||||
{ title: "Something went kinda wrong", severity: "warning" },
|
||||
|
@ -23,9 +24,6 @@ export default function AlertPage() {
|
|||
];
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="googlebot" content="noindex" />
|
||||
</Head>
|
||||
<div className="p-4 bg-gray-200">
|
||||
<h1>Alert component</h1>
|
||||
<div className="flex flex-col">
|
||||
|
@ -52,4 +50,7 @@ export default function AlertPage() {
|
|||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default page.default;
|
||||
export const getStaticProps = page.getStaticProps;
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import { Badge, BadgeProps } from "@components/ui/Badge";
|
||||
|
||||
import { sandboxPage } from ".";
|
||||
|
||||
const page = sandboxPage(function BadgePage() {
|
||||
const list: BadgeProps[] = [
|
||||
//
|
||||
{ variant: "success" },
|
||||
{ variant: "gray" },
|
||||
{ variant: "success" },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="p-4 bg-gray-200">
|
||||
<h1>Badge component</h1>
|
||||
<div className="flex flex-col">
|
||||
{list.map((props, index) => (
|
||||
<div key={index} className="p-2 m-2 bg-white">
|
||||
<h3>
|
||||
<code>
|
||||
{JSON.stringify(
|
||||
props,
|
||||
(key, value) => {
|
||||
if (key.includes("Icon")) {
|
||||
return "..";
|
||||
}
|
||||
return value;
|
||||
},
|
||||
2
|
||||
)}
|
||||
</code>
|
||||
</h3>
|
||||
<Badge {...(props as any)}>Badge text</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default page.default;
|
||||
export const getStaticProps = page.getStaticProps;
|
|
@ -1,11 +1,12 @@
|
|||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
import Head from "next/head";
|
||||
import React from "react";
|
||||
|
||||
import { Button, ButtonProps } from "@components/ui/Button";
|
||||
import { Button, ButtonBaseProps } from "@components/ui/Button";
|
||||
|
||||
export default function ButtonPage() {
|
||||
const list: ButtonProps[] = [
|
||||
import { sandboxPage } from ".";
|
||||
|
||||
const page = sandboxPage(function ButtonPage() {
|
||||
const list: ButtonBaseProps[] = [
|
||||
// primary
|
||||
{ color: "primary" },
|
||||
{ color: "primary", disabled: true },
|
||||
|
@ -26,18 +27,15 @@ export default function ButtonPage() {
|
|||
{ color: "primary", size: "base" },
|
||||
{ color: "primary", size: "lg" },
|
||||
|
||||
// href
|
||||
{ href: "/staging" },
|
||||
{ href: "/staging", disabled: true },
|
||||
// // href
|
||||
// { href: "/staging" },
|
||||
// { href: "/staging", disabled: true },
|
||||
|
||||
{ StartIcon: PlusIcon },
|
||||
{ EndIcon: PlusIcon },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="googlebot" content="noindex" />
|
||||
</Head>
|
||||
<div className="p-4 bg-gray-200">
|
||||
<h1>Button component</h1>
|
||||
<div className="flex flex-col">
|
||||
|
@ -57,11 +55,14 @@ export default function ButtonPage() {
|
|||
)}
|
||||
</code>
|
||||
</h3>
|
||||
<Button {...props}>Button text</Button>
|
||||
<Button {...(props as any)}>Button text</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default page.default;
|
||||
export const getStaticProps = page.getStaticProps;
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { List, ListItem } from "@components/List";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import { sandboxPage } from ".";
|
||||
|
||||
const page = sandboxPage(() => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div className="p-4">
|
||||
Unstyled -{" "}
|
||||
<Button size="sm" color="minimal" onClick={() => setExpanded((state) => !state)}>
|
||||
Toggle expanded
|
||||
</Button>
|
||||
<List>
|
||||
<ListItem expanded={expanded} className="transition-all">
|
||||
An item
|
||||
</ListItem>
|
||||
<ListItem expanded={expanded} className="transition-all">
|
||||
An item
|
||||
</ListItem>
|
||||
<ListItem expanded={expanded} className="transition-all">
|
||||
An item
|
||||
</ListItem>
|
||||
<ListItem expanded={expanded} className="transition-all">
|
||||
An item
|
||||
</ListItem>
|
||||
</List>
|
||||
One expanded
|
||||
<List>
|
||||
<ListItem>An item</ListItem>
|
||||
<ListItem expanded>Spaced</ListItem>
|
||||
<ListItem>An item</ListItem>
|
||||
<ListItem>An item</ListItem>
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default page.default;
|
||||
export const getStaticProps = page.getStaticProps;
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react";
|
||||
|
||||
import { Label, Input, TextField } from "@components/form/fields";
|
||||
|
||||
import { sandboxPage } from ".";
|
||||
|
||||
const page = sandboxPage(() => (
|
||||
<div className="p-4 space-y-6">
|
||||
<div>
|
||||
<Label>Label</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Input placeholder="Input" />
|
||||
</div>
|
||||
<div>
|
||||
<TextField label="TextField" placeholder="it has an input baked in" />
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
export default page.default;
|
||||
export const getStaticProps = page.getStaticProps;
|
|
@ -0,0 +1,73 @@
|
|||
import fs from "fs";
|
||||
import { NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import path from "path";
|
||||
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
async function _getStaticProps() {
|
||||
const dir = path.join(process.cwd(), "pages", "sandbox");
|
||||
|
||||
const pages = fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => !file.startsWith("."))
|
||||
.map((file) => {
|
||||
const parts = file.split(".");
|
||||
// remove extension
|
||||
parts.pop();
|
||||
return parts.join(".");
|
||||
});
|
||||
return {
|
||||
props: {
|
||||
pages,
|
||||
},
|
||||
};
|
||||
}
|
||||
type PageProps = inferSSRProps<typeof _getStaticProps>;
|
||||
|
||||
const SandboxPage: NextPage<PageProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="googlebot" content="noindex" />
|
||||
</Head>
|
||||
<nav>
|
||||
<ul className="flex justify-between flex-col md:flex-row">
|
||||
{props.pages.map((pathname) => (
|
||||
<li key={pathname}>
|
||||
<Link href={"/sandbox/" + pathname + "#main"}>
|
||||
<a className="font-mono px-4">{pathname}</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<main id="main" className="bg-gray-100">
|
||||
{props.children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export function sandboxPage(Component: NextPage) {
|
||||
const Wrapper: NextPage<PageProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<SandboxPage {...props}>
|
||||
<Component />
|
||||
</SandboxPage>
|
||||
</>
|
||||
);
|
||||
};
|
||||
return {
|
||||
default: Wrapper,
|
||||
getStaticProps: _getStaticProps,
|
||||
};
|
||||
}
|
||||
|
||||
const page = sandboxPage(() => {
|
||||
return <p className="text-center text-2xl my-20">Click a component above</p>;
|
||||
});
|
||||
|
||||
export default page.default;
|
||||
export const getStaticProps = page.getStaticProps;
|
|
@ -7,7 +7,14 @@ import { hashPassword } from "../lib/auth";
|
|||
const prisma = new PrismaClient();
|
||||
|
||||
async function createUserAndEventType(opts: {
|
||||
user: { email: string; password: string; username: string; plan: UserPlan; name: string };
|
||||
user: {
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
plan: UserPlan;
|
||||
name: string;
|
||||
completedOnboarding?: boolean;
|
||||
};
|
||||
eventTypes: Array<
|
||||
Prisma.EventTypeCreateInput & {
|
||||
_bookings?: Prisma.BookingCreateInput[];
|
||||
|
@ -18,7 +25,7 @@ async function createUserAndEventType(opts: {
|
|||
...opts.user,
|
||||
password: await hashPassword(opts.user.password),
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: true,
|
||||
completedOnboarding: opts.user.completedOnboarding ?? true,
|
||||
};
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: opts.user.email },
|
||||
|
@ -97,24 +104,14 @@ async function createUserAndEventType(opts: {
|
|||
async function main() {
|
||||
await createUserAndEventType({
|
||||
user: {
|
||||
email: "free@example.com",
|
||||
password: "free",
|
||||
username: "free",
|
||||
name: "Free Example",
|
||||
plan: "FREE",
|
||||
email: "onboarding@example.com",
|
||||
password: "onboarding",
|
||||
username: "onboarding",
|
||||
name: "onboarding",
|
||||
plan: "TRIAL",
|
||||
completedOnboarding: false,
|
||||
},
|
||||
eventTypes: [
|
||||
{
|
||||
title: "30min",
|
||||
slug: "30min",
|
||||
length: 30,
|
||||
},
|
||||
{
|
||||
title: "60min",
|
||||
slug: "60min",
|
||||
length: 30,
|
||||
},
|
||||
],
|
||||
eventTypes: [],
|
||||
});
|
||||
|
||||
await createUserAndEventType({
|
||||
|
@ -199,6 +196,28 @@ async function main() {
|
|||
],
|
||||
});
|
||||
|
||||
await createUserAndEventType({
|
||||
user: {
|
||||
email: "free@example.com",
|
||||
password: "free",
|
||||
username: "free",
|
||||
name: "Free Example",
|
||||
plan: "FREE",
|
||||
},
|
||||
eventTypes: [
|
||||
{
|
||||
title: "30min",
|
||||
slug: "30min",
|
||||
length: 30,
|
||||
},
|
||||
{
|
||||
title: "60min",
|
||||
slug: "60min",
|
||||
length: 30,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,20 @@ async function getUserFromSession({ session, req }: { session: Maybe<Session>; r
|
|||
createdDate: true,
|
||||
hideBranding: true,
|
||||
avatar: true,
|
||||
credentials: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
selectedCalendars: {
|
||||
select: {
|
||||
externalId: true,
|
||||
integration: true,
|
||||
},
|
||||
},
|
||||
completedOnboarding: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import { Prisma, BookingStatus } from "@prisma/client";
|
||||
import { BookingStatus, Prisma } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getErrorFromUnknown } from "pages/_error";
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
|
||||
|
||||
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||
import getIntegrations, { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
import { getCalendarAdapterOrNull } from "../../lib/calendarClient";
|
||||
import { createProtectedRouter } from "../createRouter";
|
||||
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
||||
|
||||
|
@ -91,6 +94,88 @@ export const viewerRouter = createProtectedRouter()
|
|||
return bookings;
|
||||
},
|
||||
})
|
||||
.query("integrations", {
|
||||
async resolve({ ctx }) {
|
||||
const { user } = ctx;
|
||||
const { credentials } = user;
|
||||
const integrations = getIntegrations(credentials);
|
||||
|
||||
function countActive(items: { credentials: unknown[] }[]) {
|
||||
return items.reduce((acc, item) => acc + item.credentials.length, 0);
|
||||
}
|
||||
const conferencing = integrations.flatMap((item) => (item.variant === "conferencing" ? [item] : []));
|
||||
const payment = integrations.flatMap((item) => (item.variant === "payment" ? [item] : []));
|
||||
const calendar = integrations.flatMap((item) => (item.variant === "calendar" ? [item] : []));
|
||||
|
||||
// get user's credentials + their connected integrations
|
||||
const calendarCredentials = user.credentials
|
||||
.filter((credential) => credential.type.endsWith("_calendar"))
|
||||
.flatMap((credential) => {
|
||||
const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type);
|
||||
|
||||
const adapter = getCalendarAdapterOrNull({
|
||||
...credential,
|
||||
userId: user.id,
|
||||
});
|
||||
return integration && adapter && integration.variant === "calendar"
|
||||
? [{ integration, credential, adapter }]
|
||||
: [];
|
||||
});
|
||||
|
||||
// get all the connected integrations' calendars (from third party)
|
||||
const connectedCalendars = await Promise.all(
|
||||
calendarCredentials.map(async (item) => {
|
||||
const { adapter, integration, credential } = item;
|
||||
try {
|
||||
const _calendars = await adapter.listCalendars();
|
||||
const calendars = _calendars.map((cal) => ({
|
||||
...cal,
|
||||
isSelected: !!user.selectedCalendars.find((selected) => selected.externalId === cal.externalId),
|
||||
}));
|
||||
const primary = calendars.find((item) => item.primary) ?? calendars[0];
|
||||
if (!primary) {
|
||||
return {
|
||||
integration,
|
||||
credentialId: credential.id,
|
||||
error: {
|
||||
message: "No primary calendar found",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
integration,
|
||||
credentialId: credential.id,
|
||||
primary,
|
||||
calendars,
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = getErrorFromUnknown(_error);
|
||||
return {
|
||||
integration,
|
||||
error: {
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
return {
|
||||
conferencing: {
|
||||
items: conferencing,
|
||||
numActive: countActive(conferencing),
|
||||
},
|
||||
calendar: {
|
||||
items: calendar,
|
||||
numActive: countActive(calendar),
|
||||
},
|
||||
payment: {
|
||||
items: payment,
|
||||
numActive: countActive(payment),
|
||||
},
|
||||
connectedCalendars,
|
||||
};
|
||||
},
|
||||
})
|
||||
.mutation("updateProfile", {
|
||||
input: z.object({
|
||||
username: z.string().optional(),
|
||||
|
|
Loading…
Reference in New Issue