2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

0
calcom/packages/app-store/.gitignore vendored Normal file
View File

View File

@ -0,0 +1 @@
*.generated.*

View File

@ -0,0 +1,207 @@
import { render, screen, cleanup } from "@testing-library/react";
import { vi } from "vitest";
import BookingPageTagManager, { handleEvent } from "./BookingPageTagManager";
// NOTE: We don't intentionally mock appStoreMetadata as that also tests config.json and generated files for us for no cost. If it becomes a pain in future, we could just start mocking it.
vi.mock("next/script", () => {
return {
default: ({ ...props }) => {
return <div {...props} />;
},
};
});
const windowProps: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setOnWindow(prop: any, value: any) {
window[prop] = value;
windowProps.push(prop);
}
afterEach(() => {
windowProps.forEach((prop) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
delete window[prop];
});
windowProps.splice(0);
cleanup();
});
describe("BookingPageTagManager", () => {
it("GTM App when enabled should have its scripts added with appropriate trackingID and $pushEvent replacement", () => {
const GTM_CONFIG = {
enabled: true,
trackingId: "GTM-123",
};
render(
<BookingPageTagManager
eventType={{
metadata: {
apps: {
gtm: GTM_CONFIG,
},
},
price: 0,
currency: "USD",
}}
/>
);
const scripts = screen.getAllByTestId("cal-analytics-app-gtm");
const trackingScript = scripts[0];
const pushEventScript = scripts[1];
expect(trackingScript.innerHTML).toContain(GTM_CONFIG.trackingId);
expect(pushEventScript.innerHTML).toContain("cal_analytics_app__gtm");
});
it("GTM App when disabled should not have its scripts added", () => {
const GTM_CONFIG = {
enabled: false,
trackingId: "GTM-123",
};
render(
<BookingPageTagManager
eventType={{
metadata: {
apps: {
gtm: GTM_CONFIG,
},
},
price: 0,
currency: "USD",
}}
/>
);
const scripts = screen.queryAllByTestId("cal-analytics-app-gtm");
expect(scripts.length).toBe(0);
});
it("should not add scripts for an app that doesnt have tag defined(i.e. non-analytics app)", () => {
render(
<BookingPageTagManager
eventType={{
metadata: {
apps: {
zoomvideo: {
enabled: true,
},
},
},
price: 0,
currency: "USD",
}}
/>
);
const scripts = screen.queryAllByTestId("cal-analytics-app-zoomvideo");
expect(scripts.length).toBe(0);
});
it("should not crash for an app that doesnt exist", () => {
render(
<BookingPageTagManager
eventType={{
metadata: {
apps: {
//@ts-expect-error Testing for non-existent app
nonexistentapp: {
enabled: true,
},
},
},
price: 0,
currency: "USD",
}}
/>
);
const scripts = screen.queryAllByTestId("cal-analytics-app-zoomvideo");
expect(scripts.length).toBe(0);
});
});
describe("handleEvent", () => {
it("should not push internal events to analytics apps", () => {
expect(
handleEvent({
detail: {
// Internal event
type: "__abc",
},
})
).toBe(false);
expect(
handleEvent({
detail: {
// Not an internal event
type: "_abc",
},
})
).toBe(true);
});
it("should call the function on window with the event name and data", () => {
const pushEventXyz = vi.fn();
const pushEventAnything = vi.fn();
const pushEventRandom = vi.fn();
const pushEventNotme = vi.fn();
setOnWindow("cal_analytics_app__xyz", pushEventXyz);
setOnWindow("cal_analytics_app__anything", pushEventAnything);
setOnWindow("cal_analytics_app_random", pushEventRandom);
setOnWindow("cal_analytics_notme", pushEventNotme);
handleEvent({
detail: {
type: "abc",
key: "value",
},
});
expect(pushEventXyz).toHaveBeenCalledWith({
name: "abc",
data: {
key: "value",
},
});
expect(pushEventAnything).toHaveBeenCalledWith({
name: "abc",
data: {
key: "value",
},
});
expect(pushEventRandom).toHaveBeenCalledWith({
name: "abc",
data: {
key: "value",
},
});
expect(pushEventNotme).not.toHaveBeenCalled();
});
it("should not error if accidentally the value is not a function", () => {
const pushEventNotAfunction = "abc";
const pushEventAnything = vi.fn();
setOnWindow("cal_analytics_app__notafun", pushEventNotAfunction);
setOnWindow("cal_analytics_app__anything", pushEventAnything);
handleEvent({
detail: {
type: "abc",
key: "value",
},
});
// No error for cal_analytics_app__notafun and pushEventAnything is called
expect(pushEventAnything).toHaveBeenCalledWith({
name: "abc",
data: {
key: "value",
},
});
});
});

View File

@ -0,0 +1,147 @@
import Script from "next/script";
import { getEventTypeAppData } from "@calcom/app-store/_utils/getEventTypeAppData";
import { appStoreMetadata } from "@calcom/app-store/bookerAppsMetaData";
import type { Tag } from "@calcom/app-store/types";
import { sdkActionManager } from "@calcom/lib/sdk-event";
import type { AppMeta } from "@calcom/types/App";
import type { appDataSchemas } from "./apps.schemas.generated";
const PushEventPrefix = "cal_analytics_app_";
// AnalyticApp has appData.tag always set
type AnalyticApp = Omit<AppMeta, "appData"> & {
appData: Omit<NonNullable<AppMeta["appData"]>, "tag"> & {
tag: NonNullable<NonNullable<AppMeta["appData"]>["tag"]>;
};
};
const getPushEventScript = ({ tag, appId }: { tag: Tag; appId: string }) => {
if (!tag.pushEventScript) {
return tag.pushEventScript;
}
return {
...tag.pushEventScript,
// In case of complex pushEvent implementations, we could think about exporting a pushEvent function from the analytics app maybe but for now this should suffice
content: tag.pushEventScript?.content?.replace("$pushEvent", `${PushEventPrefix}_${appId}`),
};
};
function getAnalyticsApps(eventType: Parameters<typeof getEventTypeAppData>[0]) {
return Object.entries(appStoreMetadata).reduce(
(acc, entry) => {
const [appId, app] = entry;
const eventTypeAppData = getEventTypeAppData(eventType, appId as keyof typeof appDataSchemas);
if (!eventTypeAppData || !app.appData?.tag) {
return acc;
}
acc[appId] = {
meta: app as AnalyticApp,
eventTypeAppData: eventTypeAppData,
};
return acc;
},
{} as Record<
string,
{
meta: AnalyticApp;
eventTypeAppData: ReturnType<typeof getEventTypeAppData>;
}
>
);
}
export function handleEvent(event: { detail: Record<string, unknown> & { type: string } }) {
const { type: name, ...data } = event.detail;
// Don't push internal events to analytics apps
// They are meant for internal use like helping embed make some decisions
if (name.startsWith("__")) {
return false;
}
Object.entries(window).forEach(([prop, value]) => {
if (!prop.startsWith(PushEventPrefix) || typeof value !== "function") {
return;
}
// Find the pushEvent if defined by the analytics app
const pushEvent = window[prop as keyof typeof window];
pushEvent({
name,
data,
});
});
return true;
}
export default function BookingPageTagManager({
eventType,
}: {
eventType: Parameters<typeof getEventTypeAppData>[0];
}) {
const analyticsApps = getAnalyticsApps(eventType);
return (
<>
{Object.entries(analyticsApps).map(([appId, { meta: app, eventTypeAppData }]) => {
const tag = app.appData.tag;
const parseValue = <T extends string | undefined>(val: T): T => {
if (!val) {
return val;
}
// Only support UpperCase,_and numbers in template variables. This prevents accidental replacement of other strings.
const regex = /\{([A-Z_\d]+)\}/g;
let matches;
while ((matches = regex.exec(val))) {
const variableName = matches[1];
if (eventTypeAppData[variableName]) {
// Replace if value is available. It can possible not be a template variable that just matches the regex.
val = val.replace(
new RegExp(`{${variableName}}`, "g"),
eventTypeAppData[variableName]
) as NonNullable<T>;
}
}
return val;
};
const pushEventScript = getPushEventScript({ tag, appId });
return tag.scripts.concat(pushEventScript ? [pushEventScript] : []).map((script, index) => {
const parsedAttributes: NonNullable<(typeof tag.scripts)[number]["attrs"]> = {};
const attrs = script.attrs || {};
Object.entries(attrs).forEach(([name, value]) => {
if (typeof value === "string") {
value = parseValue(value);
}
parsedAttributes[name] = value;
});
return (
<Script
data-testid={`cal-analytics-app-${appId}`}
src={parseValue(script.src)}
id={`${appId}-${index}`}
key={`${appId}-${index}`}
dangerouslySetInnerHTML={{
__html: parseValue(script.content) || "",
}}
{...parsedAttributes}
defer
/>
);
});
})}
</>
);
}
if (typeof window !== "undefined") {
// Attach listener outside React as it has to be attached only once per page load
// Setup listener for all events to push to analytics apps
sdkActionManager?.on("*", handleEvent);
}

View File

@ -0,0 +1,25 @@
## App Contribution Guidelines
#### `DESCRIPTION.md`
1. images - include atleast 4 images (do we have a recommended size here?). Can show app in use and/or installation steps
2. add only file name for images, path not required. i.e. `1.jpeg`, not `/app-store/zohocalendar/1.jpeg`
3. description should include what the integration with Cal allows the user to do e.g. `Allows you to sync Cal bookings with your Zoho Calendar`
#### `README.md`
1. Include installation instructions and links to the app's website.
2. For url use `<baseUrl>/api/integrations`, rather than `<Cal.com>/api/integrations`
#### `config.json`
1. set `"logo": "icon.svg"` and save icon in `/static`. Don't use paths here.
2. description here should not exceed 10 words (this is arbitrary, but should not be long otherwise it's truncated in the app store)
#### Others
1. Add API documentation links in comments for files `api`, `lib` and `types`
2. Use [`AppDeclarativeHandler`](../types/AppHandler.d.ts) across all apps. Whatever isn't supported in it, support that.
3. README should be added in the respective app and can be linked in main README [like this](https://github.com/calcom/cal.com/pull/10429/files/155ac84537d12026f595551fe3542e810b029714#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R509)
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin. In local development you can open /settings/admin with the admin credentials (see [seed.ts](packages/prisma/seed.ts))

View File

@ -0,0 +1,46 @@
import React from "react";
import type { z, ZodType } from "zod";
export type GetAppData = (key: string) => unknown;
export type SetAppData = (key: string, value: unknown) => void;
type LockedIcon = JSX.Element | false | undefined;
type Disabled = boolean | undefined;
type AppContext = {
getAppData: GetAppData;
setAppData: SetAppData;
LockedIcon?: LockedIcon;
disabled?: Disabled;
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const EventTypeAppContext = React.createContext<AppContext>({
getAppData: () => ({}),
setAppData: () => ({}),
});
type SetAppDataGeneric<TAppData extends ZodType> = <
TKey extends keyof z.infer<TAppData>,
TValue extends z.infer<TAppData>[TKey]
>(
key: TKey,
value: TValue
) => void;
type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
key: TKey
) => z.infer<TAppData>[TKey];
export const useAppContextWithSchema = <TAppData extends ZodType>() => {
type GetAppData = GetAppDataGeneric<TAppData>;
type SetAppData = SetAppDataGeneric<TAppData>;
// TODO: Not able to do it without type assertion here
const context = React.useContext(EventTypeAppContext) as {
getAppData: GetAppData;
setAppData: SetAppData;
LockedIcon: LockedIcon;
disabled: Disabled;
};
return context;
};
export default EventTypeAppContext;

View File

@ -0,0 +1,128 @@
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { getAppFromSlug } from "@calcom/app-store/utils";
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
export type TDependencyData = {
name?: string;
installed?: boolean;
}[];
/**
* Get App metdata either using dirName or slug
*/
export async function getAppWithMetadata(app: { dirName: string } | { slug: string }) {
let appMetadata: App | null;
if ("dirName" in app) {
appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata] as App;
} else {
const foundEntry = Object.entries(appStoreMetadata).find(([, meta]) => {
return meta.slug === app.slug;
});
if (!foundEntry) return null;
appMetadata = foundEntry[1] as App;
}
if (!appMetadata) return null;
// Let's not leak api keys to the front end
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, ...metadata } = appMetadata;
return metadata;
}
/** Mainly to use in listings for the frontend, use in getStaticProps or getServerSideProps */
export async function getAppRegistry() {
const dbApps = await prisma.app.findMany({
where: { enabled: true },
select: { dirName: true, slug: true, categories: true, enabled: true, createdAt: true },
});
const apps = [] as App[];
const installCountPerApp = await getInstallCountPerApp();
for await (const dbapp of dbApps) {
const app = await getAppWithMetadata(dbapp);
if (!app) continue;
// Skip if app isn't installed
/* This is now handled from the DB */
// if (!app.installed) return apps;
app.createdAt = dbapp.createdAt.toISOString();
apps.push({
...app,
category: app.category || "other",
installed:
true /* All apps from DB are considered installed by default. @TODO: Add and filter our by `enabled` property */,
installCount: installCountPerApp[dbapp.slug] || 0,
});
}
return apps;
}
export async function getAppRegistryWithCredentials(userId: number, userAdminTeams: UserAdminTeams = []) {
// Get teamIds to grab existing credentials
const dbApps = await prisma.app.findMany({
where: { enabled: true },
select: {
...safeAppSelect,
credentials: {
where: { OR: [{ userId }, { teamId: { in: userAdminTeams } }] },
select: safeCredentialSelect,
},
},
orderBy: {
credentials: {
_count: "desc",
},
},
});
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
metadata: true,
},
});
const usersDefaultApp = userMetadata.parse(user?.metadata)?.defaultConferencingApp?.appSlug;
const apps = [] as (App & {
credentials: Credential[];
isDefault?: boolean;
})[];
const installCountPerApp = await getInstallCountPerApp();
for await (const dbapp of dbApps) {
const app = await getAppWithMetadata(dbapp);
if (!app) continue;
// Skip if app isn't installed
/* This is now handled from the DB */
// if (!app.installed) return apps;
app.createdAt = dbapp.createdAt.toISOString();
let dependencyData: TDependencyData = [];
if (app.dependencies) {
dependencyData = app.dependencies.map((dependency) => {
const dependencyInstalled = dbApps.some(
(dbAppIterator) => dbAppIterator.credentials.length && dbAppIterator.slug === dependency
);
// If the app marked as dependency is simply deleted from the codebase, we can have the situation where App is marked installed in DB but we couldn't get the app.
const dependencyName = getAppFromSlug(dependency)?.name;
return { name: dependencyName, installed: dependencyInstalled };
});
}
apps.push({
...app,
categories: dbapp.categories,
credentials: dbapp.credentials,
installed: true,
installCount: installCountPerApp[dbapp.slug] || 0,
isDefault: usersDefaultApp === dbapp.slug,
...(app.dependencies && { dependencyData }),
});
}
return apps;
}

View File

