`/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" DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
GOOGLE_API_CREDENTIALS='secret' GOOGLE_API_CREDENTIALS='secret'
GOOGLE_REDIRECT_URL='https://localhost:3000/integrations/googlecalendar/callback'
BASE_URL='http://localhost:3000' BASE_URL='http://localhost:3000'
NEXT_PUBLIC_APP_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 * 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) { export function Dialog(props: DialogProps) {
const { children, ...other } = props; const { children, ...other } = props;
return ( 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"> <h3 className="font-cal text-gray-900 text-lg font-bold leading-6" id="modal-title">
{title} {title}
</h3> </h3>
<div> <div className="text-gray-400 text-sm">{subtitle}</div>
<p className="text-gray-400 text-sm">{subtitle}</p> </div>
</div> );
}
export function DialogFooter(props: { children: ReactNode }) {
return (
<div>
<div className="mt-5 flex space-x-2 justify-end">{props.children}</div>
</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 HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { shouldShowOnboarding } from "@lib/getting-started";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
@ -29,10 +30,7 @@ import Logo from "./Logo";
function useMeQuery() { function useMeQuery() {
const [session] = useSession(); const [session] = useSession();
const meQuery = trpc.useQuery(["viewer.me"], { const meQuery = trpc.useQuery(["viewer.me"]);
// refetch max once per 5s
staleTime: 5000,
});
useEffect(() => { useEffect(() => {
// refetch if sesion changes // refetch if sesion changes
@ -59,6 +57,26 @@ function useRedirectToLoginIfUnauthenticated() {
}, [loading, session, router]); }, [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: { export default function Shell(props: {
centered?: boolean; centered?: boolean;
title?: string; title?: string;
@ -74,6 +92,15 @@ export default function Shell(props: {
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const query = useMeQuery(); const query = useMeQuery();
useEffect(
function redirectToOnboardingIfNeeded() {
if (query.data && shouldShowOnboarding(query.data)) {
router.push("/getting-started");
}
},
[query.data, router]
);
const navigation = [ const navigation = [
{ {
name: "Event Types", name: "Event Types",
@ -209,7 +236,6 @@ export default function Shell(props: {
<div className="mb-4 flex-shrink-0">{props.CTA}</div> <div className="mb-4 flex-shrink-0">{props.CTA}</div>
</div> </div>
<div className="px-4 sm:px-6 md:px-8">{props.children}</div> <div className="px-4 sm:px-6 md:px-8">{props.children}</div>
{/* show bottom navigation for md and smaller (tablet and phones) */} {/* 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"> <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 */} {/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
@ -239,7 +265,6 @@ export default function Shell(props: {
) )
)} )}
</nav> </nav>
{/* add padding to content for mobile navigation*/} {/* add padding to content for mobile navigation*/}
<div className="block md:hidden pt-12" /> <div className="block md:hidden pt-12" />
</div> </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 { export interface AlertProps {
title?: ReactNode; title?: ReactNode;
message?: ReactNode; message?: ReactNode;
actions?: ReactNode;
className?: string; className?: string;
severity: "success" | "warning" | "error"; severity: "success" | "warning" | "error";
} }
@ -36,6 +37,7 @@ export function Alert(props: AlertProps) {
<h3 className="text-sm font-medium">{props.title}</h3> <h3 className="text-sm font-medium">{props.title}</h3>
<div className="text-sm">{props.message}</div> <div className="text-sm">{props.message}</div>
</div> </div>
{props.actions && <div className="text-sm">{props.actions}</div>}
</div> </div>
</div> </div>
); );

View File

@ -13,7 +13,7 @@ export const Badge = function Badge(props: BadgeProps) {
<span <span
{...passThroughProps} {...passThroughProps}
className={classNames( 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 === "default" && "bg-yellow-100 text-yellow-800",
variant === "success" && "bg-green-100 text-green-800", variant === "success" && "bg-green-100 text-green-800",
variant === "gray" && "bg-gray-200 text-gray-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 classNames from "@lib/classNames";
import { SVGComponent } from "@lib/types/SVGComponent"; import { SVGComponent } from "@lib/types/SVGComponent";
export type ButtonProps = { export type ButtonBaseProps = {
color?: "primary" | "secondary" | "minimal" | "warn"; color?: "primary" | "secondary" | "minimal" | "warn";
size?: "base" | "sm" | "lg" | "fab" | "icon"; size?: "base" | "sm" | "lg" | "fab" | "icon";
loading?: boolean; loading?: boolean;
@ -13,10 +13,12 @@ export type ButtonProps = {
StartIcon?: SVGComponent; StartIcon?: SVGComponent;
EndIcon?: SVGComponent; EndIcon?: SVGComponent;
shallow?: boolean; shallow?: boolean;
} & ( };
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] }) export type ButtonProps = ButtonBaseProps &
| (JSX.IntrinsicElements["button"] & { href?: never }) (
); | (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
| (JSX.IntrinsicElements["button"] & { href?: never })
);
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button( export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
props: ButtonProps, 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 Label from "@radix-ui/react-label";
import * as PrimitiveSwitch from "@radix-ui/react-switch"; import * as PrimitiveSwitch from "@radix-ui/react-switch";
import { useState } from "react"; import { useState } from "react";
@ -16,7 +17,7 @@ export default function Switch(props) {
} }
setChecked(change); setChecked(change);
}; };
const id = useId();
return ( return (
<div className="flex items-center h-[20px]"> <div className="flex items-center h-[20px]">
<PrimitiveSwitch.Root <PrimitiveSwitch.Root
@ -25,6 +26,7 @@ export default function Switch(props) {
onCheckedChange={onPrimitiveCheckedChange} onCheckedChange={onPrimitiveCheckedChange}
{...primitiveProps}> {...primitiveProps}>
<PrimitiveSwitch.Thumb <PrimitiveSwitch.Thumb
id={id}
className={classNames( className={classNames(
"bg-white w-[16px] h-[16px] block transition-transform", "bg-white w-[16px] h-[16px] block transition-transform",
checked ? "translate-x-[16px]" : "translate-x-0" checked ? "translate-x-[16px]" : "translate-x-0"
@ -32,7 +34,9 @@ export default function Switch(props) {
/> />
</PrimitiveSwitch.Root> </PrimitiveSwitch.Root>
{label && ( {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}
</Label.Root> </Label.Root>
)} )}

1
environment.d.ts vendored
View File

@ -3,6 +3,7 @@ declare namespace NodeJS {
readonly CALENDSO_ENCRYPTION_KEY: string | undefined; readonly CALENDSO_ENCRYPTION_KEY: string | undefined;
readonly DATABASE_URL: string | undefined; readonly DATABASE_URL: string | undefined;
readonly GOOGLE_API_CREDENTIALS: string | undefined; readonly GOOGLE_API_CREDENTIALS: string | undefined;
readonly GOOGLE_REDIRECT_URL: string | undefined;
readonly BASE_URL: string | undefined; readonly BASE_URL: string | undefined;
readonly NEXT_PUBLIC_BASE_URL: string | undefined; readonly NEXT_PUBLIC_BASE_URL: string | undefined;
readonly NEXT_PUBLIC_APP_URL: string | undefined; readonly NEXT_PUBLIC_APP_URL: string | undefined;

View File

@ -517,8 +517,26 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
}; };
}; };
// factory function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null {
const calendars = (withCredentials): CalendarApiAdapter[] => 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 withCredentials
.map((cred) => { .map((cred) => {
switch (cred.type) { switch (cred.type) {
@ -534,7 +552,7 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
return; // unknown credential, could be legacy? In any case, ignore return; // unknown credential, could be legacy? In any case, ignore
} }
}) })
.filter(Boolean); .flatMap((item) => (item ? [item as CalendarApiAdapter] : []));
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
Promise.all( Promise.all(
@ -543,6 +561,11 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda
return results.reduce((acc, availability) => acc.concat(availability), []); return results.reduce((acc, availability) => acc.concat(availability), []);
}); });
/**
*
* @param withCredentials
* @deprecated
*/
const listCalendars = (withCredentials) => const listCalendars = (withCredentials) =>
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) => Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null) 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({}); 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) => { .filter((calendar) => {
return calendar.components?.includes("VEVENT"); return calendar.components?.includes("VEVENT");
}) })
.map((calendar) => ({ .map((calendar, index) => ({
externalId: calendar.url, externalId: calendar.url,
name: calendar.displayName ?? "", name: calendar.displayName ?? "",
primary: false, // FIXME Find a better way to set the primary calendar
primary: index === 0,
integration: this.integrationName, integration: this.integrationName,
})); }));
} catch (reason) { } 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 = { type Props = {
onSubmit: () => void; onSubmit: () => void;
@ -6,6 +19,95 @@ type Props = {
export const ADD_APPLE_INTEGRATION_FORM_TITLE = "addAppleIntegration"; 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 AddAppleIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
const onSubmit = (event) => { const onSubmit = (event) => {
event.preventDefault(); event.preventDefault();

View File

@ -79,6 +79,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
const { error, value: iCalString } = await createEvent({ const { error, value: iCalString } = await createEvent({
uid, uid,
startInputType: "utc", startInputType: "utc",
// FIXME types
start: this.convertDate(event.startTime), start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime), duration: this.getDuration(event.startTime, event.endTime),
title: event.title, title: event.title,
@ -137,6 +138,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
const { error, value: iCalString } = await createEvent({ const { error, value: iCalString } = await createEvent({
uid, uid,
startInputType: "utc", startInputType: "utc",
// FIXME - types wrong
start: this.convertDate(event.startTime), start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime), duration: this.getDuration(event.startTime, event.endTime),
title: event.title, title: event.title,
@ -203,6 +205,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
} }
} }
// FIXME - types wrong
async getAvailability( async getAvailability(
dateFrom: string, dateFrom: string,
dateTo: string, dateTo: string,
@ -258,10 +261,11 @@ export class CalDavCalendar implements CalendarApiAdapter {
.filter((calendar) => { .filter((calendar) => {
return calendar.components?.includes("VEVENT"); return calendar.components?.includes("VEVENT");
}) })
.map((calendar) => ({ .map((calendar, index) => ({
externalId: calendar.url, externalId: calendar.url,
name: calendar.displayName ?? "", name: calendar.displayName ?? "",
primary: false, // FIXME Find a better way to set the primary calendar
primary: index === 0,
integration: this.integrationName, integration: this.integrationName,
})); }));
} catch (reason) { } 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 = { type Props = {
onSubmit: () => void; onSubmit: () => void;
@ -11,8 +24,93 @@ export type AddCalDavIntegrationRequest = {
password: string; 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 AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
const onSubmit = (event) => { const onSubmit = (event: any) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();

View File

@ -8,65 +8,75 @@ const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>; 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[]) { function getIntegrations(credentials: CredentialData[]) {
const integrations = [ const integrations = ALL_INTEGRATIONS.map((integration) => ({
{ ...integration,
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), /**
credential: credentials.find((integration) => integration.type === "google_calendar") || null, * @deprecated use `credentials.
type: "google_calendar", */
title: "Google Calendar", credential: credentials.find((credential) => credential.type === integration.type) || null,
imageSrc: "integrations/google-calendar.svg", credentials: credentials.filter((credential) => credential.type === integration.type) || null,
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",
},
];
return integrations; return integrations;
} }
export type IntegraionMeta = ReturnType<typeof getIntegrations>;
export function hasIntegration(integrations: ReturnType<typeof getIntegrations>, type: string): boolean { export function hasIntegration(integrations: ReturnType<typeof getIntegrations>, type: string): boolean {
return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential); 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req }); const session = await getSession({ req: req });
if (!session) { if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" }); res.status(401).json({ message: "Not authenticated" });
return; return;
} }
const currentUser = await prisma.user.findFirst({ const currentUser = await prisma.user.findUnique({
where: { where: {
id: session.user.id, 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") { if (req.method == "POST") {
await prisma.selectedCalendar.create({ await prisma.selectedCalendar.upsert({
data: { where: {
user: { userId_integration_externalId: {
connect: { userId: currentUser.id,
id: currentUser.id, integration: req.body.integration,
}, externalId: req.body.externalId,
}, },
},
create: {
userId: currentUser.id,
integration: req.body.integration, integration: req.body.integration,
externalId: req.body.externalId, externalId: req.body.externalId,
}, },
// already exists
update: {},
}); });
res.status(200).json({ message: "Calendar Selection Saved" }); 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 // Get token from Google Calendar API
const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web; 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({ const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline", access_type: "offline",

View File

@ -2,10 +2,9 @@ import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth"; 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query; const { code } = req.query;
@ -13,7 +12,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Check that user is authenticated // Check that user is authenticated
const session = await getSession({ req: req }); 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" }); res.status(401).json({ message: "You must be logged in to do this" });
return; return;
} }
@ -21,9 +20,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(400).json({ message: "`code` must be a string" }); res.status(400).json({ message: "`code` must be a string" });
return; 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 { 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 token = await oAuth2Client.getToken(code);
const key = token.res?.data; const key = token.res?.data;
await prisma.credential.create({ await prisma.credential.create({

View File

@ -16,7 +16,7 @@ import { NextPageContext } from "next";
import { useSession } from "next-auth/client"; import { useSession } from "next-auth/client";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Integration } from "pages/integrations"; import { Integration } from "pages/integrations/_new";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import TimezoneSelect from "react-timezone-select"; import TimezoneSelect from "react-timezone-select";

View File

@ -1,103 +1,9 @@
import { useSession } from "next-auth/client"; function RedirectPage() {
import { useRouter } from "next/router"; return null;
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>
);
} }
export async function getServerSideProps(context) { export async function getServerSideProps() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars return { redirect: { permanent: false, destination: "/integrations" } };
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 default RedirectPage;

View File

@ -1,510 +1,410 @@
import { InformationCircleIcon } from "@heroicons/react/outline"; import { Maybe } from "@trpc/server";
import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid"; import Image from "next/image";
import { GetServerSidePropsContext } from "next"; import { ReactNode, useEffect, useState } from "react";
import { useSession } from "next-auth/client"; import { useMutation } from "react-query";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { getSession } from "@lib/auth"; import { QueryCell } from "@lib/QueryCell";
import { ONBOARDING_NEXT_REDIRECT, shouldShowOnboarding } from "@lib/getting-started"; import classNames from "@lib/classNames";
import AddAppleIntegration, { import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
ADD_APPLE_INTEGRATION_FORM_TITLE, import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
} from "@lib/integrations/Apple/components/AddAppleIntegration"; import showToast from "@lib/notification";
import AddCalDavIntegration, { import { inferQueryOutput, trpc } from "@lib/trpc";
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 { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog"; import { Dialog } from "@components/Dialog";
import Loader from "@components/Loader"; import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
import Shell from "@components/Shell"; import Shell, { ShellSubHeading } from "@components/Shell";
import Button from "@components/ui/Button"; 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"; import Switch from "@components/ui/Switch";
export default function Home({ integrations }: inferSSRProps<typeof getServerSideProps>) { type IntegrationCalendar = inferQueryOutput<"viewer.integrations">["calendar"]["items"][number];
const [, loading] = useSession();
const [selectableCalendars, setSelectableCalendars] = useState([]); function pluralize(opts: { num: number; plural: string; singular: string }) {
const addCalDavIntegrationRef = useRef<HTMLFormElement>(null); if (opts.num === 0) {
const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false); return opts.singular;
const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null); }
return opts.singular;
}
const addAppleIntegrationRef = useRef<HTMLFormElement>(null); function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) {
const [isAddAppleIntegrationDialogOpen, setIsAddAppleIntegrationDialogOpen] = useState(false); const num = props.numConnections;
const [addAppleError, setAddAppleError] = useState<{ message: string } | null>(null); 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() { // refetch intergrations when modal closes
fetch("api/availability/calendar") const utils = trpc.useContext();
.then((response) => response.json()) useEffect(() => {
.then((data) => { utils.invalidateQueries(["viewer.integrations"]);
setSelectableCalendars(data); }, [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",
},
}); });
} if (!res.ok) {
throw new Error("Something went wrong");
function integrationHandler(type) { }
if (type === "caldav_calendar") { },
setAddCalDavError(null); {
setIsAddCalDavIntegrationDialogOpen(true); async onSettled() {
return; await utils.invalidateQueries(["viewer.integrations"]);
}
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",
}, },
}); onSuccess() {
}; setModalOpen(false);
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",
}, },
}); }
}; );
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) { function ConnectOrDisconnectIntegrationButton(props: {
return (selected) => { //
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId); credential: Maybe<{ id: number }>;
selectableCalendars[i].selected = selected; type: IntegrationCalendar["type"];
if (selected) { installed: boolean;
fetch("api/availability/calendar", { }) {
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", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(selectableCalendars[i]), body: JSON.stringify(body),
}).then((response) => response.json()); });
if (!res.ok) {
throw new Error("Something went wrong");
}
} else { } else {
fetch("api/availability/calendar", { const res = await fetch("/api/availability/calendar", {
method: "DELETE", method: "DELETE",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(selectableCalendars[i]), body: JSON.stringify(body),
}).then((response) => response.json()); });
if (!res.ok) {
throw new Error("Something went wrong");
}
} }
}; },
} {
async onSettled() {
function getCalendarIntegrationImage(integrationType: string) { await utils.invalidateQueries(["viewer.integrations"]);
switch (integrationType) { },
case "google_calendar": onError() {
return "integrations/google-calendar.svg"; showToast(`Something went wrong when toggling "${props.title}""`, "error");
case "office365_calendar": },
return "integrations/outlook.svg";
case "caldav_calendar":
return "integrations/caldav.svg";
case "apple_calendar":
return "integrations/apple-calendar.svg";
default:
return "";
} }
}
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>
); );
return (
const SelectCalendarDialog = () => ( <Switch
<Dialog onOpenChange={(open) => !open && onCloseSelectCalendar()}> key={props.externalId}
<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"> name="enabled"
Select calendars label={props.title}
</DialogTrigger> defaultChecked={props.defaultSelected}
onCheckedChange={(isOn: boolean) => {
<DialogContent> mutation.mutate({ isOn });
<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>
); );
}
const handleAddCalDavIntegrationSaveButtonPress = async () => { export default function IntegrationsPage() {
const form = addCalDavIntegrationRef.current.elements; const query = trpc.useQuery(["viewer.integrations"]);
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 />;
}
return ( return (
<div> <Shell heading="Integrations" subtitle="Connect your favourite apps.">
<Shell heading="Integrations" subtitle="Connect your favourite apps." CTA={<ConnectNewAppDialog />}> <QueryCell
<div className="mb-8 overflow-hidden bg-white border border-gray-200 rounded-sm"> query={query}
{integrations.filter((ig) => ig.credential).length !== 0 ? ( success={({ data }) => {
<ul className="divide-y divide-gray-200"> return (
{integrations <>
.filter((ig) => ig.credential) <ShellSubHeading
.map((ig) => ( title={
<li key={ig.credential.id}> <SubHeadingTitleWithConnections
<Link href={"/integrations/" + ig.credential.id}> title="Conferencing"
<a className="block hover:bg-gray-50"> numConnections={data.conferencing.numActive}
<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} /> <List>
</div> {data.conferencing.items.map((item) => (
<div className="flex-1 min-w-0 px-4 md:grid md:grid-cols-2 md:gap-4"> <IntegrationListItem
<div> key={item.title}
<p className="text-sm font-medium truncate text-neutral-900">{ig.title}</p> {...item}
<p className="flex items-center text-sm text-gray-500"> actions={<ConnectOrDisconnectIntegrationButton {...item} />}
{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>
))} ))}
</ul> </List>
) : (
<div className="bg-white rounded-sm shadow"> <ShellSubHeading
<div className="flex"> className="mt-6"
<div className="pl-8 py-9"> title={
<InformationCircleIcon className="w-16 text-neutral-900" /> <SubHeadingTitleWithConnections title="Payment" numConnections={data.payment.numActive} />
</div> }
<div className="py-5 sm:p-6"> />
<h3 className="text-lg font-medium leading-6 text-gray-900"> <List>
You don&apos;t have any apps connected. {data.payment.items.map((item) => (
</h3> <IntegrationListItem
<div className="mt-2 text-sm text-gray-500"> key={item.title}
<p> {...item}
You currently do not have any apps connected. Connect your first app to get started. actions={<ConnectOrDisconnectIntegrationButton {...item} />}
</p> />
</div> ))}
<ConnectNewAppDialog /> </List>
</div>
</div> <ShellSubHeading
</div> className="mt-6"
)} title={
</div> <SubHeadingTitleWithConnections
<div className="mb-8 bg-white border border-gray-200 rounded-sm"> title="Calendars"
<div className="px-4 py-5 sm:p-6"> numConnections={data.calendar.numActive}
<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> subtitle={
</div> <>
<SelectCalendarDialog /> Configure how your links integrate with your calendars.
</div> <br />
</div> You can override these settings on a per event basis.
<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> {data.connectedCalendars.length > 0 && (
</div> <>
<div className="mt-5"> <List>
<a href="mailto:apps@cal.com" className="btn btn-white"> {data.connectedCalendars.map((item, index) => (
Contact us <li key={index}>
</a> {item.calendars ? (
</div> <IntegrationListItem
</div> {...item.integration}
</div> description={item.primary.externalId}
<ConnectCalDavServerDialog /> actions={
<ConnectAppleServerDialog /> <DisconnectIntegration
</Shell> id={item.credentialId}
</div> 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 React from "react";
import { Alert, AlertProps } from "@components/ui/Alert"; import { Alert, AlertProps } from "@components/ui/Alert";
export default function AlertPage() { import { sandboxPage } from ".";
const page = sandboxPage(function AlertPage() {
const list: AlertProps[] = [ const list: AlertProps[] = [
{ title: "Something went wrong", severity: "error" }, { title: "Something went wrong", severity: "error" },
{ title: "Something went kinda wrong", severity: "warning" }, { title: "Something went kinda wrong", severity: "warning" },
@ -23,9 +24,6 @@ export default function AlertPage() {
]; ];
return ( return (
<> <>
<Head>
<meta name="googlebot" content="noindex" />
</Head>
<div className="p-4 bg-gray-200"> <div className="p-4 bg-gray-200">
<h1>Alert component</h1> <h1>Alert component</h1>
<div className="flex flex-col"> <div className="flex flex-col">
@ -52,4 +50,7 @@ export default function AlertPage() {
</div> </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 { PlusIcon } from "@heroicons/react/solid";
import Head from "next/head";
import React from "react"; import React from "react";
import { Button, ButtonProps } from "@components/ui/Button"; import { Button, ButtonBaseProps } from "@components/ui/Button";
export default function ButtonPage() { import { sandboxPage } from ".";
const list: ButtonProps[] = [
const page = sandboxPage(function ButtonPage() {
const list: ButtonBaseProps[] = [
// primary // primary
{ color: "primary" }, { color: "primary" },
{ color: "primary", disabled: true }, { color: "primary", disabled: true },
@ -26,18 +27,15 @@ export default function ButtonPage() {
{ color: "primary", size: "base" }, { color: "primary", size: "base" },
{ color: "primary", size: "lg" }, { color: "primary", size: "lg" },
// href // // href
{ href: "/staging" }, // { href: "/staging" },
{ href: "/staging", disabled: true }, // { href: "/staging", disabled: true },
{ StartIcon: PlusIcon }, { StartIcon: PlusIcon },
{ EndIcon: PlusIcon }, { EndIcon: PlusIcon },
]; ];
return ( return (
<> <>
<Head>
<meta name="googlebot" content="noindex" />
</Head>
<div className="p-4 bg-gray-200"> <div className="p-4 bg-gray-200">
<h1>Button component</h1> <h1>Button component</h1>
<div className="flex flex-col"> <div className="flex flex-col">
@ -57,11 +55,14 @@ export default function ButtonPage() {
)} )}
</code> </code>
</h3> </h3>
<Button {...props}>Button text</Button> <Button {...(props as any)}>Button text</Button>
</div> </div>
))} ))}
</div> </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(); const prisma = new PrismaClient();
async function createUserAndEventType(opts: { 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< eventTypes: Array<
Prisma.EventTypeCreateInput & { Prisma.EventTypeCreateInput & {
_bookings?: Prisma.BookingCreateInput[]; _bookings?: Prisma.BookingCreateInput[];
@ -18,7 +25,7 @@ async function createUserAndEventType(opts: {
...opts.user, ...opts.user,
password: await hashPassword(opts.user.password), password: await hashPassword(opts.user.password),
emailVerified: new Date(), emailVerified: new Date(),
completedOnboarding: true, completedOnboarding: opts.user.completedOnboarding ?? true,
}; };
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: opts.user.email }, where: { email: opts.user.email },
@ -97,24 +104,14 @@ async function createUserAndEventType(opts: {
async function main() { async function main() {
await createUserAndEventType({ await createUserAndEventType({
user: { user: {
email: "free@example.com", email: "onboarding@example.com",
password: "free", password: "onboarding",
username: "free", username: "onboarding",
name: "Free Example", name: "onboarding",
plan: "FREE", plan: "TRIAL",
completedOnboarding: false,
}, },
eventTypes: [ eventTypes: [],
{
title: "30min",
slug: "30min",
length: 30,
},
{
title: "60min",
slug: "60min",
length: 30,
},
],
}); });
await createUserAndEventType({ 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(); await prisma.$disconnect();
} }

View File

@ -32,6 +32,20 @@ async function getUserFromSession({ session, req }: { session: Maybe<Session>; r
createdDate: true, createdDate: true,
hideBranding: true, hideBranding: true,
avatar: true, avatar: true,
credentials: {
select: {
id: true,
type: true,
key: true,
},
},
selectedCalendars: {
select: {
externalId: true,
integration: true,
},
},
completedOnboarding: true,
locale: 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 { TRPCError } from "@trpc/server";
import { getErrorFromUnknown } from "pages/_error";
import { z } from "zod"; import { z } from "zod";
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername"; import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
import { checkRegularUsername } from "@lib/core/checkRegularUsername"; import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import getIntegrations, { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
import slugify from "@lib/slugify"; import slugify from "@lib/slugify";
import { getCalendarAdapterOrNull } from "../../lib/calendarClient";
import { createProtectedRouter } from "../createRouter"; import { createProtectedRouter } from "../createRouter";
import { resizeBase64Image } from "../lib/resizeBase64Image"; import { resizeBase64Image } from "../lib/resizeBase64Image";
@ -91,6 +94,88 @@ export const viewerRouter = createProtectedRouter()
return bookings; 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", { .mutation("updateProfile", {
input: z.object({ input: z.object({
username: z.string().optional(), username: z.string().optional(),