`/integrations` facelift (#858)

This commit is contained in:
Alex Johansson 2021-10-12 11:35:44 +02:00 committed by GitHub
parent 7dc4a55319
commit c3dc18643e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1259 additions and 716 deletions

View File

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

View File

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

72
components/List.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
environment.d.ts vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&apos;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 },
};
}

View File

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

43
pages/sandbox/Badge.tsx Normal file
View File

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

View File

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

42
pages/sandbox/List.tsx Normal file
View File

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

22
pages/sandbox/form.tsx Normal file
View File

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

73
pages/sandbox/index.tsx Normal file
View File

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

View File

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

View File

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

View File

@ -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(),