@ -0,0 +1,151 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import { classNames } from "@calcom/lib";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Switch, Badge, Avatar, Button, Icon } from "@calcom/ui";
import type { CredentialOwner } from "../types";
import OmniInstallAppButton from "./OmniInstallAppButton";
export default function AppCard({
app,
description,
switchOnClick,
switchChecked,
children,
returnTo,
teamId,
disableSwitch,
switchTooltip,
hideSettingsIcon = false,
hideAppCardOptions = false,
}: {
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
description?: React.ReactNode;
switchChecked?: boolean;
switchOnClick?: (e: boolean) => void;
children?: React.ReactNode;
returnTo?: string;
teamId?: number;
LockedIcon?: React.ReactNode;
disableSwitch?: boolean;
switchTooltip?: string;
hideSettingsIcon?: boolean;
hideAppCardOptions?: boolean;
}) {
const { t } = useTranslation();
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const { setAppData, LockedIcon, disabled: managedDisabled } = useAppContextWithSchema();
return (
<div
className={classNames(
"border-subtle",
app?.isInstalled && "mb-4 rounded-md border",
!app.enabled && "grayscale"
)}>
<div className={classNames(app.isInstalled ? "p-4 text-sm sm:p-4" : "px-5 py-4 text-sm sm:px-5")}>
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
<Link
href={`/apps/${app.slug}`}
className={classNames(app?.isInstalled ? "mr-[11px]" : "mr-3", "h-auto w-10 rounded-sm")}>
<img
className={classNames(
app?.logo.includes("-dark") && "dark:invert",
app?.isInstalled ? "min-w-[42px]" : "min-w-[40px]",
"w-full"
)}
src={app?.logo}
alt={app?.name}
/>
</Link>
<div className="flex flex-col pe-3">
<div className="text-emphasis">
<span className={classNames(app?.isInstalled && "text-base", "font-semibold leading-4")}>
{app?.name}
</span>
{!app?.isInstalled && (
<span className="bg-emphasis ml-1 rounded px-1 py-0.5 text-xs font-medium leading-3 tracking-[0.01em]">
{app?.categories[0].charAt(0).toUpperCase() + app?.categories[0].slice(1)}
</span>
)}
</div>
<p title={app?.description} className="text-default line-clamp-1 pt-1 text-sm font-normal">
{description || app?.description}
</p>
</div>
<div className="ml-auto flex items-center space-x-2">
{app.credentialOwner && (
<div className="ml-auto">
<Badge variant="gray">
<div className="flex items-center">
<Avatar
className="mr-2"
alt={app.credentialOwner.name || "Credential Owner Name"}
size="sm"
imageSrc={app.credentialOwner.avatar}
/>
{app.credentialOwner.name}
</div>
</Badge>
</div>
)}
{app?.isInstalled || app.credentialOwner ? (
<div className="ml-auto flex items-center">
<Switch
disabled={!app.enabled || managedDisabled || disableSwitch}
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);
}
setAppData("enabled", enabled);
}}
checked={switchChecked}
LockedIcon={LockedIcon}
data-testid={`${app.slug}-app-switch`}
tooltip={switchTooltip}
/>
</div>
) : (
<OmniInstallAppButton
className="ml-auto flex items-center"
appId={app.slug}
returnTo={returnTo}
teamId={teamId}
/>
)}
</div>
</div>
</div>
{hideAppCardOptions ? null : (
<div ref={animationRef}>
{app?.isInstalled && switchChecked && <hr className="border-subtle" />}
{app?.isInstalled && switchChecked ? (
app.isSetupAlready === undefined || app.isSetupAlready ? (
<div className="relative p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">
{!hideSettingsIcon && (
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
<Icon name="settings" className="text-default h-4 w-4" aria-hidden="true" />
</Link>
)}
{children}
</div>
) : (
<div className="flex h-64 w-full flex-col items-center justify-center gap-4 ">
<p>{t("this_app_is_not_setup_already")}</p>
<Link href={`/apps/${app.slug}/setup`}>
<Button StartIcon="settings">{t("setup")}</Button>
</Link>
</div>
)
) : null}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,48 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useMemo } from "react";
import { classNames as cs } from "@calcom/lib";
import { HorizontalTabs, VerticalTabs } from "@calcom/ui";
import getAppCategories from "../_utils/getAppCategories";
const AppCategoryNavigation = ({
baseURL,
children,
containerClassname,
className,
classNames,
useQueryParam = false,
}: {
baseURL: string;
children: React.ReactNode;
/** @deprecated use classNames instead */
containerClassname?: string;
/** @deprecated use classNames instead */
className?: string;
classNames?: {
root?: string;
container?: string;
verticalTabsItem?: string;
};
useQueryParam?: boolean;
}) => {
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const appCategories = useMemo(() => getAppCategories(baseURL, useQueryParam), [baseURL, useQueryParam]);
return (
<div className={cs("flex flex-col gap-x-6 md:p-0 xl:flex-row", classNames?.root ?? className)}>
<div className="hidden xl:block">
<VerticalTabs tabs={appCategories} sticky linkShallow itemClassname={classNames?.verticalTabsItem} />
</div>
<div className="block overflow-x-scroll xl:hidden">
<HorizontalTabs tabs={appCategories} linkShallow />
</div>
<main className={classNames?.container ?? containerClassname} ref={animationRef}>
{children}
</main>
</div>
);
};
export default AppCategoryNavigation;

View File

@ -0,0 +1,19 @@
import dynamic from "next/dynamic";
export const ConfigAppMap = {
vital: dynamic(() => import("../vital/components/AppConfiguration")),
};
export const AppConfiguration = (props: { type: string } & { credentialIds: number[] }) => {
let appName = props.type.replace(/_/g, "");
let ConfigAppComponent = ConfigAppMap[appName as keyof typeof ConfigAppMap];
/** So we can either call it by simple name (ex. `slack`, `giphy`) instead of
* `slackmessaging`, `giphyother` while maintaining retro-compatibility. */
if (!ConfigAppComponent) {
[appName] = props.type.split("_");
ConfigAppComponent = ConfigAppMap[appName as keyof typeof ConfigAppMap];
}
if (!ConfigAppComponent) return null;
return <ConfigAppComponent credentialIds={props.credentialIds} />;
};

View File

@ -0,0 +1,13 @@
import { AppSettingsComponentsMap } from "@calcom/app-store/apps.browser.generated";
import { DynamicComponent } from "./DynamicComponent";
export const AppSettings = (props: { slug: string }) => {
return (
<DynamicComponent<typeof AppSettingsComponentsMap>
wrapperClassName="border-t border-subtle p-6"
componentMap={AppSettingsComponentsMap}
{...props}
/>
);
};

View File

@ -0,0 +1,20 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function DynamicComponent<T extends Record<string, React.ComponentType<any>>>(props: {
componentMap: T;
slug: string;
wrapperClassName?: string;
}) {
const { componentMap, slug, wrapperClassName, ...rest } = props;
const dirName = slug === "stripe" ? "stripepayment" : slug;
// There can be apps with no matching component
if (!componentMap[dirName]) return null;
const Component = componentMap[dirName];
return (
<div className={wrapperClassName || ""}>
<Component {...rest} />
</div>
);
}

View File

@ -0,0 +1,35 @@
import type z from "zod";
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import EventTypeAppContext from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { ErrorBoundary } from "@calcom/ui";
import type { EventTypeAppCardComponentProps, CredentialOwner } from "../types";
import { DynamicComponent } from "./DynamicComponent";
export const EventTypeAppCard = (props: {
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
eventType: EventTypeAppCardComponentProps["eventType"];
getAppData: GetAppData;
setAppData: SetAppData;
// For event type apps, get these props from shouldLockDisableProps
LockedIcon?: JSX.Element | false;
eventTypeFormMetadata: z.infer<typeof EventTypeMetaDataSchema>;
disabled?: boolean;
}) => {
const { app, getAppData, setAppData, LockedIcon, disabled } = props;
return (
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={{ getAppData, setAppData, LockedIcon, disabled }}>
<DynamicComponent
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
componentMap={EventTypeAddonMap}
{...props}
/>
</EventTypeAppContext.Provider>
</ErrorBoundary>
);
};

View File

@ -0,0 +1,9 @@
import { EventTypeSettingsMap } from "@calcom/app-store/apps.browser.generated";
import type { EventTypeAppSettingsComponentProps } from "../types";
import { DynamicComponent } from "./DynamicComponent";
export const EventTypeAppSettings = (props: EventTypeAppSettingsComponentProps) => {
const { slug, ...rest } = props;
return <DynamicComponent slug={slug} componentMap={EventTypeSettingsMap} {...rest} />;
};

View File

@ -0,0 +1,83 @@
import { classNames } from "@calcom/lib";
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast } from "@calcom/ui";
import useAddAppMutation from "../_utils/useAddAppMutation";
import { InstallAppButton } from "../components";
/**
* Use this component to allow installing an app from anywhere on the app.
* Use of this component requires you to remove custom InstallAppButtonComponent so that it can manage the redirection itself
*/
export default function OmniInstallAppButton({
appId,
className,
returnTo,
teamId,
}: {
appId: string;
className: string;
returnTo?: string;
teamId?: number;
}) {
const { t } = useLocale();
const { data: app } = useApp(appId);
const utils = trpc.useUtils();
const mutation = useAddAppMutation(null, {
returnTo,
onSuccess: (data) => {
//TODO: viewer.appById might be replaced with viewer.apps so that a single query needs to be invalidated.
utils.viewer.appById.invalidate({ appId });
utils.viewer.integrations.invalidate({
extendsFeature: "EventType",
...(teamId && { teamId }),
});
if (data?.setupPending) return;
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
if (!app) {
return null;
}
return (
<InstallAppButton
type={app.type}
teamsPlanRequired={app.teamsPlanRequired}
wrapperClassName={classNames("[@media(max-width:260px)]:w-full", className)}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
onClick: () => {
mutation.mutate({
type: app.type,
variant: app.variant,
slug: app.slug,
...(teamId && { teamId }),
});
},
};
}
return (
<Button
loading={mutation.isPending}
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon="plus"
{...props}>
{t("add")}
</Button>
);
}}
/>
);
}

View File

@ -0,0 +1,97 @@
import { render, screen } from "@testing-library/react";
import type { CredentialOwner } from "types";
import { vi } from "vitest";
import type { RouterOutputs } from "@calcom/trpc";
import { DynamicComponent } from "./DynamicComponent";
import { EventTypeAppCard } from "./EventTypeAppCardInterface";
vi.mock("./DynamicComponent", async () => {
const actual = (await vi.importActual("./DynamicComponent")) as object;
return {
...actual,
DynamicComponent: vi.fn(() => <div>MockedDynamicComponent</div>),
};
});
afterEach(() => {
vi.clearAllMocks();
});
const getAppDataMock = vi.fn();
const setAppDataMock = vi.fn();
const mockProps = {
app: {
name: "TestApp",
slug: "testapp",
credentialOwner: {},
} as RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner },
eventType: {},
getAppData: getAppDataMock,
setAppData: setAppDataMock,
LockedIcon: <div>MockedIcon</div>,
disabled: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
describe("Tests for EventTypeAppCard component", () => {
test("Should render DynamicComponent with correct slug", () => {
render(<EventTypeAppCard {...mockProps} />);
expect(DynamicComponent).toHaveBeenCalledWith(
expect.objectContaining({
slug: mockProps.app.slug,
}),
{}
);
expect(screen.getByText("MockedDynamicComponent")).toBeInTheDocument();
});
test("Should invoke getAppData and setAppData from context on render", () => {
render(
<EventTypeAppCard
{...mockProps}
value={{
getAppData: getAppDataMock(),
setAppData: setAppDataMock(),
}}
/>
);
expect(getAppDataMock).toHaveBeenCalled();
expect(setAppDataMock).toHaveBeenCalled();
});
test("Should render DynamicComponent with 'stripepayment' slug for stripe app", () => {
const stripeProps = {
...mockProps,
app: {
...mockProps.app,
slug: "stripe",
},
};
render(<EventTypeAppCard {...stripeProps} />);
expect(DynamicComponent).toHaveBeenCalledWith(
expect.objectContaining({
slug: "stripepayment",
}),
{}
);
expect(screen.getByText("MockedDynamicComponent")).toBeInTheDocument();
});
test("Should display error boundary message on child component error", () => {
(DynamicComponent as jest.Mock).mockImplementation(() => {
return Error("Mocked error from DynamicComponent");
});
render(<EventTypeAppCard {...mockProps} />);
const errorMessage = screen.getByText(`There is some problem with ${mockProps.app.name} App`);
expect(errorMessage).toBeInTheDocument();
});
});

View File

@ -0,0 +1,23 @@
import type { GetServerSidePropsContext } from "next";
export const AppSetupPageMap = {
alby: import("../../alby/pages/setup/_getServerSideProps"),
make: import("../../make/pages/setup/_getServerSideProps"),
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
};
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const { slug } = ctx.params || {};
if (typeof slug !== "string") return { notFound: true } as const;
if (!(slug in AppSetupPageMap)) return { props: {} };
const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap];
if (!page.getServerSideProps) return { props: {} };
const props = await page.getServerSideProps(ctx);
return props;
};

View File

@ -0,0 +1,25 @@
import dynamic from "next/dynamic";
import { DynamicComponent } from "../../_components/DynamicComponent";
export const AppSetupMap = {
alby: dynamic(() => import("../../alby/pages/setup")),
"apple-calendar": dynamic(() => import("../../applecalendar/pages/setup")),
exchange: dynamic(() => import("../../exchangecalendar/pages/setup")),
"exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")),
"exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")),
"caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")),
"ics-feed": dynamic(() => import("../../ics-feedcalendar/pages/setup")),
zapier: dynamic(() => import("../../zapier/pages/setup")),
make: dynamic(() => import("../../make/pages/setup")),
closecom: dynamic(() => import("../../closecom/pages/setup")),
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
paypal: dynamic(() => import("../../paypal/pages/setup")),
};
export const AppSetupPage = (props: { slug: string }) => {
return <DynamicComponent<typeof AppSetupMap> componentMap={AppSetupMap} {...props} />;
};
export default AppSetupPage;

View File

@ -0,0 +1,10 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
export default function checkSession(req: NextApiRequest) {
if (!req.session?.user?.id) {
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
}
return req.session;
}

View File

@ -0,0 +1,18 @@
import type { DestinationCalendar } from "@prisma/client";
import { metadata as OutlookMetadata } from "../../office365calendar";
/**
* When inviting attendees to a calendar event, sometimes the external ID is only used for internal purposes
* Need to process the correct external ID for the calendar service
*/
const processExternalId = (destinationCalendar: DestinationCalendar) => {
if (destinationCalendar.integration === OutlookMetadata.type) {
// Primary email should always be present for Outlook
return destinationCalendar.primaryEmail || destinationCalendar.externalId;
}
return destinationCalendar.externalId;
};
export default processExternalId;

View File

@ -0,0 +1,64 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { AppCategories } from "@calcom/prisma/enums";
import type { IconName } from "@calcom/ui";
function getHref(baseURL: string, category: string, useQueryParam: boolean) {
const baseUrlParsed = new URL(baseURL, WEBAPP_URL);
baseUrlParsed.searchParams.set("category", category);
return useQueryParam ? `${baseUrlParsed.toString()}` : `${baseURL}/${category}`;
}
type AppCategoryEntry = {
name: AppCategories;
href: string;
icon: IconName;
};
const getAppCategories = (baseURL: string, useQueryParam: boolean): AppCategoryEntry[] => {
// Manually sorted alphabetically, but leaving "Other" at the end
// TODO: Refactor and type with Record<AppCategories, AppCategoryEntry> to enforce consistency
return [
{
name: "analytics",
href: getHref(baseURL, "analytics", useQueryParam),
icon: "bar-chart",
},
{
name: "automation",
href: getHref(baseURL, "automation", useQueryParam),
icon: "share-2",
},
{
name: "calendar",
href: getHref(baseURL, "calendar", useQueryParam),
icon: "calendar",
},
{
name: "conferencing",
href: getHref(baseURL, "conferencing", useQueryParam),
icon: "video",
},
{
name: "crm",
href: getHref(baseURL, "crm", useQueryParam),
icon: "contact",
},
{
name: "messaging",
href: getHref(baseURL, "messaging", useQueryParam),
icon: "mail",
},
{
name: "payment",
href: getHref(baseURL, "payment", useQueryParam),
icon: "credit-card",
},
{
name: "other",
href: getHref(baseURL, "other", useQueryParam),
icon: "grid-3x3",
},
];
};
export default getAppCategories;

View File

@ -0,0 +1,22 @@
import type { AppCategories } from "@calcom/prisma/enums";
/**
* Handles if the app category should be full capitalized ex. CRM
*
* @param {App["variant"]} variant - The variant of the app.
* @param {boolean} [returnLowerCase] - Optional flag to return the title in lowercase.
*/
const getAppCategoryTitle = (variant: AppCategories, returnLowerCase?: boolean) => {
let title: string;
if (variant === "crm") {
title = "CRM";
return title;
} else {
title = variant;
}
return returnLowerCase ? title.toLowerCase() : title;
};
export default getAppCategoryTitle;

View File

@ -0,0 +1,10 @@
import type { Prisma } from "@prisma/client";
import prisma from "@calcom/prisma";
async function getAppKeysFromSlug(slug: string) {
const app = await prisma.app.findUnique({ where: { slug } });
return (app?.keys || {}) as Prisma.JsonObject;
}
export default getAppKeysFromSlug;

View File

@ -0,0 +1,53 @@
import logger from "@calcom/lib/logger";
import type { Calendar, CalendarClass } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import appStore from "..";
interface CalendarApp {
lib: {
CalendarService: CalendarClass;
};
}
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
/**
* @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
*/
const isCalendarService = (x: unknown): x is CalendarApp =>
!!x &&
typeof x === "object" &&
"lib" in x &&
typeof x.lib === "object" &&
!!x.lib &&
"CalendarService" in x.lib;
export const getCalendar = async (credential: CredentialPayload | null): Promise<Calendar | null> => {
if (!credential || !credential.key) return null;
let { type: calendarType } = credential;
if (calendarType?.endsWith("_other_calendar")) {
calendarType = calendarType.split("_other_calendar")[0];
}
// Backwards compatibility until CRM manager is created
if (calendarType?.endsWith("_crm")) {
calendarType = calendarType.split("_crm")[0];
}
const calendarAppImportFn = appStore[calendarType.split("_").join("") as keyof typeof appStore];
if (!calendarAppImportFn) {
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
const calendarApp = await calendarAppImportFn();
if (!isCalendarService(calendarApp)) {
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
log.info("Got calendarApp", calendarApp.lib.CalendarService);
const CalendarService = calendarApp.lib.CalendarService;
return new CalendarService(credential);
};

View File

@ -0,0 +1,33 @@
import logger from "@calcom/lib/logger";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM } from "@calcom/types/CrmService";
import appStore from "..";
type Class<I, Args extends any[] = any[]> = new (...args: Args) => I;
type CrmClass = Class<CRM, [CredentialPayload]>;
const log = logger.getSubLogger({ prefix: ["CrmManager"] });
export const getCrm = async (credential: CredentialPayload) => {
if (!credential || !credential.key) return null;
const { type: crmType } = credential;
const crmName = crmType.split("_")[0];
const crmAppImportFn = appStore[crmName as keyof typeof appStore];
if (!crmAppImportFn) {
log.warn(`crm of type ${crmType} is not implemented`);
return null;
}
const crmApp = await crmAppImportFn();
if (crmApp && "lib" in crmApp && "CrmService" in crmApp.lib) {
const CrmService = crmApp.lib.CrmService as CrmClass;
return new CrmService(credential);
}
};
export default getCrm;

View File

@ -0,0 +1,51 @@
import type { z } from "zod";
import type { BookerEvent } from "@calcom/features/bookings/types";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
export type EventTypeApps = NonNullable<NonNullable<z.infer<typeof EventTypeMetaDataSchema>>["apps"]>;
export type EventTypeAppsList = keyof EventTypeApps;
export const getEventTypeAppData = <T extends EventTypeAppsList>(
eventType: Pick<BookerEvent, "price" | "currency" | "metadata">,
appId: T,
forcedGet?: boolean
): EventTypeApps[T] => {
const metadata = eventType.metadata;
const appMetadata = metadata?.apps && metadata.apps[appId];
if (appMetadata) {
const allowDataGet = forcedGet ? true : appMetadata.enabled;
return allowDataGet
? {
...appMetadata,
// We should favor eventType's price and currency over appMetadata's price and currency
price: eventType.price || appMetadata.price || null,
currency: eventType.currency || appMetadata.currency || null,
// trackingId is legacy way to store value for TRACKING_ID. So, we need to support both.
TRACKING_ID: appMetadata.TRACKING_ID || appMetadata.trackingId || null,
}
: null;
}
// Backward compatibility for existing event types.
// TODO: After the new AppStore EventType App flow is stable, write a migration to migrate metadata to new format which will let us remove this compatibility code
// Migration isn't being done right now, to allow a revert if needed
const legacyAppsData = {
stripe: {
enabled: !!eventType.price,
// Price default is 0 in DB. So, it would always be non nullish.
price: eventType.price,
// Currency default is "usd" in DB.So, it would also be available always
currency: eventType.currency,
paymentOption: "ON_BOOKING",
},
giphy: {
enabled: !!eventType.metadata?.giphyThankYouPage,
thankYouPage: eventType.metadata?.giphyThankYouPage || "",
},
} as const;
// TODO: This assertion helps typescript hint that only one of the app's data can be returned
const legacyAppData = legacyAppsData[appId as Extract<T, keyof typeof legacyAppsData>];
const allowDataGet = forcedGet ? true : legacyAppData?.enabled;
return allowDataGet ? legacyAppData : null;
};

View File

@ -0,0 +1,20 @@
import z from "zod";
import { AppCategories } from "@calcom/prisma/enums";
const variantSchema = z.nativeEnum(AppCategories);
export default function getInstalledAppPath(
{ variant, slug }: { variant?: string; slug?: string },
locationSearch = ""
): string {
if (!variant) return `/apps/installed${locationSearch}`;
const parsedVariant = variantSchema.safeParse(variant);
if (!parsedVariant.success) return `/apps/installed${locationSearch}`;
if (!slug) return `/apps/installed/${variant}${locationSearch}`;
return `/apps/installed/${variant}?hl=${slug}${locationSearch && locationSearch.slice(1)}`;
}

View File

@ -0,0 +1,14 @@
import type Zod from "zod";
import type z from "zod";
import getAppKeysFromSlug from "./getAppKeysFromSlug";
export async function getParsedAppKeysFromSlug<T extends Zod.Schema>(
slug: string,
schema: T
): Promise<z.infer<T>> {
const appKeys = await getAppKeysFromSlug(slug);
return schema.parse(appKeys);
}
export default getParsedAppKeysFromSlug;

View File

@ -0,0 +1,58 @@
import type { Prisma } from "@prisma/client";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import type { UserProfile } from "@calcom/types/UserProfile";
export async function checkInstalled(slug: string, userId: number) {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
appId: slug,
userId: userId,
},
});
if (alreadyInstalled) {
throw new HttpError({ statusCode: 422, message: "Already installed" });
}
}
type InstallationArgs = {
appType: string;
user: {
id: number;
profile?: UserProfile;
};
slug: string;
key?: Prisma.InputJsonValue;
teamId?: number;
subscriptionId?: string | null;
paymentStatus?: string | null;
billingCycleStart?: number | null;
};
export async function createDefaultInstallation({
appType,
user,
slug,
key = {},
teamId,
billingCycleStart,
paymentStatus,
subscriptionId,
}: InstallationArgs) {
const installation = await prisma.credential.create({
data: {
type: appType,
key,
...(teamId ? { teamId } : { userId: user.id }),
appId: slug,
subscriptionId,
paymentStatus,
billingCycleStart,
},
});
if (!installation) {
throw new Error(`Unable to create user credential for type ${appType}`);
}
return installation;
}

View File

@ -0,0 +1,21 @@
import prisma from "@calcom/prisma";
import type { CredentialPayload } from "@calcom/types/Credential";
export const invalidateCredential = async (credentialId: CredentialPayload["id"]) => {
const credential = await prisma.credential.findUnique({
where: {
id: credentialId,
},
});
if (credential) {
await prisma.credential.update({
where: {
id: credentialId,
},
data: {
invalid: true,
},
});
}
};

View File

@ -0,0 +1,22 @@
/**
* This class is used to convert axios like response to fetch response
*/
export class AxiosLikeResponseToFetchResponse<
T extends {
status: number;
statusText: string;
data: unknown;
}
> extends Response {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: any;
constructor(axiomResponse: T) {
super(JSON.stringify(axiomResponse.data), {
status: axiomResponse.status,
statusText: axiomResponse.statusText,
});
}
async json() {
return super.json() as unknown as T["data"];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,565 @@
/**
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
* It is aware of the credential sync endpoint and can sync the token from the third party source.
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
*
* For a recommended usage example, see Zoom VideoApiAdapter.ts
*/
import type { z } from "zod";
import { CREDENTIAL_SYNC_ENDPOINT } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { AxiosLikeResponseToFetchResponse } from "./AxiosLikeResponseToFetchResponse";
import type { OAuth2TokenResponseInDbWhenExistsSchema, OAuth2UniversalSchema } from "./universalSchema";
import { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema";
const log = logger.getSubLogger({ prefix: ["app-store/_utils/oauth/OAuthManager"] });
export const enum TokenStatus {
UNUSABLE_TOKEN_OBJECT,
UNUSABLE_ACCESS_TOKEN,
INCONCLUSIVE,
VALID,
}
type ResourceOwner =
| {
id: number | null;
type: "team";
}
| {
id: number | null;
type: "user";
};
type FetchNewTokenObject = ({ refreshToken }: { refreshToken: string | null }) => Promise<Response | null>;
type UpdateTokenObject = (
token: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>
) => Promise<void>;
type isTokenObjectUnusable = (response: Response) => Promise<{ reason: string } | null>;
type isAccessTokenUnusable = (response: Response) => Promise<{ reason: string } | null>;
type IsTokenExpired = (token: z.infer<typeof OAuth2UniversalSchema>) => Promise<boolean> | boolean;
type InvalidateTokenObject = () => Promise<void>;
type ExpireAccessToken = () => Promise<void>;
type CredentialSyncVariables = {
/**
* The secret required to access the credential sync endpoint
*/
CREDENTIAL_SYNC_SECRET: string | undefined;
/**
* The header name that the secret should be passed in
*/
CREDENTIAL_SYNC_SECRET_HEADER_NAME: string;
/**
* The endpoint where the credential sync should happen
*/
CREDENTIAL_SYNC_ENDPOINT: string | undefined;
APP_CREDENTIAL_SHARING_ENABLED: boolean;
};
/**
* Manages OAuth2.0 tokens for an app and resourceOwner
* If expiry_date or expires_in isn't provided in token then it is considered expired immediately(if credential sync is not enabled)
* If credential sync is enabled, the token is considered expired after a year. It is expected to be refreshed by the API request from the credential source(as it knows when the token is expired)
*/
export class OAuthManager {
private currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
private resourceOwner: ResourceOwner;
private appSlug: string;
private fetchNewTokenObject: FetchNewTokenObject;
private updateTokenObject: UpdateTokenObject;
private isTokenObjectUnusable: isTokenObjectUnusable;
private isAccessTokenUnusable: isAccessTokenUnusable;
private isTokenExpired: IsTokenExpired;
private invalidateTokenObject: InvalidateTokenObject;
private expireAccessToken: ExpireAccessToken;
private credentialSyncVariables: CredentialSyncVariables;
private useCredentialSync: boolean;
private autoCheckTokenExpiryOnRequest: boolean;
constructor({
resourceOwner,
appSlug,
currentTokenObject,
fetchNewTokenObject,
updateTokenObject,
isTokenObjectUnusable,
isAccessTokenUnusable,
invalidateTokenObject,
expireAccessToken,
credentialSyncVariables,
autoCheckTokenExpiryOnRequest = true,
isTokenExpired = (token: z.infer<typeof OAuth2TokenResponseInDbWhenExistsSchema>) => {
log.debug(
"isTokenExpired called",
safeStringify({ expiry_date: token.expiry_date, currentTime: Date.now() })
);
return getExpiryDate() <= Date.now();
function isRelativeToEpoch(relativeTimeInSeconds: number) {
return relativeTimeInSeconds > 1000000000; // If it is more than 2001-09-09 it can be considered relative to epoch. Also, that is more than 30 years in future which couldn't possibly be relative to current time
}
function getExpiryDate() {
if (token.expiry_date) {
return token.expiry_date;
}
// It is usually in "seconds since now" but due to some integrations logic converting it to "seconds since epoch"(e.g. Office365Calendar has done that) we need to confirm what is the case here.
// But we for now know that it is in seconds for sure
// If it is not relative to epoch then it would be wrong to use it as it would make the token as non-expired when it could be expired
if (token.expires_in && isRelativeToEpoch(token.expires_in)) {
return token.expires_in * 1000;
}
// 0 means it would be expired as Date.now() is greater than that
return 0;
}
},
}: {
/**
* The resource owner for which the token is being managed
*/
resourceOwner: ResourceOwner;
/**
* Does response for any request contain information that refresh_token became invalid and thus the entire token object become unusable
* Note: Right now, the implementations of this function makes it so that the response is considered invalid(sometimes) even if just access_token is revoked or invalid. In that case, regenerating access token should work. So, we shouldn't mark the token as invalid in that case.
* We should instead mark the token as expired. We could do that by introducing isAccessTokenInvalid function
*
* @param response
* @returns
*/
isTokenObjectUnusable: isTokenObjectUnusable;
/**
*
*/
isAccessTokenUnusable: isAccessTokenUnusable;
/**
* The current token object.
*/
currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
/**
* The unique identifier of the app that the token is for. It is required to do credential syncing in self-hosting
*/
appSlug: string;
/**
*
* It could be null in case refresh_token isn't available. This is possible when credential sync happens from a third party who doesn't want to share refresh_token and credential syncing has been disabled after the sync has happened.
* If credential syncing is still enabled `fetchNewTokenObject` wouldn't be called
*/
fetchNewTokenObject: FetchNewTokenObject;
/**
* update token object
*/
updateTokenObject: UpdateTokenObject;
/**
* Handler to invalidate the token object. It is called when the token object is invalid and credential syncing is disabled
*/
invalidateTokenObject: InvalidateTokenObject;
/*
* Handler to expire the access token. It is called when credential syncing is enabled and when the token object expires
*/
expireAccessToken: ExpireAccessToken;
/**
* The variables required for credential syncing
*/
credentialSyncVariables: CredentialSyncVariables;
/**
* If the token should be checked for expiry before sending a request
*/
autoCheckTokenExpiryOnRequest?: boolean;
/**
* If there is a different way to check if the token is expired(and not the standard way of checking expiry_date)
*/
isTokenExpired?: IsTokenExpired;
}) {
this.resourceOwner = resourceOwner;
this.currentTokenObject = currentTokenObject;
this.appSlug = appSlug;
this.fetchNewTokenObject = fetchNewTokenObject;
this.isTokenObjectUnusable = isTokenObjectUnusable;
this.isAccessTokenUnusable = isAccessTokenUnusable;
this.isTokenExpired = isTokenExpired;
this.invalidateTokenObject = invalidateTokenObject;
this.expireAccessToken = expireAccessToken;
this.credentialSyncVariables = credentialSyncVariables;
this.useCredentialSync = !!(
credentialSyncVariables.APP_CREDENTIAL_SHARING_ENABLED &&
credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT &&
credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME &&
credentialSyncVariables.CREDENTIAL_SYNC_SECRET
);
if (this.useCredentialSync) {
// Though it should be validated without credential sync as well but it seems like we have some credentials without userId in production
// So, we are not validating it for now
ensureValidResourceOwner(resourceOwner);
}
this.autoCheckTokenExpiryOnRequest = autoCheckTokenExpiryOnRequest;
this.updateTokenObject = updateTokenObject;
}
private isResponseNotOkay(response: Response) {
return !response.ok || response.status < 200 || response.status >= 300;
}
public async getTokenObjectOrFetch() {
const myLog = log.getSubLogger({
prefix: [`getTokenObjectOrFetch:appSlug=${this.appSlug}`],
});
const isExpired = await this.isTokenExpired(this.currentTokenObject);
myLog.debug(
"getTokenObjectOrFetch called",
safeStringify({
isExpired,
resourceOwner: this.resourceOwner,
})
);
if (!isExpired) {
myLog.debug("Token is not expired. Returning the current token object");
return { token: this.normalizeNewlyReceivedToken(this.currentTokenObject), isUpdated: false };
} else {
const token = {
// Keep the old token object as it is, as some integrations don't send back all the props e.g. refresh_token isn't sent again by Google Calendar
// It also allows any other properties set to be retained.
// Let's not use normalizedCurrentTokenObject here as `normalizeToken` could possible be not idempotent
...this.currentTokenObject,
...this.normalizeNewlyReceivedToken(await this.refreshOAuthToken()),
};
myLog.debug("Token is expired. So, returning new token object");
this.currentTokenObject = token;
await this.updateTokenObject(token);
return { token, isUpdated: true };
}
}
public async request(arg: { url: string; options: RequestInit }): Promise<{
tokenStatus: TokenStatus;
json: unknown;
}>;
public async request<T>(
customFetch: () => Promise<
AxiosLikeResponseToFetchResponse<{
status: number;
statusText: string;
data: T;
}>
>
): Promise<{
tokenStatus: TokenStatus;
json: T;
}>;
/**
* Send request automatically adding the Authorization header with the access token. More importantly, handles token invalidation
*/
public async request<T>(
customFetchOrUrlAndOptions:
| { url: string; options: RequestInit }
| (() => Promise<
AxiosLikeResponseToFetchResponse<{
status: number;
statusText: string;
data: T;
}>
>)
) {
let response;
const myLog = log.getSubLogger({ prefix: ["request"] });
if (this.autoCheckTokenExpiryOnRequest) {
await this.getTokenObjectOrFetch();
}
if (typeof customFetchOrUrlAndOptions === "function") {
myLog.debug("Sending request using customFetch");
const customFetch = customFetchOrUrlAndOptions;
try {
response = await customFetch();
} catch (e) {
// Get response from error so that code further down can categorize it into tokenUnusable or access token unusable
// Those methods accept response only
response = handleFetchError(e);
}
} else {
const { url, options } = customFetchOrUrlAndOptions;
const headers = {
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
"Content-Type": "application/json",
...options?.headers,
};
myLog.debug("Sending request using fetch", safeStringify({ customFetchOrUrlAndOptions, headers }));
// We don't catch fetch error here because such an error would be temporary and we shouldn't take any action on it.
response = await fetch(url, {
method: "GET",
...options,
headers: headers,
});
}
myLog.debug(
"Response from request",
safeStringify({
text: await response.clone().text(),
status: response.status,
statusText: response.statusText,
})
);
const { tokenStatus, json } = await this.getAndValidateOAuth2Response({
response,
});
if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) {
// In case of Credential Sync, we expire the token so that through the sync we can refresh the token
// TODO: We should consider sending a special 'reason' query param to toke sync endpoint to convey the reason for getting token
await this.invalidate();
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
await this.expireAccessToken();
} else if (tokenStatus === TokenStatus.INCONCLUSIVE) {
await this.onInconclusiveResponse();
}
// We are done categorizing the token status. Now, we can throw back
if ("myFetchError" in (json || {})) {
throw new Error(json.myFetchError);
}
return { tokenStatus: tokenStatus, json };
}
/**
* It doesn't automatically detect the response for tokenObject and accessToken becoming invalid
* Could be used when you expect a possible non JSON response as well.
*/
public async requestRaw({ url, options }: { url: string; options: RequestInit }) {
const myLog = log.getSubLogger({ prefix: ["requestRaw"] });
myLog.debug("Sending request using fetch", safeStringify({ url, options }));
if (this.autoCheckTokenExpiryOnRequest) {
await this.getTokenObjectOrFetch();
}
const headers = {
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
"Content-Type": "application/json",
...options?.headers,
};
const response = await fetch(url, {
method: "GET",
...options,
headers: headers,
});
myLog.debug(
"Response from request",
safeStringify({
text: await response.clone().text(),
status: response.status,
statusText: response.statusText,
})
);
if (this.isResponseNotOkay(response)) {
await this.onInconclusiveResponse();
}
return response;
}
private async onInconclusiveResponse() {
const myLog = log.getSubLogger({ prefix: ["onInconclusiveResponse"] });
myLog.debug("Expiring the access token");
// We can't really take any action on inconclusive response
// But in case of credential sync we should expire the token so that through the sync we can possibly fix the issue by refreshing the token
// It is important because in that cases tokens have an infinite expiry and it is possible that the token is revoked and isAccessUnusable and isTokenObjectUnusable couldn't detect the issue
if (this.useCredentialSync) {
await this.expireAccessToken();
}
}
private async invalidate() {
const myLog = log.getSubLogger({ prefix: ["invalidate"] });
if (this.useCredentialSync) {
myLog.debug("Expiring the access token");
// We are not calling it through refreshOAuthToken flow because the token is refreshed already there
// There is no point expiring the token as we will probably get the same result in that case.
await this.expireAccessToken();
} else {
myLog.debug("Invalidating the token object");
// In case credential sync is enabled there is no point of marking the token as invalid as user doesn't take action on that.
// The third party needs to sync the correct credential back which we get done by marking the token as expired.
await this.invalidateTokenObject();
}
}
private normalizeNewlyReceivedToken(
token: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>
) {
if (!token.expiry_date && !token.expires_in) {
// Use a practically infinite expiry(a year) for when Credential Sync is enabled. Token is expected to be refreshed by the API request from the credential source.
// If credential sync is not enabled, we should consider the token as expired otherwise the token could be considered valid forever
token.expiry_date = this.useCredentialSync ? Date.now() + 365 * 24 * 3600 * 1000 : 0;
} else if (token.expires_in !== undefined && token.expiry_date === undefined) {
token.expiry_date = Math.round(Date.now() + token.expires_in * 1000);
// As expires_in could be relative to current time, we can't keep it in the token object as it could endup giving wrong absolute expiry_time if outdated value is used
// That could happen if we merge token objects which we do
delete token.expires_in;
}
return token;
}
// TODO: On regenerating access_token successfully, we should call makeTokenObjectValid(to counter invalidateTokenObject). This should fix stale banner in UI to reconnect when the connection is working
private async refreshOAuthToken() {
const myLog = log.getSubLogger({ prefix: ["refreshOAuthToken"] });
let response;
const refreshToken = this.currentTokenObject.refresh_token ?? null;
if (this.resourceOwner.id && this.useCredentialSync) {
if (
!this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET ||
!this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME ||
!this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT
) {
throw new Error("Credential syncing is enabled but the required env variables are not set");
}
myLog.debug(
"Refreshing OAuth token from credential sync endpoint",
safeStringify({
appSlug: this.appSlug,
resourceOwner: this.resourceOwner,
endpoint: CREDENTIAL_SYNC_ENDPOINT,
})
);
try {
response = await fetch(`${this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT}`, {
method: "POST",
headers: {
[this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME]:
this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET,
},
body: new URLSearchParams({
calcomUserId: this.resourceOwner.id.toString(),
appSlug: this.appSlug,
}),
});
} catch (e) {
myLog.error("Could not refresh the token.", safeStringify(e));
throw new Error(
`Could not refresh the token due to connection issue with the endpoint: ${CREDENTIAL_SYNC_ENDPOINT}`
);
}
} else {
myLog.debug(
"Refreshing OAuth token",
safeStringify({
appSlug: this.appSlug,
resourceOwner: this.resourceOwner,
})
);
try {
response = await this.fetchNewTokenObject({ refreshToken });
} catch (e) {
response = handleFetchError(e);
}
if (!response) {
throw new Error("`fetchNewTokenObject` could not refresh the token");
}
}
const clonedResponse = response.clone();
myLog.debug(
"Response from refreshOAuthToken",
safeStringify({
text: await clonedResponse.text(),
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
})
);
const { json, tokenStatus } = await this.getAndValidateOAuth2Response({
response,
});
if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) {
await this.invalidateTokenObject();
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
await this.expireAccessToken();
}
const parsedToken = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.safeParse(json);
if (!parsedToken.success) {
myLog.error("Token parsing error:", safeStringify(parsedToken.error.issues));
throw new Error("Invalid token response");
}
return parsedToken.data;
}
private async getAndValidateOAuth2Response({ response }: { response: Response }) {
const myLog = log.getSubLogger({ prefix: ["getAndValidateOAuth2Response"] });
const clonedResponse = response.clone();
// handle empty response (causes crash otherwise on doing json() as "" is invalid JSON) which is valid in some cases like PATCH calls(with 204 response)
if ((await clonedResponse.text()).trim() === "") {
return { tokenStatus: TokenStatus.VALID, json: null, invalidReason: null } as const;
}
const tokenObjectUsabilityRes = await this.isTokenObjectUnusable(response.clone());
const accessTokenUsabilityRes = await this.isAccessTokenUnusable(response.clone());
const isNotOkay = this.isResponseNotOkay(response);
const json = await response.json();
if (tokenObjectUsabilityRes?.reason) {
myLog.error("Token Object has become unusable");
return {
tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT,
invalidReason: tokenObjectUsabilityRes.reason,
json,
} as const;
}
if (accessTokenUsabilityRes?.reason) {
myLog.error("Access Token has become unusable");
return {
tokenStatus: TokenStatus.UNUSABLE_ACCESS_TOKEN,
invalidReason: accessTokenUsabilityRes?.reason,
json,
};
}
// Any handlable not ok response should be handled through isTokenObjectUnusable or isAccessTokenUnusable but if still not handled, we should throw an error
// So, that the caller can handle it. It could be a network error or some other temporary error from the third party App itself.
if (isNotOkay) {
return {
tokenStatus: TokenStatus.INCONCLUSIVE,
invalidReason: response.statusText,
json,
};
}
return { tokenStatus: TokenStatus.VALID, json, invalidReason: null } as const;
}
}
function ensureValidResourceOwner(
resourceOwner: { id: number | null; type: "team" } | { id: number | null; type: "user" }
) {
if (resourceOwner.type === "team") {
throw new Error("Teams are not supported");
} else {
if (!resourceOwner.id) {
throw new Error("resourceOwner should have id set");
}
}
}
/**
* It converts error into a Response
*/
function handleFetchError(e: unknown) {
const myLog = log.getSubLogger({ prefix: ["handleFetchError"] });
myLog.debug("Error", safeStringify(e));
if (e instanceof Error) {
return new Response(JSON.stringify({ myFetchError: e.message }), { status: 500 });
}
return new Response(JSON.stringify({ myFetchError: "UNKNOWN_ERROR" }), { status: 500 });
}

View File

@ -0,0 +1,69 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../oauth/decodeOAuthState";
import { throwIfNotHaveAdminAccessToTeam } from "../throwIfNotHaveAdminAccessToTeam";
/**
* This function is used to create app credentials for either a user or a team
*
* @param appData information about the app
* @param appData.type the app slug
* @param appData.appId the app slug
* @param key the keys for the app's credentials
* @param req the request object from the API call. Used to determine if the credential belongs to a user or a team
*/
const createOAuthAppCredential = async (
appData: { type: string; appId: string },
key: unknown,
req: NextApiRequest
) => {
const userId = req.session?.user.id;
if (!userId) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
// For OAuth flows, see if a teamId was passed through the state
const state = decodeOAuthState(req);
if (state?.teamId) {
// Check that the user belongs to the team
const team = await prisma.team.findFirst({
where: {
id: state.teamId,
members: {
some: {
userId: req.session?.user.id,
accepted: true,
},
},
},
select: { id: true, members: { select: { userId: true } } },
});
if (!team) throw new Error("User does not belong to the team");
return await prisma.credential.create({
data: {
type: appData.type,
key: key || {},
teamId: state.teamId,
appId: appData.appId,
},
});
}
await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId });
return await prisma.credential.create({
data: {
type: appData.type,
key: key || {},
userId,
appId: appData.appId,
},
});
};
export default createOAuthAppCredential;

View File

@ -0,0 +1,12 @@
import type { NextApiRequest } from "next";
import type { IntegrationOAuthCallbackState } from "../../types";
export function decodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") {
return undefined;
}
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
return state;
}

View File

@ -0,0 +1,12 @@
import type { NextApiRequest } from "next";
import type { IntegrationOAuthCallbackState } from "../../types";
export function encodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") {
return undefined;
}
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
return JSON.stringify(state);
}

View File

@ -0,0 +1,26 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { CredentialPayload } from "@calcom/types/Credential";
import { OAuth2TokenResponseInDbSchema } from "./universalSchema";
export function getTokenObjectFromCredential(credential: CredentialPayload) {
const parsedTokenResponse = OAuth2TokenResponseInDbSchema.safeParse(credential.key);
if (!parsedTokenResponse.success) {
logger.error(
"GoogleCalendarService-getTokenObjectFromCredential",
safeStringify(parsedTokenResponse.error.issues)
);
throw new Error(
`Could not parse credential.key ${credential.id} with error: ${parsedTokenResponse?.error}`
);
}
const tokenResponse = parsedTokenResponse.data;
if (!tokenResponse) {
throw new Error(`credential.key is not set for credential ${credential.id}`);
}
return tokenResponse;
}

View File

@ -0,0 +1,21 @@
import prisma from "@calcom/prisma";
import type { CredentialPayload } from "@calcom/types/Credential";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
export const markTokenAsExpired = async (credential: CredentialPayload) => {
const tokenResponse = getTokenObjectFromCredential(credential);
if (credential && credential.key) {
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: {
...tokenResponse,
expiry_date: Date.now() - 3600 * 1000,
},
},
});
}
};

View File

@ -0,0 +1,26 @@
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import { invalidateCredential } from "../invalidateCredential";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
import { markTokenAsExpired } from "./markTokenAsExpired";
import { updateTokenObject } from "./updateTokenObject";
export const credentialSyncVariables = {
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
};
export const oAuthManagerHelper = {
updateTokenObject,
markTokenAsExpired,
invalidateCredential: invalidateCredential,
getTokenObjectFromCredential,
credentialSyncVariables,
};

View File

@ -0,0 +1,46 @@
import { z } from "zod";
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
export const minimumTokenResponseSchema = z
.object({
access_token: z.string(),
})
.passthrough()
.superRefine((tokenObject, ctx) => {
if (!Object.values(tokenObject).some((value) => typeof value === "number")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Missing a field that defines a token expiry date. Check the specific app package to see how token expiry is defined.",
});
}
});
export type ParseRefreshTokenResponse<S extends z.ZodTypeAny> =
| z.infer<S>
| z.infer<typeof minimumTokenResponseSchema>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
let refreshTokenResponse;
const credentialSyncingEnabled =
APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) {
refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
} else {
refreshTokenResponse = schema.safeParse(response);
}
if (!refreshTokenResponse.success) {
throw new Error("Invalid refreshed tokens were returned");
}
if (!refreshTokenResponse.data.refresh_token && credentialSyncingEnabled) {
refreshTokenResponse.data.refresh_token = "refresh_token";
}
return refreshTokenResponse.data;
};
export default parseRefreshTokenResponse;

View File

@ -0,0 +1,35 @@
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => {
// Check that app syncing is enabled and that the credential belongs to a user
if (
APP_CREDENTIAL_SHARING_ENABLED &&
process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT &&
CREDENTIAL_SYNC_SECRET &&
userId
) {
// Customize the payload based on what your endpoint requires
// The response should only contain the access token and expiry date
const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
method: "POST",
headers: {
[CREDENTIAL_SYNC_SECRET_HEADER_NAME]: CREDENTIAL_SYNC_SECRET,
},
body: new URLSearchParams({
calcomUserId: userId.toString(),
appSlug,
}),
});
return response;
} else {
const response = await refreshFunction();
return response;
}
};
export default refreshOAuthTokens;

View File

@ -0,0 +1,45 @@
import { z } from "zod";
/**
* We should be able to work with just the access token.
* access_token allows us to access the resources
*/
export const OAuth2BareMinimumUniversalSchema = z
.object({
access_token: z.string(),
/**
* It is usually 'Bearer'
*/
token_type: z.string().optional(),
})
// We want any other property to be passed through and stay there.
.passthrough();
export const OAuth2UniversalSchema = OAuth2BareMinimumUniversalSchema.extend({
/**
* If we aren't sent refresh_token, it means that the party syncing us the credentials don't want us to ever refresh the token.
* They would be responsible to send us the access_token before it expires.
*/
refresh_token: z.string().optional(),
/**
* It is only needed when connecting to the API for the first time. So, it is okay if the party syncing us the credentials don't send it as then it is responsible to provide us the access_token already
*/
scope: z.string().optional(),
/**
* Absolute expiration time in milliseconds
*/
expiry_date: z.number().optional(),
});
export const OAuth2UniversalSchemaWithCalcomBackwardCompatibility = OAuth2UniversalSchema.extend({
/**
* Time in seconds until the token expires
* Either this or expiry_date should be provided
*/
expires_in: z.number().optional(),
});
export const OAuth2TokenResponseInDbWhenExistsSchema = OAuth2UniversalSchemaWithCalcomBackwardCompatibility;
export const OAuth2TokenResponseInDbSchema = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.nullable();

View File

@ -0,0 +1,22 @@
import type z from "zod";
import prisma from "@calcom/prisma";
import type { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema";
export const updateTokenObject = async ({
tokenObject,
credentialId,
}: {
tokenObject: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>;
credentialId: number;
}) => {
await prisma.credential.update({
where: {
id: credentialId,
},
data: {
key: tokenObject,
},
});
};

View File

@ -0,0 +1,94 @@
import type Stripe from "stripe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getStripeCustomerIdFromUserId, stripe } from "./stripe";
interface RedirectArgs {
userId: number;
appSlug: string;
appPaidMode: string;
priceId: string;
trialDays?: number;
}
export const withPaidAppRedirect = async ({
appSlug,
appPaidMode,
priceId,
userId,
trialDays,
}: RedirectArgs) => {
const redirect_uri = `${WEBAPP_URL}/api/integrations/${appSlug}/callback?checkoutId={CHECKOUT_SESSION_ID}`;
const stripeCustomerId = await getStripeCustomerIdFromUserId(userId);
const checkoutSession = await stripe.checkout.sessions.create({
success_url: redirect_uri,
cancel_url: redirect_uri,
mode: appPaidMode === "subscription" ? "subscription" : "payment",
payment_method_types: ["card"],
allow_promotion_codes: true,
customer: stripeCustomerId,
line_items: [
{
quantity: 1,
price: priceId,
},
],
client_reference_id: userId.toString(),
...(trialDays
? {
subscription_data: {
trial_period_days: trialDays,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - trial_settings isn't available cc @erik
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
},
}
: undefined),
});
return checkoutSession.url;
};
export const withStripeCallback = async (
checkoutId: string,
appSlug: string,
callback: (args: { checkoutSession: Stripe.Checkout.Session }) => Promise<{ url: string }>
): Promise<{ url: string }> => {
if (!checkoutId) {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "No Stripe Checkout Session ID" })
)}`,
};
}
const checkoutSession = await stripe.checkout.sessions.retrieve(checkoutId);
if (!checkoutSession) {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "Unknown Stripe Checkout Session ID" })
)}`,
};
}
if (checkoutSession.payment_status !== "paid") {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "Stripe Payment not processed" })
)}`,
};
}
if (checkoutSession.mode === "subscription" && checkoutSession.subscription) {
await stripe.subscriptions.update(checkoutSession.subscription.toString(), {
metadata: {
appSlug,
},
});
}
// Execute the callback if all checks pass
return callback({ checkoutSession });
};

View File

@ -0,0 +1,34 @@
import type z from "zod";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { appDataSchemas } from "../../apps.schemas.generated";
/**
*
* @param metadata The event type metadata
* @param inclusive Determines if multiple includes the case of 1
* @returns boolean
*/
const checkForMultiplePaymentApps = (
metadata: z.infer<typeof EventTypeMetaDataSchema>,
inclusive = false
) => {
let enabledPaymentApps = 0;
for (const appKey in metadata?.apps) {
const app = metadata?.apps[appKey as keyof typeof appDataSchemas];
if ("appCategories" in app) {
const isPaymentApp = app.appCategories.includes("payment");
if (isPaymentApp && app.enabled) {
enabledPaymentApps++;
}
} else if ("price" in app && app.enabled) {
enabledPaymentApps++;
}
}
return inclusive ? enabledPaymentApps >= 1 : enabledPaymentApps > 1;
};
export default checkForMultiplePaymentApps;

View File

@ -0,0 +1,57 @@
import type { LocationObject } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
import prisma from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
const setDefaultConferencingApp = async (userId: number, appSlug: string) => {
const eventTypes = await getBulkEventTypes(userId);
const eventTypeIds = eventTypes.eventTypes.map((item) => item.id);
const foundApp = getAppFromSlug(appSlug);
const appType = foundApp?.appData?.location?.type;
if (!appType) {
return;
}
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
metadata: true,
credentials: true,
},
});
const currentMetadata = userMetadata.parse(user?.metadata);
const credentialId = user?.credentials.find((item) => item.appId == appSlug)?.id;
//Update the default conferencing app for the user.
await prisma.user.update({
where: {
id: userId,
},
data: {
metadata: {
...currentMetadata,
defaultConferencingApp: {
appSlug,
},
},
},
});
await prisma.eventType.updateMany({
where: {
id: {
in: eventTypeIds,
},
userId,
},
data: {
locations: [{ type: appType, credentialId }] as LocationObject[],
},
});
};
export default setDefaultConferencingApp;

View File

@ -0,0 +1,74 @@
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
export async function getStripeCustomerIdFromUserId(userId: number) {
// Get user
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
name: true,
metadata: true,
},
});
if (!user?.email) throw new HttpError({ statusCode: 404, message: "User email not found" });
const customerId = await getStripeCustomerId(user);
return customerId;
}
const userType = Prisma.validator<Prisma.UserArgs>()({
select: {
email: true,
metadata: true,
},
});
export type UserType = Prisma.UserGetPayload<typeof userType>;
/** This will retrieve the customer ID from Stripe or create it if it doesn't exists yet. */
export async function getStripeCustomerId(user: UserType): Promise<string> {
let customerId: string | null = null;
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
} else {
/* We fallback to finding the customer by email (which is not optimal) */
const customersResponse = await stripe.customers.list({
email: user.email,
limit: 1,
});
if (customersResponse.data[0]?.id) {
customerId = customersResponse.data[0].id;
} else {
/* Creating customer on Stripe and saving it on prisma */
const customer = await stripe.customers.create({ email: user.email });
customerId = customer.id;
}
await prisma.user.update({
where: {
email: user.email,
},
data: {
metadata: {
...(user.metadata as Prisma.JsonObject),
stripeCustomerId: customerId,
},
},
});
}
return customerId;
}
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY || "";
export const stripe = new Stripe(stripePrivateKey, {
apiVersion: "2020-08-27",
});

View File

@ -0,0 +1,43 @@
export function generateJsonResponse({
json,
status = 200,
statusText = "OK",
}: {
json: unknown;
status?: number;
statusText?: string;
}) {
return new Response(JSON.stringify(json), {
status,
statusText,
});
}
export function internalServerErrorResponse({
json,
}: {
json: unknown;
status?: number;
statusText?: string;
}) {
return generateJsonResponse({ json, status: 500, statusText: "Internal Server Error" });
}
export function generateTextResponse({
text,
status = 200,
statusText = "OK",
}: {
text: string;
status?: number;
statusText?: string;
}) {
return new Response(text, {
status: status,
statusText: statusText,
});
}
export function successResponse({ json }: { json: unknown }) {
return generateJsonResponse({ json });
}

View File

@ -0,0 +1,20 @@
import { HttpError } from "@calcom/lib/http-error";
import { UserRepository } from "@calcom/lib/server/repository/user";
export const throwIfNotHaveAdminAccessToTeam = async ({
teamId,
userId,
}: {
teamId: number | null;
userId: number;
}) => {
if (!teamId) {
return;
}
const teamsUserHasAdminAccessFor = await UserRepository.getUserAdminTeams(userId);
const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((id) => id === teamId);
if (!hasAdminAccessToTeam) {
throw new HttpError({ statusCode: 401, message: "You must be an admin of the team to do this" });
}
};

View File

@ -0,0 +1,138 @@
import type { UseMutationOptions } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import type { IntegrationOAuthCallbackState } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { App } from "@calcom/types/App";
function gotoUrl(url: string, newTab?: boolean) {
if (newTab) {
window.open(url, "_blank");
return;
}
window.location.href = url;
}
type CustomUseMutationOptions =
| Omit<UseMutationOptions<unknown, unknown, unknown, unknown>, "mutationKey" | "mutationFn" | "onSuccess">
| undefined;
type AddAppMutationData = { setupPending: boolean } | void;
export type UseAddAppMutationOptions = CustomUseMutationOptions & {
onSuccess?: (data: AddAppMutationData) => void;
installGoogleVideo?: boolean;
returnTo?: string;
};
function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutationOptions) {
const pathname = usePathname();
const onErrorReturnTo = `${WEBAPP_URL}${pathname}`;
const mutation = useMutation<
AddAppMutationData,
Error,
| {
type?: App["type"];
variant?: string;
slug?: string;
teamId?: number;
returnTo?: string;
defaultInstall?: boolean;
}
| ""
>({
...options,
mutationFn: async (variables) => {
let type: string | null | undefined;
const teamId = variables && variables.teamId ? variables.teamId : undefined;
const defaultInstall = variables && variables.defaultInstall ? variables.defaultInstall : undefined;
const returnTo = options?.returnTo
? options.returnTo
: variables && variables.returnTo
? variables.returnTo
: undefined;
if (variables === "") {
type = _type;
} else {
type = variables.type;
}
if (type?.endsWith("_other_calendar")) {
type = type.split("_other_calendar")[0];
}
if (options?.installGoogleVideo && type !== "google_calendar")
throw new Error("Could not install Google Meet");
const state: IntegrationOAuthCallbackState = {
onErrorReturnTo,
fromApp: true,
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
...(teamId && { teamId }),
...(returnTo && { returnTo }),
...(defaultInstall && { defaultInstall }),
};
const stateStr = JSON.stringify(state);
const searchParams = generateSearchParamString({
stateStr,
teamId,
returnTo,
});
const res = await fetch(`/api/integrations/${type}/add${searchParams}`);
if (!res.ok) {
const errorBody = await res.json();
throw new Error(errorBody.message || "Something went wrong");
}
const json = await res.json();
const externalUrl = /https?:\/\//.test(json?.url) && !json?.url?.startsWith(window.location.origin);
// Check first that the URL is absolute, then check that it is of different origin from the current.
if (externalUrl) {
// TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks
gotoUrl(json.url, json.newTab);
return { setupPending: !json.newTab };
} else if (json.url) {
gotoUrl(json.url, json.newTab);
return {
setupPending:
json?.url?.endsWith("/setup") || json?.url?.includes("/apps/installation/event-types"),
};
} else if (returnTo) {
gotoUrl(returnTo, false);
return { setupPending: true };
} else {
return { setupPending: false };
}
},
});
return mutation;
}
export default useAddAppMutation;
const generateSearchParamString = ({
stateStr,
teamId,
returnTo,
}: {
stateStr: string;
teamId?: number;
returnTo?: string;
}) => {
const url = new URL("https://example.com"); // Base URL can be anything since we only care about the search params
url.searchParams.append("state", stateStr);
if (teamId !== undefined) {
url.searchParams.append("teamId", teamId.toString());
}
if (returnTo) {
url.searchParams.append("returnTo", returnTo);
}
// Return the search string part of the URL
return url.search;
};

View File

@ -0,0 +1,38 @@
import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppCardApp } from "../types";
function useIsAppEnabled(app: EventTypeAppCardApp) {
const { getAppData, setAppData } = useAppContextWithSchema();
const [enabled, setEnabled] = useState(() => {
const isAppEnabled = getAppData("enabled");
if (!app.credentialOwner) {
return isAppEnabled ?? false; // Default to false if undefined
}
const credentialId = getAppData("credentialId");
const isAppEnabledForCredential =
isAppEnabled &&
(app.userCredentialIds.some((id) => id === credentialId) ||
app.credentialOwner.credentialId === credentialId);
return isAppEnabledForCredential ?? false; // Default to false if undefined
});
const updateEnabled = (newValue: boolean) => {
if (!newValue) {
setAppData("credentialId", undefined);
}
if (newValue && (app.userCredentialIds?.length || app.credentialOwner?.credentialId)) {
setAppData("credentialId", app.credentialOwner?.credentialId || app.userCredentialIds[0]);
}
setEnabled(newValue);
};
return { enabled, updateEnabled };
}
export default useIsAppEnabled;

View File

@ -0,0 +1,8 @@
---
items:
- iframe: { src: https://www.youtube.com/embed/tExtOJqnI0Q }
- 1.jpg
- 2.png
---
{DESCRIPTION}

View File

@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import config from "../config.json";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const appType = config.type;
try {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
type: appType,
userId: req.session.user.id,
},
});
if (alreadyInstalled) {
throw new Error("Already installed");
}
const installation = await prisma.credential.create({
data: {
type: appType,
key: {},
userId: req.session.user.id,
appId: "alby",
},
});
if (!installation) {
throw new Error("Unable to create user credential for Alby");
}
} catch (error: unknown) {
if (error instanceof Error) {
return res.status(500).json({ message: error.message });
}
return res.status(500);
}
return res.status(200).json({ url: "/apps/alby/setup" });
}

View File

@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as webhook, config } from "./webhook";

View File

@ -0,0 +1,125 @@
import type { NextApiRequest, NextApiResponse } from "next";
import getRawBody from "raw-body";
import { z } from "zod";
import { albyCredentialKeysSchema } from "@calcom/app-store/alby/lib";
import parseInvoice from "@calcom/app-store/alby/lib/parseInvoice";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
import prisma from "@calcom/prisma";
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const bodyRaw = await getRawBody(req);
const headers = req.headers;
const bodyAsString = bodyRaw.toString();
const parseHeaders = webhookHeadersSchema.safeParse(headers);
if (!parseHeaders.success) {
console.error(parseHeaders.error);
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
const { data: parsedHeaders } = parseHeaders;
const parse = eventSchema.safeParse(JSON.parse(bodyAsString));
if (!parse.success) {
console.error(parse.error);
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
const { data: parsedPayload } = parse;
if (parsedPayload.metadata?.payer_data?.appId !== "cal.com") {
throw new HttpCode({ statusCode: 204, message: "Payment not for cal.com" });
}
const payment = await prisma.payment.findFirst({
where: {
uid: parsedPayload.metadata.payer_data.referenceId,
},
select: {
id: true,
amount: true,
bookingId: true,
booking: {
select: {
user: {
select: {
credentials: {
where: {
type: "alby_payment",
},
},
},
},
},
},
},
});
if (!payment) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
const key = payment.booking?.user?.credentials?.[0].key;
if (!key) throw new HttpCode({ statusCode: 204, message: "Credentials not found" });
const parseCredentials = albyCredentialKeysSchema.safeParse(key);
if (!parseCredentials.success) {
console.error(parseCredentials.error);
throw new HttpCode({ statusCode: 500, message: "Credentials not valid" });
}
const credentials = parseCredentials.data;
const albyInvoice = await parseInvoice(bodyAsString, parsedHeaders, credentials.webhook_endpoint_secret);
if (!albyInvoice) throw new HttpCode({ statusCode: 204, message: "Invoice not found" });
if (albyInvoice.amount !== payment.amount) {
throw new HttpCode({ statusCode: 400, message: "invoice amount does not match payment amount" });
}
return await handlePaymentSuccess(payment.id, payment.bookingId);
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
return res.status(err.statusCode || 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
}
}
const payerDataSchema = z
.object({
appId: z.string().optional(),
referenceId: z.string().optional(),
})
.optional();
const metadataSchema = z
.object({
payer_data: payerDataSchema,
})
.optional();
const eventSchema = z.object({
metadata: metadataSchema,
});
const webhookHeadersSchema = z
.object({
"svix-id": z.string(),
"svix-timestamp": z.string(),
"svix-signature": z.string(),
})
.passthrough();

View File

@ -0,0 +1,192 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import QRCode from "react-qr-code";
import z from "zod";
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, showToast } from "@calcom/ui";
import { Spinner } from "@calcom/ui/components/icon/Spinner";
interface IAlbyPaymentComponentProps {
payment: {
// Will be parsed on render
data: unknown;
};
paymentPageProps: PaymentPageProps;
}
// Create zod schema for data
const PaymentAlbyDataSchema = z.object({
invoice: z
.object({
paymentRequest: z.string(),
})
.required(),
});
export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
const { payment } = props;
const { data } = payment;
const [showQRCode, setShowQRCode] = useState(window.webln === undefined);
const [isPaying, setPaying] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const wrongUrl = (
<>
<p className="mt-3 text-center">Couldn&apos;t obtain payment URL</p>
</>
);
const parsedData = PaymentAlbyDataSchema.safeParse(data);
if (!parsedData.success || !parsedData.data?.invoice?.paymentRequest) {
return wrongUrl;
}
const paymentRequest = parsedData.data.invoice.paymentRequest;
return (
<div className="mb-4 mt-8 flex h-full w-full flex-col items-center justify-center gap-4">
<PaymentChecker {...props.paymentPageProps} />
{isPaying && <Spinner className="mt-12 h-8 w-8" />}
{!isPaying && (
<>
{!showQRCode && (
<div className="flex gap-4">
<Button color="secondary" onClick={() => setShowQRCode(true)}>
Show QR
</Button>
{window.webln && (
<Button
onClick={async () => {
try {
if (!window.webln) {
throw new Error("webln not found");
}
setPaying(true);
await window.webln.enable();
window.webln.sendPayment(paymentRequest);
} catch (error) {
setPaying(false);
alert((error as Error).message);
}
}}>
Pay Now
</Button>
)}
</div>
)}
{showQRCode && (
<>
<div className="flex items-center justify-center gap-2">
<p className="text-xs">Waiting for payment...</p>
<Spinner className="h-4 w-4" />
</div>
<p className="text-sm">Click or scan the invoice below to pay</p>
<Link
href={`lightning:${paymentRequest}`}
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent p-2
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
<QRCode size={128} value={paymentRequest} />
</Link>
<Button
size="sm"
color="secondary"
onClick={() => copyToClipboard(paymentRequest)}
className="text-subtle rounded-md"
StartIcon={isCopied ? "clipboard-check" : "clipboard"}>
Copy Invoice
</Button>
<Link target="_blank" href="https://getalby.com" className="link mt-4 text-sm underline">
Don&apos;t have a lightning wallet?
</Link>
</>
)}
</>
)}
<Link target="_blank" href="https://getalby.com">
<div className="mt-4 flex items-center text-sm">
Powered by&nbsp;
<img title="Alby" src="/app-store/alby/logo.svg" alt="Alby" className="h-8 dark:hidden" />
<img
title="Alby"
src="/app-store/alby/logo-dark.svg"
alt="Alby"
className="hidden h-8 dark:block"
/>
</div>
</Link>
</div>
);
};
type PaymentCheckerProps = PaymentPageProps;
function PaymentChecker(props: PaymentCheckerProps) {
// TODO: move booking success code to a common lib function
// TODO: subscribe rather than polling
const searchParams = useCompatSearchParams();
const bookingSuccessRedirect = useBookingSuccessRedirect();
const utils = trpc.useUtils();
const { t } = useLocale();
useEffect(() => {
if (searchParams === null) {
return;
}
// use closure to ensure non-nullability
const sp = searchParams;
const interval = setInterval(() => {
(async () => {
if (props.booking.status === "ACCEPTED") {
return;
}
const { booking: bookingResult } = await utils.viewer.bookings.find.fetch({
bookingUid: props.booking.uid,
});
if (bookingResult?.paid) {
showToast("Payment successful", "success");
const params: {
uid: string;
email: string | null;
location: string;
} = {
uid: props.booking.uid,
email: sp.get("email"),
location: t("web_conferencing_details_to_follow"),
};
bookingSuccessRedirect({
successRedirectUrl: props.eventType.successRedirectUrl,
query: params,
booking: props.booking,
forwardParamsSuccessRedirect: props.eventType.forwardParamsSuccessRedirect,
});
}
})();
}, 1000);
return () => clearInterval(interval);
}, [
bookingSuccessRedirect,
props.booking,
props.booking.id,
props.booking.status,
props.eventType.id,
props.eventType.successRedirectUrl,
props.eventType.forwardParamsSuccessRedirect,
props.payment.success,
searchParams,
t,
utils.viewer.bookings,
]);
return null;
}

View File

@ -0,0 +1,30 @@
import { fiat } from "@getalby/lightning-tools";
import React from "react";
import { Tooltip } from "@calcom/ui";
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
type AlbyPriceComponentProps = {
displaySymbol: boolean;
price: number;
formattedPrice: string;
};
export function AlbyPriceComponent({ displaySymbol, price, formattedPrice }: AlbyPriceComponentProps) {
const [fiatValue, setFiatValue] = React.useState<string>("loading...");
React.useEffect(() => {
(async () => {
const unformattedFiatValue = await fiat.getFiatValue({ satoshi: price, currency: "USD" });
setFiatValue(`$${unformattedFiatValue.toFixed(2)}`);
})();
}, [price]);
return (
<Tooltip content={fiatValue}>
<div className="inline-flex items-center justify-center">
{displaySymbol && <SatSymbol className="h-4 w-4" />}
{formattedPrice}
</div>
</Tooltip>
);
}

View File

@ -0,0 +1,55 @@
import { usePathname, useSearchParams } from "next/navigation";
import { useState, useMemo } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps";
import type { appDataSchema } from "../zod";
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
app,
eventType,
eventTypeFormMetadata,
}) {
const searchParams = useSearchParams();
const { t } = useLocale();
/** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/
const pathname = usePathname();
const asPath = useMemo(
() => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`,
[pathname, searchParams]
);
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
return (
<AppCard
returnTo={WEBAPP_URL + asPath}
app={app}
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);
}}
description={<>Add bitcoin lightning payments to your events</>}
disableSwitch={shouldDisableSwitch}
switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
};
export default EventTypeAppCard;

View File

@ -0,0 +1,117 @@
import { useState, useEffect } from "react";
import { currencyOptions } from "@calcom/app-store/alby/lib/currencyOptions";
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, TextField } from "@calcom/ui";
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
import { PaypalPaymentOptions as paymentOptions } from "../zod";
type Option = { value: string; label: string };
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
eventType,
getAppData,
setAppData,
}) => {
const { t } = useLocale();
const price = getAppData("price");
const currency = getAppData("currency");
const [selectedCurrency, setSelectedCurrency] = useState(
currencyOptions.find((c) => c.value === currency) || currencyOptions[0]
);
const paymentOption = getAppData("paymentOption");
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
label: paymentOptions[0].label,
value: paymentOptions[0].value,
};
const seatsEnabled = !!eventType.seatsPerTimeSlot;
const [requirePayment] = useState(getAppData("enabled"));
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
// make sure a currency is selected
useEffect(() => {
if (!currency && requirePayment) {
setAppData("currency", selectedCurrency.value);
}
}, [currency, selectedCurrency, setAppData, requirePayment]);
return (
<>
{recurringEventDefined ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-2 block items-center sm:flex">
<TextField
label="Price"
labelSrOnly
addOnLeading={<SatSymbol className="h-4 w-4" />}
addOnSuffix={selectedCurrency.unit || selectedCurrency.value}
type="number"
required
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
placeholder="Price"
onChange={(e) => {
setAppData("price", Number(e.target.value));
if (currency) {
setAppData("currency", currency);
}
}}
value={price && price > 0 ? price : undefined}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setAppData("currency", e.value);
}
}}
/>
</div>
<div className="mt-2 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
Payment option
</label>
<Select<Option>
defaultValue={
paymentOptionSelectValue
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
}
options={paymentOptions.map((option) => {
return { ...option, label: t(option.label) || option.label };
})}
onChange={(input) => {
if (input) setAppData("paymentOption", input.value);
}}
className="mb-1 h-[38px] w-full"
isDisabled={seatsEnabled}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)
)}
</>
);
};
export default EventTypeAppSettingsInterface;

View File

@ -0,0 +1,19 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Alby",
"slug": "alby",
"type": "alby_payment",
"logo": "icon.svg",
"url": "https://getalby.com",
"variant": "payment",
"categories": ["payment"],
"publisher": "Alby",
"email": "support@getalby.com",
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your Cal.com meetings.\r",
"extendsFeature": "EventType",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-app-card",
"dirName": "alby",
"isOAuth": false
}

View File

@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";

View File

@ -0,0 +1,141 @@
import { LightningAddress } from "@getalby/lightning-tools";
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import type z from "zod";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
import { albyCredentialKeysSchema } from "./albyCredentialKeysSchema";
const log = logger.getSubLogger({ prefix: ["payment-service:alby"] });
export class PaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof albyCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
const keyParsing = albyCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
}
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"]
) {
try {
const booking = await prisma.booking.findFirst({
select: {
uid: true,
title: true,
},
where: {
id: bookingId,
},
});
if (!booking || !this.credentials?.account_lightning_address) {
throw new Error("Alby: Booking or Lightning address not found");
}
const uid = uuidv4();
const lightningAddress = new LightningAddress(this.credentials.account_lightning_address);
await lightningAddress.fetch();
const invoice = await lightningAddress.requestInvoice({
satoshi: payment.amount,
payerdata: {
appId: "cal.com",
referenceId: uid,
},
});
console.log("Created invoice", invoice, uid);
const paymentData = await prisma.payment.create({
data: {
uid,
app: {
connect: {
slug: "alby",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
externalId: invoice.paymentRequest,
currency: payment.currency,
data: Object.assign(
{},
{ invoice: { ...invoice, isPaid: false } }
) as unknown as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
},
});
if (!paymentData) {
throw new Error();
}
return paymentData;
} catch (error) {
log.error("Alby: Payment could not be created", bookingId, safeStringify(error));
throw new Error(ErrorCode.PaymentCreationFailure);
}
}
async update(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async refund(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async collectCard(
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
_bookingId: number,
_bookerEmail: string,
_paymentOption: PaymentOption
): Promise<Payment> {
throw new Error("Method not implemented");
}
chargeCard(
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
_bookingId: number
): Promise<Payment> {
throw new Error("Method not implemented.");
}
getPaymentPaidStatus(): Promise<string> {
throw new Error("Method not implemented.");
}
getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
afterPayment(
_event: CalendarEvent,
_booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
},
_paymentData: Payment
): Promise<void> {
return Promise.resolve();
}
deletePayment(_paymentId: number): Promise<boolean> {
return Promise.resolve(false);
}
isSetupAlready(): boolean {
return !!this.credentials;
}
}

View File

@ -0,0 +1,9 @@
import z from "zod";
export const albyCredentialKeysSchema = z.object({
account_id: z.string(),
account_email: z.string(),
account_lightning_address: z.string(),
webhook_endpoint_id: z.string(),
webhook_endpoint_secret: z.string(),
});

View File

@ -0,0 +1 @@
export const currencyOptions = [{ label: "BTC", value: "BTC", unit: "sats" }];

View File

@ -0,0 +1,13 @@
import { z } from "zod";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
export const getAlbyKeys = async () => {
const appKeys = await getAppKeysFromSlug("alby");
return appKeysSchema.parse(appKeys);
};
const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});

View File

@ -0,0 +1,6 @@
import type { Invoice as AlbyInvoice } from "@getalby/sdk/dist/types";
export * from "./PaymentService";
export * from "./albyCredentialKeysSchema";
export type { AlbyInvoice };

View File

@ -0,0 +1,22 @@
import type { Invoice } from "@getalby/sdk/dist/types";
import { Webhook } from "svix";
export default function parseInvoice(
body: string,
headers: {
"svix-id": string;
"svix-timestamp": string;
"svix-signature": string;
},
webhookEndpointSecret: string
): Invoice | null {
try {
const wh = new Webhook(webhookEndpointSecret);
return wh.verify(body, headers) as Invoice;
} catch (err) {
// Looks like alby might sent multiple webhooks for the same invoice but it should only work once
// TODO: remove the Alby webhook when uninstalling the Alby app
console.error(err);
}
return null;
}

View File

@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/alby",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*",
"@getalby/lightning-tools": "^4.0.2",
"@getalby/sdk": "^2.4.0",
"@webbtc/webln-types": "^2.0.1",
"react-qr-code": "^2.0.12",
"svix": "^0.85.1"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your Cal.com meetings.\r"
}

View File

@ -0,0 +1,54 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import { getAlbyKeys } from "../../lib/getAlbyKeys";
import type { IAlbySetupProps } from "./index";
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const notFound = { notFound: true } as const;
if (typeof ctx.params?.slug !== "string") return notFound;
const { req, res } = ctx;
const session = await getServerSession({ req, res });
if (!session?.user?.id) {
const redirect = { redirect: { permanent: false, destination: "/auth/login" } } as const;
return redirect;
}
const credentials = await prisma.credential.findFirst({
where: {
type: "alby_payment",
userId: session?.user.id,
},
});
const { client_id: clientId, client_secret: clientSecret } = await getAlbyKeys();
const props: IAlbySetupProps = {
email: null,
lightningAddress: null,
clientId,
clientSecret,
};
if (credentials?.key) {
const { account_lightning_address, account_email } = credentials.key as {
account_lightning_address?: string;
account_email?: string;
};
if (account_lightning_address) {
props.lightningAddress = account_lightning_address;
}
if (account_email) {
props.email = account_email;
}
}
return {
props,
};
};

View File

@ -0,0 +1,194 @@
import { auth, Client, webln } from "@getalby/sdk";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useCallback, useEffect } from "react";
import { Toaster } from "react-hot-toast";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Badge, Button, showToast } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import { albyCredentialKeysSchema } from "../../lib/albyCredentialKeysSchema";
export interface IAlbySetupProps {
email: string | null;
lightningAddress: string | null;
clientId: string;
clientSecret: string;
}
export default function AlbySetup(props: IAlbySetupProps) {
const params = useCompatSearchParams();
if (params?.get("callback") === "true") {
return <AlbySetupCallback />;
}
return <AlbySetupPage {...props} />;
}
function AlbySetupCallback() {
const [error, setError] = useState<string | null>(null);
const searchParams = useCompatSearchParams();
useEffect(() => {
if (!searchParams) {
return;
}
if (!window.opener) {
setError("Something went wrong. Opener not available. Please contact support@getalby.com");
return;
}
const code = searchParams?.get("code");
const error = searchParams?.get("error");
if (!code) {
setError("declined");
}
if (error) {
setError(error);
alert(error);
return;
}
window.opener.postMessage({
type: "alby:oauth:success",
payload: { code },
});
window.close();
}, [searchParams]);
return (
<div>
{error && <p>Authorization failed: {error}</p>}
{!error && <p>Connecting...</p>}
</div>
);
}
function AlbySetupPage(props: IAlbySetupProps) {
const router = useRouter();
const { t } = useLocale();
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment", appId: "alby" });
const [albyPaymentAppCredentials] = integrations.data?.items || [];
const [credentialId] = albyPaymentAppCredentials?.userCredentialIds || [-1];
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({
onSuccess: () => {
showToast(t("keys_have_been_saved"), "success");
router.push("/event-types");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const connectWithAlby = useCallback(async () => {
const authClient = new auth.OAuth2User({
client_id: props.clientId,
client_secret: props.clientSecret,
callback: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/apps/alby/setup?callback=true`,
scopes: ["invoices:read", "account:read"],
user_agent: "cal.com",
});
const weblnOAuthProvider = new webln.OauthWeblnProvider({
auth: authClient,
});
await weblnOAuthProvider.enable();
const client = new Client(authClient);
const accountInfo = await client.accountInformation({});
// TODO: add a way to delete the endpoint when the app is uninstalled
const webhookEndpoint = await client.createWebhookEndpoint({
filter_types: ["invoice.incoming.settled"],
url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/alby/webhook`,
description: "Cal.com",
});
saveKeysMutation.mutate({
credentialId,
key: albyCredentialKeysSchema.parse({
account_id: accountInfo.identifier,
account_email: accountInfo.email,
account_lightning_address: accountInfo.lightning_address,
webhook_endpoint_id: webhookEndpoint.id,
webhook_endpoint_secret: webhookEndpoint.endpoint_secret,
}),
});
}, [credentialId, props.clientId, props.clientSecret, saveKeysMutation]);
if (integrations.isPending) {
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}
const albyIcon = (
<>
<img className="h-12 w-12 dark:hidden" src="/api/app-store/alby/icon-borderless.svg" alt="Alby Icon" />
<img
className="hidden h-12 w-12 dark:block"
src="/api/app-store/alby/icon-borderless-dark.svg"
alt="Alby Icon"
/>
</>
);
return (
<div className="bg-default flex h-screen">
{showContent ? (
<div className="flex w-full items-center justify-center p-4">
<div className="bg-default border-subtle m-auto flex max-w-[43em] flex-col items-center justify-center gap-4 overflow-auto rounded border p-4 md:p-10">
{!props.lightningAddress ? (
<>
<p className="text-default">
Create or connect to an existing Alby account to receive lightning payments for your paid
bookings.
</p>
<button
className="font-body flex h-10 w-56 items-center justify-center gap-2 rounded-md font-bold text-black shadow transition-all hover:brightness-90 active:scale-95"
style={{
background: "linear-gradient(180deg, #FFDE6E 63.72%, #F8C455 95.24%)",
}}
type="button"
onClick={connectWithAlby}>
{albyIcon}
<span className="mr-2">Connect with Alby</span>
</button>
</>
) : (
<>
{albyIcon}
<p>Alby Connected!</p>
<Badge>Email: {props.email}</Badge>
<Badge>Lightning Address: {props.lightningAddress}</Badge>
</>
)}
{/* TODO: remove when invoices are generated using user identifier */}
<div className="mt-4 rounded bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
<Icon name="info" className="mb-0.5 inline-flex h-4 w-4" /> Your Alby lightning address will be
used to generate invoices. If you update your lightning address, please disconnect and setup the
Alby app again.
</div>
<Link href="/apps/alby">
<Button color="secondary">Go to App Store Listing</Button>
</Link>
</div>
</div>
) : (
<div className="ml-5 mt-5">
<div>Alby</div>
<div className="mt-3">
<Link href="/apps/alby" passHref={true} legacyBehavior>
<Button>{t("go_to_app_store")}</Button>
</Link>
</div>
</div>
)}
<Toaster position="bottom-right" />
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@ -0,0 +1,16 @@
<svg width="490" height="489" viewBox="0 0 490 489" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M90.0776 181.767C97.3318 182.509 104.344 181.661 110.765 179.495L110.917 179.632L111.159 179.874C83.8531 210.92 64.5892 251.099 56.6382 295.564C51.0044 327.034 67.8907 357.293 95.8477 370.605C141.418 392.293 192.395 404.423 246.143 404.423C299.891 404.423 349.823 392.535 395.03 371.272C423.093 358.066 440.1 327.822 434.557 296.306C426.697 251.599 407.024 211.481 379.976 179.965C345.825 140.15 380.082 179.798 380.082 179.798C380.082 179.798 391.819 182.342 398.119 181.903C422.427 180.222 442.024 160.368 443.387 136.03C445.007 106.877 421.018 82.9028 391.865 84.5384C367.86 85.8863 348.127 105.014 346.082 128.973C345.446 136.439 346.506 143.618 348.914 150.145L348.521 150.539L348.294 150.766C318.822 129.033 283.671 116.221 245.552 116.221C207.433 116.221 172.04 129.124 142.493 151.008L142.266 150.781L141.903 150.418L141.191 149.706C143.508 143.224 144.507 136.106 143.826 128.715C141.66 104.878 121.972 85.9015 98.0739 84.5536C68.8599 82.918 44.8708 106.907 46.5065 136.091C47.8392 159.777 66.4671 179.344 90.0624 181.782L90.0776 181.767Z" fill="white" stroke="white" stroke-width="19.962"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M104.98 351.417C85.2161 342.012 73.7213 320.886 77.5681 299.335C85.6099 254.325 106.07 214.797 134.451 186.083C138.783 181.691 143.311 177.572 147.991 173.695C175.69 150.872 209.281 137.514 245.492 137.514C281.703 137.514 315.051 150.781 342.69 173.453C347.385 177.299 351.913 181.434 356.26 185.811C384.928 214.691 405.555 254.582 413.537 300.016C417.338 321.597 405.737 342.724 385.913 352.053C343.478 372.014 296.09 383.175 246.098 383.175C196.105 383.175 147.718 371.787 104.95 351.417H104.98Z" fill="#FFDF6F"/>
<path d="M95.5749 121.279L82.6111 134.243L113.446 165.078C118.822 161.988 123.35 157.566 126.561 152.265L95.5749 121.279Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M395.287 96.6087C375.448 96.2756 358.183 113.056 357.986 132.895C357.91 140.15 359.955 146.934 363.529 152.659L394.894 121.294L407.857 134.258L376.796 165.305C382.944 168.727 390.184 170.454 397.847 169.803C415.505 168.303 429.772 153.977 431.195 136.318C432.922 114.903 416.232 96.9571 395.287 96.6087ZM363.529 152.659L349.233 166.94C353.927 170.817 358.44 174.967 362.772 179.329L376.796 165.305C371.374 162.276 366.8 157.914 363.529 152.659ZM422.593 298.441C414.339 251.463 392.879 209.709 362.772 179.329L356.29 185.811C351.943 181.434 347.415 177.314 342.72 173.468L349.233 166.94C320.034 142.709 284.353 128.367 245.507 128.367C206.661 128.367 170.753 142.8 141.494 167.198L148.006 173.71C143.311 177.572 138.798 181.706 134.466 186.098L127.985 179.616C98.1648 209.815 76.8714 251.19 68.5419 297.745C63.9379 323.551 77.7498 348.615 101.042 359.701C145.007 380.631 194.212 392.353 246.128 392.353C298.044 392.353 346.219 380.874 389.85 360.353C413.219 349.342 427.152 324.293 422.593 298.441ZM382.036 343.754C340.812 363.169 294.742 374.013 246.128 374.013C197.514 374.013 150.489 362.942 108.917 343.148C92.6976 335.424 83.5048 318.235 86.5943 300.97C94.3635 257.505 113.991 219.81 140.948 192.565L154.533 180.237C180.642 158.989 212.052 146.692 245.507 146.692C278.961 146.692 310.129 158.883 336.193 179.98L349.808 192.293C377.038 219.689 396.832 257.732 404.541 301.606C407.585 318.917 398.316 336.106 382.036 343.754ZM126.576 152.28C123.365 157.581 118.837 161.988 113.461 165.093L127.985 179.616C132.316 175.225 136.814 171.09 141.494 167.198L126.576 152.28ZM126.576 152.28C130.059 146.541 132.013 139.771 131.877 132.562C131.483 112.495 114.536 96.215 94.4695 96.6087C73.5548 97.0328 56.9411 114.949 58.6676 136.318C60.1063 154.067 74.5089 168.425 92.2584 169.818C100.012 170.424 107.297 168.606 113.461 165.093L82.6264 134.258L95.5902 121.294L126.576 152.28Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M407.857 134.258L376.796 165.305C371.374 162.276 366.8 157.914 363.529 152.659L394.894 121.295L407.857 134.258Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M362.772 179.329L356.29 185.811C351.943 181.434 347.415 177.315 342.72 173.468L349.232 166.94C353.927 170.817 358.44 174.967 362.772 179.329Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M148.006 173.71C143.311 177.572 138.798 181.706 134.467 186.098L127.985 179.616C132.316 175.225 136.814 171.09 141.494 167.198L148.006 173.71Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.576 152.28C123.366 157.581 118.837 161.988 113.461 165.093L82.6265 134.258L95.5903 121.295L126.576 152.28Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M407.857 134.243L376.796 165.29C371.374 162.261 366.8 157.899 363.529 152.644L394.894 121.279L407.857 134.243Z" fill="black"/>
<path d="M349.232 166.925L342.705 173.453C347.4 177.299 351.928 181.434 356.275 185.811L362.757 179.329C358.425 174.952 353.912 170.817 349.232 166.94V166.925Z" fill="black"/>
<path d="M141.478 167.183C136.799 171.075 132.301 175.225 127.969 179.601L134.451 186.083C138.783 181.691 143.311 177.572 147.99 173.695L141.478 167.183Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.873 329.064C124.971 322.582 115.536 305.665 121.048 289.4C138.041 239.241 187.624 202.924 246.143 202.924C304.662 202.924 354.245 239.226 371.253 289.4C376.766 305.665 367.33 322.597 351.429 329.064C318.943 342.3 283.399 349.585 246.158 349.585C208.918 349.585 173.373 342.3 140.888 329.064H140.873Z" fill="black"/>
<path d="M288.321 305.59C305.2 305.59 318.883 294.646 318.883 281.146C318.883 267.646 305.2 256.703 288.321 256.703C271.442 256.703 257.759 267.646 257.759 281.146C257.759 294.646 271.442 305.59 288.321 305.59Z" fill="white"/>
<path d="M200.845 305.605C217.724 305.605 231.407 294.661 231.407 281.161C231.407 267.661 217.724 256.718 200.845 256.718C183.966 256.718 170.283 267.661 170.283 281.161C170.283 294.661 183.966 305.605 200.845 305.605Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -0,0 +1,11 @@
<svg width="490" height="489" viewBox="0 0 490 489" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="36.675" cy="36.675" r="36.675" transform="matrix(-1 0 0 1 131.308 101.468)" fill="black"/>
<path d="M88.52 132.641L156.98 201.101" stroke="black" stroke-width="18.3375"/>
<circle cx="394.145" cy="138.143" r="36.675" fill="black"/>
<path d="M400.869 132.641L332.409 201.101" stroke="black" stroke-width="18.3375"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M105.043 356.993C85.276 347.583 73.7721 326.451 77.6256 304.901C94.165 212.406 163.093 143.033 245.611 143.033C328.33 143.033 397.391 212.743 413.716 305.573C417.513 327.163 405.915 348.285 386.081 357.618C343.64 377.591 296.235 388.755 246.222 388.755C195.69 388.755 147.82 377.358 105.043 356.993Z" fill="#FFDF6F"/>
<path d="M413.716 305.573L422.746 303.985L413.716 305.573ZM386.081 357.618L382.177 349.322L386.081 357.618ZM86.6512 306.515C102.633 217.14 168.694 152.201 245.611 152.201V133.864C157.491 133.864 85.6973 207.672 68.6 303.287L86.6512 306.515ZM245.611 152.201C322.713 152.201 388.911 217.457 404.686 307.161L422.746 303.985C405.872 208.028 333.946 133.864 245.611 133.864V152.201ZM382.177 349.322C340.932 368.732 294.857 379.586 246.222 379.586V397.924C297.613 397.924 346.348 386.449 389.985 365.914L382.177 349.322ZM246.222 379.586C197.082 379.586 150.556 368.505 108.984 348.714L101.102 365.272C145.084 386.21 194.298 397.924 246.222 397.924V379.586ZM404.686 307.161C407.73 324.47 398.462 341.659 382.177 349.322L389.985 365.914C413.369 354.91 427.296 329.855 422.746 303.985L404.686 307.161ZM68.6 303.287C63.9824 329.11 77.7983 354.178 101.102 365.272L108.984 348.714C92.7538 340.988 83.5618 323.792 86.6512 306.515L68.6 303.287Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.311 333.998C124.4 327.518 114.962 310.589 120.475 294.317C137.477 244.138 187.075 207.825 245.611 207.825C304.148 207.825 353.745 244.138 370.748 294.317C376.261 310.589 366.823 327.518 350.912 333.998C318.414 347.233 282.864 354.525 245.611 354.525C208.358 354.525 172.808 347.233 140.311 333.998Z" fill="black"/>
<ellipse cx="287.788" cy="286.065" rx="30.5625" ry="24.45" fill="white"/>
<ellipse cx="200.295" cy="286.08" rx="30.5625" ry="24.45" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,12 @@
<svg width="554" height="554" viewBox="0 0 554 554" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="554" height="554" rx="64" fill="white"/>
<path d="M127.7 202.4C107.431 202.4 90.9999 185.969 90.9999 165.7C90.9999 145.431 107.431 129 127.7 129C147.969 129 164.4 145.431 164.4 165.7C164.4 185.969 147.969 202.4 127.7 202.4Z" fill="black"/>
<path d="M121.6 160.2L190.1 228.7" stroke="black" stroke-width="18.3"/>
<path d="M427.2 202.4C447.469 202.4 463.9 185.969 463.9 165.7C463.9 145.431 447.469 129 427.2 129C406.931 129 390.5 145.431 390.5 165.7C390.5 185.969 406.931 202.4 427.2 202.4Z" fill="black"/>
<path d="M434 160.2L365.5 228.7" stroke="black" stroke-width="18.3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M138.1 384.6C128.451 380.122 120.543 372.597 115.592 363.182C110.641 353.768 108.922 342.987 110.7 332.5C127.3 240 196.2 170.6 278.7 170.6C361.4 170.6 430.5 240.3 446.8 333.2C448.539 343.695 446.779 354.47 441.792 363.867C436.805 373.263 428.866 380.759 419.2 385.2C375.441 405.799 327.664 416.454 279.3 416.4C228.8 416.4 180.9 405 138.1 384.6Z" fill="#FFDF6F"/>
<path d="M119.8 334.2C135.8 244.7 201.8 179.8 278.8 179.8V161.5C190.6 161.5 118.8 235.3 101.7 330.9L119.7 334.1L119.8 334.2ZM278.8 179.8C355.8 179.8 422 245.1 437.8 334.8L455.8 331.6C439 235.6 367 161.5 278.8 161.5V179.8ZM415.3 376.9C372.76 396.917 326.314 407.265 279.3 407.2V425.5C330.7 425.5 379.4 414 423.1 393.5L415.3 376.9ZM279.3 407.2C230.2 407.2 183.7 396.1 142.1 376.3L134.2 392.9C178.2 413.9 227.4 425.5 279.3 425.5V407.2ZM437.8 334.8C440.8 352.1 431.6 369.3 415.3 376.9L423.1 393.5C446.5 382.5 460.4 357.5 455.8 331.5L437.8 334.8ZM101.7 330.8C99.5558 343.269 101.577 356.098 107.451 367.303C113.325 378.509 122.725 387.47 134.2 392.8L142.1 376.3C134.267 372.688 127.84 366.599 123.81 358.973C119.78 351.347 118.371 342.606 119.8 334.1L101.6 330.9L101.7 330.8Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.4 361.6C157.5 355.1 148.1 338.2 153.6 321.9C170.6 271.7 220.2 235.4 278.7 235.4C337.2 235.4 386.8 271.7 403.8 321.9C409.4 338.2 399.9 355.1 384 361.6C350.559 375.19 314.796 382.153 278.7 382.1C241.5 382.1 205.9 374.8 173.4 361.6Z" fill="black"/>
<path d="M320.9 338.1C337.8 338.1 351.5 327.176 351.5 313.7C351.5 300.224 337.8 289.3 320.9 289.3C304 289.3 290.3 300.224 290.3 313.7C290.3 327.176 304 338.1 320.9 338.1Z" fill="white"/>
<path d="M233.4 338.1C250.3 338.1 264 327.176 264 313.7C264 300.224 250.3 289.3 233.4 289.3C216.5 289.3 202.8 300.224 202.8 313.7C202.8 327.176 216.5 338.1 233.4 338.1Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,17 @@
<svg width="1211" height="525" viewBox="0 0 1211 525" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M90.0776 187.767C97.3318 188.509 104.344 187.661 110.765 185.495L110.917 185.632L111.159 185.874C83.8531 216.92 64.5892 257.099 56.6382 301.564C51.0044 333.034 67.8907 363.293 95.8477 376.605C141.418 398.293 192.395 410.423 246.143 410.423C299.891 410.423 349.823 398.535 395.03 377.272C423.093 364.066 440.1 333.822 434.557 302.306C426.697 257.599 407.024 217.481 379.976 185.965C345.825 146.15 380.082 185.798 380.082 185.798C380.082 185.798 391.819 188.342 398.119 187.903C422.427 186.222 442.024 166.368 443.387 142.03C445.007 112.877 421.018 88.9028 391.865 90.5384C367.86 91.8863 348.127 111.014 346.082 134.973C345.446 142.439 346.506 149.618 348.914 156.145L348.521 156.539L348.294 156.766C318.822 135.033 283.671 122.221 245.552 122.221C207.433 122.221 172.04 135.124 142.493 157.008L142.266 156.781L141.903 156.418L141.191 155.706C143.508 149.224 144.507 142.106 143.826 134.715C141.66 110.878 121.972 91.9015 98.0739 90.5536C68.8599 88.918 44.8708 112.907 46.5065 142.091C47.8392 165.777 66.4671 185.344 90.0624 187.782L90.0776 187.767Z" fill="white" stroke="white" stroke-width="19.962"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M104.98 357.417C85.2161 348.012 73.7213 326.886 77.5681 305.335C85.6099 260.325 106.07 220.797 134.451 192.083C138.783 187.691 143.311 183.572 147.991 179.695C175.69 156.872 209.281 143.514 245.492 143.514C281.703 143.514 315.051 156.781 342.69 179.453C347.385 183.299 351.913 187.434 356.26 191.811C384.928 220.691 405.555 260.582 413.537 306.016C417.338 327.597 405.737 348.724 385.913 358.053C343.478 378.014 296.09 389.175 246.098 389.175C196.105 389.175 147.718 377.787 104.95 357.417H104.98Z" fill="#FFDF6F"/>
<path d="M95.5749 127.279L82.6111 140.243L113.446 171.078C118.822 167.988 123.35 163.566 126.561 158.265L95.5749 127.279Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M395.287 102.609C375.448 102.276 358.183 119.056 357.986 138.895C357.91 146.15 359.955 152.934 363.529 158.659L394.894 127.294L407.857 140.258L376.796 171.305C382.944 174.727 390.184 176.454 397.847 175.803C415.505 174.303 429.772 159.977 431.195 142.318C432.922 120.903 416.232 102.957 395.287 102.609ZM363.529 158.659L349.233 172.94C353.927 176.817 358.44 180.967 362.772 185.329L376.796 171.305C371.374 168.276 366.8 163.914 363.529 158.659ZM422.593 304.441C414.339 257.463 392.879 215.709 362.772 185.329L356.29 191.811C351.943 187.434 347.415 183.314 342.72 179.468L349.233 172.94C320.034 148.709 284.353 134.367 245.507 134.367C206.661 134.367 170.753 148.8 141.494 173.198L148.006 179.71C143.311 183.572 138.798 187.706 134.466 192.098L127.985 185.616C98.1648 215.815 76.8714 257.19 68.5419 303.745C63.9379 329.551 77.7498 354.615 101.042 365.701C145.007 386.631 194.212 398.353 246.128 398.353C298.044 398.353 346.219 386.874 389.85 366.353C413.219 355.342 427.152 330.293 422.593 304.441ZM382.036 349.754C340.812 369.169 294.742 380.013 246.128 380.013C197.514 380.013 150.489 368.942 108.917 349.148C92.6976 341.424 83.5048 324.235 86.5943 306.97C94.3635 263.505 113.991 225.81 140.948 198.565L154.533 186.237C180.642 164.989 212.052 152.692 245.507 152.692C278.961 152.692 310.129 164.883 336.193 185.98L349.808 198.293C377.038 225.689 396.832 263.732 404.541 307.606C407.585 324.917 398.316 342.106 382.036 349.754ZM126.576 158.28C123.365 163.581 118.837 167.988 113.461 171.093L127.985 185.616C132.316 181.225 136.814 177.09 141.494 173.198L126.576 158.28ZM126.576 158.28C130.059 152.541 132.013 145.771 131.877 138.562C131.483 118.495 114.536 102.215 94.4695 102.609C73.5548 103.033 56.9411 120.949 58.6676 142.318C60.1063 160.067 74.5089 174.425 92.2584 175.818C100.012 176.424 107.297 174.606 113.461 171.093L82.6264 140.258L95.5902 127.294L126.576 158.28Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M407.857 140.258L376.796 171.305C371.374 168.276 366.8 163.914 363.529 158.659L394.894 127.295L407.857 140.258Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M362.772 185.329L356.29 191.811C351.943 187.434 347.415 183.315 342.72 179.468L349.232 172.94C353.927 176.817 358.44 180.967 362.772 185.329Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M148.006 179.71C143.311 183.572 138.798 187.706 134.467 192.098L127.985 185.616C132.316 181.225 136.814 177.09 141.494 173.198L148.006 179.71Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.576 158.28C123.366 163.581 118.837 167.988 113.461 171.093L82.6265 140.258L95.5903 127.295L126.576 158.28Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M407.857 140.243L376.796 171.29C371.374 168.261 366.8 163.899 363.529 158.644L394.894 127.279L407.857 140.243Z" fill="black"/>
<path d="M349.232 172.925L342.705 179.453C347.4 183.299 351.928 187.434 356.275 191.811L362.757 185.329C358.425 180.952 353.912 176.817 349.232 172.94V172.925Z" fill="black"/>
<path d="M141.478 173.183C136.799 177.075 132.301 181.225 127.969 185.601L134.451 192.083C138.783 187.691 143.311 183.572 147.99 179.695L141.478 173.183Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.873 335.064C124.971 328.582 115.536 311.665 121.048 295.4C138.041 245.241 187.624 208.924 246.143 208.924C304.662 208.924 354.245 245.226 371.253 295.4C376.766 311.665 367.33 328.597 351.429 335.064C318.943 348.3 283.399 355.585 246.158 355.585C208.918 355.585 173.373 348.3 140.888 335.064H140.873Z" fill="black"/>
<path d="M288.321 311.59C305.2 311.59 318.883 300.646 318.883 287.146C318.883 273.646 305.2 262.703 288.321 262.703C271.442 262.703 257.759 273.646 257.759 287.146C257.759 300.646 271.442 311.59 288.321 311.59Z" fill="white"/>
<path d="M200.845 311.605C217.724 311.605 231.407 300.661 231.407 287.161C231.407 273.661 217.724 262.718 200.845 262.718C183.966 262.718 170.283 273.661 170.283 287.161C170.283 300.661 183.966 311.605 200.845 311.605Z" fill="white"/>
<path d="M674.375 263.384L652.621 204.2L631.826 263.384H674.375ZM624.468 134.459H682.693L763.95 349.441L706.686 355.199L691.01 309.132H615.831L600.795 352H542.891L624.468 134.459ZM838.15 352H779.926V129.661L838.15 126.142V352ZM899.496 352H870.064V129.661L928.288 126.142V322.888L899.496 352ZM909.413 257.306L893.098 219.876C896.937 215.184 901.842 210.705 907.814 206.44C913.999 201.961 920.823 198.335 928.288 195.563C935.753 192.79 943.43 191.404 951.322 191.404C966.677 191.404 980.327 194.283 992.27 200.042C1004.21 205.8 1013.6 214.651 1020.42 226.594C1027.46 238.538 1030.98 253.787 1030.98 272.342C1030.98 290.683 1027.46 306.039 1020.42 318.409C1013.6 330.779 1004 340.057 991.631 346.242C979.474 352.427 965.185 355.519 948.762 355.519C942.364 355.519 935.966 354.559 929.568 352.64C923.383 350.72 917.198 347.734 911.013 343.682C905.041 339.417 899.069 333.872 893.098 327.047L909.413 292.816C915.598 298.575 921.676 303.053 927.648 306.252C933.62 309.238 939.378 310.731 944.923 310.731C949.829 310.731 954.201 309.238 958.04 306.252C961.879 303.267 964.971 298.895 967.317 293.136C969.663 287.378 970.836 280.553 970.836 272.662C970.836 264.77 969.663 258.159 967.317 252.827C965.185 247.282 961.985 243.123 957.72 240.351C953.668 237.578 948.549 236.192 942.364 236.192C937.246 236.192 932.02 238.111 926.688 241.95C921.357 245.789 915.598 250.908 909.413 257.306ZM1088.57 352.64L1089.85 349.121L1034.18 206.44L1087.93 191.084L1119.6 284.498L1149.35 196.842H1207.25L1139.11 370.235C1135.91 378.553 1130.69 386.231 1123.44 393.269C1116.19 400.307 1107.76 406.279 1098.16 411.184C1088.57 416.089 1078.65 419.715 1068.41 422.061L1048.9 376.953C1054.02 374.82 1059.35 372.474 1064.89 369.915C1070.65 367.356 1075.77 364.583 1080.25 361.597C1084.73 358.612 1087.5 355.626 1088.57 352.64Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,12 @@
<svg width="1211" height="525" viewBox="0 0 1211 525" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="36.675" cy="36.675" r="36.675" transform="matrix(-1 0 0 1 131.308 101.468)" fill="black"/>
<path d="M88.52 132.641L156.98 201.101" stroke="black" stroke-width="18.3375"/>
<circle cx="394.145" cy="138.143" r="36.675" fill="black"/>
<path d="M400.869 132.641L332.409 201.101" stroke="black" stroke-width="18.3375"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M105.043 356.993C85.276 347.583 73.7721 326.451 77.6256 304.901C94.165 212.406 163.093 143.033 245.611 143.033C328.33 143.033 397.391 212.743 413.716 305.573C417.513 327.163 405.915 348.285 386.081 357.618C343.64 377.591 296.235 388.755 246.222 388.755C195.69 388.755 147.82 377.358 105.043 356.993Z" fill="#FFDF6F"/>
<path d="M413.716 305.573L422.746 303.985L413.716 305.573ZM386.081 357.618L382.177 349.322L386.081 357.618ZM86.6512 306.515C102.633 217.14 168.694 152.201 245.611 152.201V133.864C157.491 133.864 85.6973 207.672 68.6 303.287L86.6512 306.515ZM245.611 152.201C322.713 152.201 388.911 217.457 404.686 307.161L422.746 303.985C405.872 208.028 333.946 133.864 245.611 133.864V152.201ZM382.177 349.322C340.932 368.732 294.857 379.586 246.222 379.586V397.924C297.613 397.924 346.348 386.449 389.985 365.914L382.177 349.322ZM246.222 379.586C197.082 379.586 150.556 368.505 108.984 348.714L101.102 365.272C145.084 386.21 194.298 397.924 246.222 397.924V379.586ZM404.686 307.161C407.73 324.47 398.462 341.659 382.177 349.322L389.985 365.914C413.369 354.91 427.296 329.855 422.746 303.985L404.686 307.161ZM68.6 303.287C63.9824 329.11 77.7983 354.178 101.102 365.272L108.984 348.714C92.7538 340.988 83.5618 323.792 86.6512 306.515L68.6 303.287Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.311 333.998C124.4 327.518 114.962 310.589 120.475 294.317C137.477 244.138 187.075 207.825 245.611 207.825C304.148 207.825 353.745 244.138 370.748 294.317C376.261 310.589 366.823 327.518 350.912 333.998C318.414 347.233 282.864 354.525 245.611 354.525C208.358 354.525 172.808 347.233 140.311 333.998Z" fill="black"/>
<ellipse cx="287.788" cy="286.065" rx="30.5625" ry="24.45" fill="white"/>
<ellipse cx="200.295" cy="286.08" rx="30.5625" ry="24.45" fill="white"/>
<path d="M674.32 263.36L652.56 204.16L631.76 263.36H674.32ZM624.4 134.4H682.64L763.92 349.44L706.64 355.2L690.96 309.12H615.76L600.72 352H542.8L624.4 134.4ZM838.14 352H779.9V129.6L838.14 126.08V352ZM899.503 352H870.063V129.6L928.303 126.08V322.88L899.503 352ZM909.423 257.28L893.103 219.84C896.943 215.147 901.849 210.667 907.823 206.4C914.009 201.92 920.836 198.293 928.303 195.52C935.769 192.747 943.449 191.36 951.343 191.36C966.703 191.36 980.356 194.24 992.303 200C1004.25 205.76 1013.64 214.613 1020.46 226.56C1027.5 238.507 1031.02 253.76 1031.02 272.32C1031.02 290.667 1027.5 306.027 1020.46 318.4C1013.64 330.773 1004.04 340.053 991.663 346.24C979.503 352.427 965.209 355.52 948.783 355.52C942.383 355.52 935.983 354.56 929.583 352.64C923.396 350.72 917.209 347.733 911.023 343.68C905.049 339.413 899.076 333.867 893.103 327.04L909.423 292.8C915.609 298.56 921.689 303.04 927.663 306.24C933.636 309.227 939.396 310.72 944.943 310.72C949.849 310.72 954.223 309.227 958.063 306.24C961.903 303.253 964.996 298.88 967.343 293.12C969.689 287.36 970.863 280.533 970.863 272.64C970.863 264.747 969.689 258.133 967.343 252.8C965.209 247.253 962.009 243.093 957.743 240.32C953.689 237.547 948.569 236.16 942.383 236.16C937.263 236.16 932.036 238.08 926.703 241.92C921.369 245.76 915.609 250.88 909.423 257.28ZM1088.62 352.64L1089.9 349.12L1034.22 206.4L1087.98 191.04L1119.66 284.48L1149.42 196.8H1207.34L1139.19 370.24C1135.98 378.56 1130.76 386.24 1123.5 393.28C1116.25 400.32 1107.83 406.293 1098.22 411.2C1088.62 416.107 1078.7 419.733 1068.46 422.08L1048.94 376.96C1054.06 374.827 1059.4 372.48 1064.94 369.92C1070.7 367.36 1075.82 364.587 1080.3 361.6C1084.78 358.613 1087.56 355.627 1088.62 352.64Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,38 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
const paymentOptionSchema = z.object({
label: z.string(),
value: z.string(),
});
export const paymentOptionsSchema = z.array(paymentOptionSchema);
export const PaypalPaymentOptions = [
{
label: "on_booking_option",
value: "ON_BOOKING",
},
];
type PaymentOption = (typeof PaypalPaymentOptions)[number]["value"];
const VALUES: [PaymentOption, ...PaymentOption[]] = [
PaypalPaymentOptions[0].value,
...PaypalPaymentOptions.slice(1).map((option) => option.value),
];
export const paymentOptionEnum = z.enum(VALUES);
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
price: z.number(),
currency: z.string(),
paymentOption: z.string().optional(),
enabled: z.boolean().optional(),
credentialId: z.number().optional(),
})
);
export const appKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});

View File

@ -0,0 +1,17 @@
---
items:
- 1.jpg
- 2.jpg
- 3.jpg
---
<iframe class="w-full aspect-video -mx-2" width="560" height="315" src="https://www.youtube.com/embed/OGe1NYKhZE8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## The joyful productivity app
- Use your calendar as a todo list
- Color your calendar to organize
- Instantly know if someone is available
- Track what you listened to when
- Send scheduling links guests love
- Always know what your team is up to

View File

@ -0,0 +1,20 @@
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import { createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirect: {
newTab: true,
url: "https://amie.so/signup",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;

View File

@ -0,0 +1 @@
export { default as add } from "./add";

View File

@ -0,0 +1,17 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Amie",
"slug": "amie",
"type": "amie_other",
"logo": "icon.svg",
"url": "https://cal.com",
"variant": "other",
"categories": ["calendar"],
"publisher": "Cal.com, Inc.",
"email": "support@cal.com",
"description": "The joyful productivity app\r\r",
"__createdUsingCli": true,
"dependencies": ["google-calendar"],
"dirName": "amie",
"isOAuth": false
}

View File

@ -0,0 +1 @@
export * as api from "./api";

View File

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/amie",
"version": "0.0.0",
"main": "./index.ts",
"description": "The joyful productivity app\r\r",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@ -0,0 +1,4 @@
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="90" height="90" rx="8" fill="#FCBABC"/>
<path d="M46.3736 43.578C47.8926 43.578 48.6956 44.603 48.6956 46.544V54.119C48.6956 54.378 48.9036 54.587 49.1606 54.587H51.2286C51.4856 54.587 51.6936 54.378 51.6936 54.119V45.631C51.6936 44.197 51.2656 42.975 50.4536 42.103C49.6376 41.225 48.4946 40.762 47.1456 40.762C45.5216 40.762 44.1576 41.378 42.9726 42.647L42.9296 42.694L42.8956 42.641C42.1116 41.413 40.8836 40.762 39.3496 40.762C37.8336 40.762 36.5966 41.334 35.6696 42.462L35.5826 42.569V41.491C35.5826 41.231 35.3746 41.022 35.1176 41.022H33.0496C32.7926 41.022 32.5846 41.231 32.5846 41.491V54.116C32.5846 54.375 32.7926 54.584 33.0496 54.584H35.1176C35.3746 54.584 35.5826 54.375 35.5826 54.116V46.619C35.5826 45.456 36.0536 44.747 36.4476 44.353C36.9476 43.858 37.6186 43.579 38.3196 43.575C39.8386 43.575 40.6416 44.6 40.6416 46.541V54.116C40.6416 54.375 40.8496 54.584 41.1066 54.584H43.1746C43.4316 54.584 43.6396 54.375 43.6396 54.116V46.619C43.6396 45.456 44.1106 44.747 44.5046 44.353C45.0006 43.862 45.6826 43.578 46.3736 43.578ZM74.9066 47.453C74.9066 45.575 74.3636 43.891 73.3806 42.709C72.3206 41.434 70.7836 40.762 68.9386 40.762C67.1436 40.762 65.5096 41.484 64.3356 42.797C63.1726 44.094 62.5336 45.878 62.5336 47.819C62.5336 51.972 65.2216 54.875 69.0686 54.875C71.5276 54.875 73.5546 53.662 74.5966 51.606C74.7266 51.347 74.6146 51.053 74.3446 50.95L72.6806 50.316C72.4506 50.228 72.1966 50.337 72.0946 50.562C71.5766 51.675 70.4796 52.322 69.0716 52.322C67.0446 52.322 65.6406 50.859 65.4076 48.503L65.4016 48.447H74.4446C74.7016 48.447 74.9096 48.237 74.9096 47.978V47.453H74.9066ZM65.5446 46.203L65.5596 46.141C66.0156 44.331 67.2366 43.291 68.9136 43.291C70.6866 43.291 71.8526 44.413 71.8806 46.15V46.2H65.5446V46.203ZM30.1976 53.953L23.5636 36.303C23.4956 36.122 23.3226 36 23.1296 36H20.0986C19.9056 36 19.7326 36.122 19.6646 36.303L13.0306 53.953C12.9156 54.259 13.1386 54.587 13.4646 54.587H15.6406C15.8356 54.587 16.0096 54.466 16.0776 54.281L17.2646 51.072H25.8916L27.0976 54.284C27.1666 54.466 27.3396 54.587 27.5316 54.587H29.7606C29.9136 54.588 30.0576 54.513 30.1446 54.386C30.2326 54.259 30.2516 54.097 30.1976 53.953ZM18.3066 48.259L21.5486 39.503L24.8376 48.259H18.3066ZM53.7796 41.494V43.362C53.7796 43.622 53.9876 43.831 54.2446 43.831H56.5386V54.119C56.5386 54.378 56.7466 54.588 57.0036 54.588H59.0716C59.3286 54.588 59.5366 54.378 59.5366 54.119V41.494C59.5366 41.234 59.3286 41.025 59.0716 41.025H54.2416C53.9876 41.025 53.7796 41.234 53.7796 41.494ZM56.4886 36.469V38.631C56.4886 38.891 56.6966 39.1 56.9536 39.1H59.1276C59.3846 39.1 59.5926 38.891 59.5926 38.631V36.469C59.5926 36.209 59.3846 36 59.1276 36H56.9536C56.6966 36 56.4886 36.212 56.4886 36.469Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,14 @@
import type { AppMeta } from "@calcom/types/App";
import { appStoreMetadata as rawAppStoreMetadata } from "./apps.metadata.generated";
import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata";
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
type AppStoreMetaData = {
[key in keyof RawAppStoreMetaData]: Omit<AppMeta, "dirName"> & { dirName: string };
};
export const appStoreMetadata = {} as AppStoreMetaData;
for (const [key, value] of Object.entries(rawAppStoreMetadata)) {
appStoreMetadata[key as keyof typeof appStoreMetadata] = getNormalizedAppMetadata(value);
}

View File

@ -0,0 +1,6 @@
---
items:
- 1.jpg
---
Apple calendar runs both the macOS and iOS mobile operating systems. Offering online cloud backup of calendars using Apples iCloud service, it can sync with Google Calendar and Microsoft Exchange Server. Users can schedule events in their day that include time, location, duration, and extra notes.

View File

@ -0,0 +1,23 @@
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: "Apple Calendar",
description: _package.description,
installed: true,
type: "apple_calendar",
title: "Apple Calendar",
variant: "calendar",
categories: ["calendar"],
category: "calendar",
logo: "icon.svg",
publisher: "BLS media",
slug: "apple-calendar",
url: "https://bls.media/",
email: "hello@bls-media.de",
dirName: "applecalendar",
isOAuth: false,
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,59 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { CalendarService } from "../lib";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const { username, password } = req.body;
// Get user
const user = await prisma.user.findFirstOrThrow({
where: {
id: req.session?.user?.id,
},
select: {
email: true,
id: true,
},
});
const data = {
type: "apple_calendar",
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: user.id,
teamId: null,
appId: "apple-calendar",
invalid: false,
};
try {
const dav = new CalendarService({
id: 0,
...data,
user: { email: user.email },
});
await dav?.listCalendars();
await prisma.credential.create({
data,
});
} catch (reason) {
logger.error("Could not add this apple calendar account", reason);
return res.status(500).json({ message: "unable_to_add_apple_calendar" });
}
return res
.status(200)
.json({ url: getInstalledAppPath({ variant: "calendar", slug: "apple-calendar" }) });
}
if (req.method === "GET") {
return res.status(200).json({ url: "/apps/apple-calendar/setup" });
}
}

View File

@ -0,0 +1 @@
export { default as add } from "./add";

View File

@ -0,0 +1,3 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";

View File

@ -0,0 +1,8 @@
import CalendarService from "@calcom/lib/CalendarService";
import type { CredentialPayload } from "@calcom/types/Credential";
export default class AppleCalendarService extends CalendarService {
constructor(credential: CredentialPayload) {
super(credential, "apple_calendar", "https://caldav.icloud.com");
}
}

View File

@ -0,0 +1 @@
export { default as CalendarService } from "./CalendarService";

View File

@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/applecalendar",
"version": "0.0.0",
"main": "./index.ts",
"description": "Apple calendar runs both the macOS and iOS mobile operating systems. Offering online cloud backup of calendars using Apples iCloud service, it can sync with Google Calendar and Microsoft Exchange Server. Users can schedule events in their day that include time, location, duration, and extra notes.",
"dependencies": {
"@calcom/prisma": "*",
"react-hook-form": "^7.43.3"
},
"devDependencies": {
"@calcom/types": "*"
}
}

View File

@ -0,0 +1,115 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, Form, PasswordField, TextField } from "@calcom/ui";
export default function AppleCalendarSetup() {
const { t } = useLocale();
const router = useRouter();
const form = useForm({
defaultValues: {
username: "",
password: "",
},
});
const [errorMessage, setErrorMessage] = useState("");
return (
<div className="bg-emphasis flex h-screen dark:bg-inherit">
<div className="bg-default dark:bg-muted border-subtle m-auto rounded p-5 dark:border md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
src="/api/app-store/applecalendar/icon.svg"
alt="Apple Calendar"
className="h-12 w-12 max-w-2xl"
/>
</div>
<div>
<h1 className="text-default dark:text-emphasis mb-3 font-semibold">
{t("connect_apple_server")}
</h1>
<div className="mt-1 text-sm">
{t("apple_server_generate_password", { appName: APP_NAME })}{" "}
<a
className="font-bold hover:underline"
href="https://appleid.apple.com/account/manage"
target="_blank"
rel="noopener noreferrer">
https://appleid.apple.com/account/manage
</a>
. {t("credentials_stored_encrypted")}
</div>
<div className="my-2 mt-4">
<Form
form={form}
handleSubmit={async (values) => {
try {
setErrorMessage("");
const res = await fetch("/api/integrations/applecalendar/add", {
method: "POST",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) {
setErrorMessage(t(json?.message) || t("something_went_wrong"));
} else {
router.push(json.url);
}
} catch (err) {
setErrorMessage(t("unable_to_add_apple_calendar"));
}
}}>
<fieldset
className="space-y-4"
disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form">
<TextField
required
type="text"
{...form.register("username")}
label="Apple ID"
placeholder="appleid@domain.com"
data-testid="apple-calendar-email"
/>
<PasswordField
required
{...form.register("password")}
label={t("password")}
placeholder="•••••••••••••"
autoComplete="password"
data-testid="apple-calendar-password"
/>
</fieldset>
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
<Button
type="submit"
loading={form.formState.isSubmitting}
data-testid="apple-calendar-login-button">
{t("save")}
</Button>
</div>
</Form>
</div>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More