Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dbb692156 | ||
|
|
0068718d24 | ||
|
|
ac817b3542 | ||
|
|
97f2cffd1f | ||
|
|
b6dea9c161 | ||
|
|
57eb4746ad | ||
|
|
eff9c87964 | ||
|
|
e06c07bfec | ||
|
|
332c00dac2 | ||
|
|
e3523db6bb | ||
|
|
9c0f046b2a | ||
|
|
6e1a717ecf | ||
|
|
b2e098c781 | ||
|
|
3010900358 | ||
|
|
9b043f44a0 | ||
|
|
814674fe40 | ||
|
|
acaa79b0b8 | ||
|
|
781a0f4a7c | ||
|
|
b2028870d0 | ||
|
|
8f70be8861 | ||
|
|
53c8f8c04a | ||
|
|
c4e5d168a5 | ||
|
|
500bfb4d82 | ||
|
|
024b92b9ce | ||
|
|
f23b63cc98 | ||
|
|
edc90e3f27 | ||
|
|
05631d093f | ||
|
|
6000ff4b48 | ||
|
|
cd7de98e3f | ||
|
|
3f51858d27 | ||
|
|
2c498ace5c | ||
|
|
595d781d51 | ||
|
|
3c508568da | ||
|
|
3204d0af34 | ||
|
|
25684f9040 | ||
|
|
bc9aeef710 | ||
|
|
393411a47a | ||
|
|
356117feaf | ||
|
|
1bf68d50e0 | ||
|
|
4a35e85756 | ||
|
|
a4f56bf680 | ||
|
|
a308075bc3 | ||
|
|
efa6d464a3 | ||
|
|
811262d7ee | ||
|
|
99a5dcf96a | ||
|
|
79f092673a | ||
|
|
b285f27d00 | ||
|
|
1fa87ae179 | ||
|
|
f6f4b67bf9 | ||
|
|
8c8550e6f8 | ||
|
|
c6246c9408 |
@@ -1,16 +0,0 @@
|
||||
name: Add PRs to project Reviewing PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-PR-to-project:
|
||||
name: Add PRs to project Reviewing PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/11
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - webhookTriggers
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “every 5 minutes” (see https://crontab.guru)
|
||||
- cron: "*/5 * * * *"
|
||||
jobs:
|
||||
cron-webhookTriggers:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/webhookTriggers \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
@@ -0,0 +1,15 @@
|
||||
BACKEND_URL=http://localhost:3002/api
|
||||
|
||||
APP_ID=cal-ai
|
||||
APP_URL=http://localhost:3000/apps/cal-ai
|
||||
|
||||
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||
PARSE_KEY=
|
||||
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optionally trace completions at https://smith.langchain.com
|
||||
# LANGCHAIN_TRACING_V2=true
|
||||
# LANGCHAIN_ENDPOINT=
|
||||
# LANGCHAIN_API_KEY=
|
||||
# LANGCHAIN_PROJECT=
|
||||
@@ -0,0 +1,48 @@
|
||||
# Cal.com Email Assistant
|
||||
|
||||
Welcome to the first stage of Cal AI!
|
||||
|
||||
This app lets you chat with your calendar via email:
|
||||
|
||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
||||
|
||||
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||
|
||||
_The AI agent can only choose from a set of tools, without ever seeing your API key._
|
||||
|
||||
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||
|
||||
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development
|
||||
|
||||
If you haven't yet, please run the [root setup](/README.md) steps.
|
||||
|
||||
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
|
||||
|
||||
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||
- A default sender email (for example, `ai@cal.dev`)
|
||||
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
|
||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||
|
||||
### Email Router
|
||||
|
||||
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||
|
||||
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||
|
||||
1. [Sign up for an account](https://signup.sendgrid.com/)
|
||||
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
|
||||
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
|
||||
4. Use the nGrok URL from above as the **Destination URL**.
|
||||
5. Activate "POST the raw, full MIME message".
|
||||
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
|
||||
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||
|
||||
Please feel free to improve any part of this architecture.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,15 @@
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||
|
||||
const plugins = [];
|
||||
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@t3-oss/env-nextjs": "^0.6.1",
|
||||
"langchain": "^0.0.131",
|
||||
"mailparser": "^3.6.5",
|
||||
"next": "^13.4.6",
|
||||
"supports-color": "8.1.1",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mailparser": "^3.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev -p 3005",
|
||||
"format": "npx prettier . --write",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"start": "next start"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import agent from "../../../utils/agent";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
/**
|
||||
* Launches a LangChain agent to process an incoming email,
|
||||
* then sends the response to the user.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const json = await request.json();
|
||||
|
||||
const { apiKey, userId, message, subject, user, replyTo } = json;
|
||||
|
||||
if ((!message && !subject) || !user) {
|
||||
return new NextResponse("Missing fields", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
|
||||
|
||||
// Send response to user
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||
to: user.email,
|
||||
from: replyTo,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
} catch (error) {
|
||||
return new NextResponse(
|
||||
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { ParsedMail, Source } from "mailparser";
|
||||
import { simpleParser } from "mailparser";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||
import getHostFromHeaders from "../../../utils/host";
|
||||
import now from "../../../utils/now";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
/**
|
||||
* Verifies email signature and app authorization,
|
||||
* then hands off to booking agent.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const body = Object.fromEntries(formData);
|
||||
|
||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||
const signature = (body.dkim as string).includes(" : pass");
|
||||
|
||||
const envelope = JSON.parse(body.envelope as string);
|
||||
|
||||
const aiEmail = envelope.to[0];
|
||||
|
||||
// Parse email from mixed MIME type
|
||||
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||
|
||||
if (!parsed.text && !parsed.subject) {
|
||||
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
credentials: {
|
||||
select: {
|
||||
appId: true,
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { email: envelope.from },
|
||||
});
|
||||
|
||||
if (!signature || !user?.email || !user?.id) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, you are not authorized to use this service. Please verify your email address and try again.",
|
||||
to: user?.email || "",
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse();
|
||||
}
|
||||
|
||||
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
|
||||
|
||||
// User has not installed the app from the app store. Direct them to install it.
|
||||
if (!(credential as { apiKey: string })?.apiKey) {
|
||||
const url = env.APP_URL;
|
||||
|
||||
await sendEmail({
|
||||
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const { apiKey } = credential as { apiKey: string };
|
||||
|
||||
// Pre-fetch data relevant to most bookings.
|
||||
const [eventTypes, availability] = await Promise.all([
|
||||
fetchEventTypes({
|
||||
apiKey,
|
||||
}),
|
||||
fetchAvailability({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
dateFrom: now,
|
||||
dateTo: now,
|
||||
}),
|
||||
]);
|
||||
|
||||
if ("error" in availability) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(availability.error);
|
||||
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
if ("error" in eventTypes) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(eventTypes.error);
|
||||
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
const { timeZone, workingHours } = availability;
|
||||
|
||||
const appHost = getHostFromHeaders(request.headers);
|
||||
|
||||
// Hand off to long-running agent endpoint to handle the email. (don't await)
|
||||
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
message: parsed.text,
|
||||
subject: parsed.subject,
|
||||
replyTo: aiEmail,
|
||||
user: {
|
||||
email: user.email,
|
||||
eventTypes,
|
||||
timeZone,
|
||||
workingHours,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
return new NextResponse("ok");
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
BACKEND_URL: process.env.BACKEND_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
APP_URL: process.env.APP_URL,
|
||||
PARSE_KEY: process.env.PARSE_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
BACKEND_URL: z.string().url(),
|
||||
APP_ID: z.string().min(1),
|
||||
APP_URL: z.string().url(),
|
||||
PARSE_KEY: z.string().min(1),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
OPENAI_API_KEY: z.string().min(1),
|
||||
SENDGRID_API_KEY: z.string().min(1),
|
||||
DATABASE_URL: z.string().url(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Creates a booking for a user by event type, times, and timezone.
|
||||
*/
|
||||
const createBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
eventTypeId,
|
||||
start,
|
||||
end,
|
||||
timeZone,
|
||||
language,
|
||||
responses,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
eventTypeId: number;
|
||||
start: string;
|
||||
end: string;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
responses: { name?: string; email?: string; location?: string };
|
||||
title?: string;
|
||||
status?: string;
|
||||
}): Promise<string | Error | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
metadata: {},
|
||||
responses,
|
||||
start,
|
||||
timeZone,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
error: data.message,
|
||||
};
|
||||
}
|
||||
|
||||
return "Booking created";
|
||||
};
|
||||
|
||||
const createBookingTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description:
|
||||
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
|
||||
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
|
||||
return JSON.stringify(
|
||||
await createBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
responses,
|
||||
start,
|
||||
status,
|
||||
timeZone,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "createBookingIfAvailable",
|
||||
schema: z.object({
|
||||
end: z
|
||||
.string()
|
||||
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||
eventTypeId: z.number(),
|
||||
language: z.string(),
|
||||
responses: z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.describe("External invited user. Not the user making the request."),
|
||||
start: z.string(),
|
||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||
timeZone: z.string(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default createBookingTool;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Cancels a booking for a user by ID with reason.
|
||||
*/
|
||||
const cancelBooking = async ({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
}: {
|
||||
apiKey: string;
|
||||
id: string;
|
||||
reason: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ reason }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking cancelled";
|
||||
};
|
||||
|
||||
const cancelBookingTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Cancel a booking",
|
||||
func: async ({ id, reason }) => {
|
||||
return JSON.stringify(
|
||||
await cancelBooking({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "cancelBooking",
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default cancelBookingTool;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Availability } from "../types/availability";
|
||||
|
||||
/**
|
||||
* Fetches availability for a user by date range and event type.
|
||||
*/
|
||||
export const fetchAvailability = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
}): Promise<Partial<Availability> | { error: string }> => {
|
||||
const params: { [k: string]: string } = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
};
|
||||
|
||||
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return {
|
||||
busy: data.busy,
|
||||
dateRanges: data.dateRanges,
|
||||
timeZone: data.timeZone,
|
||||
workingHours: data.workingHours,
|
||||
};
|
||||
};
|
||||
|
||||
const getAvailabilityTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get availability within range.",
|
||||
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
||||
return JSON.stringify(
|
||||
await fetchAvailability({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getAvailability",
|
||||
schema: z.object({
|
||||
dateFrom: z.string(),
|
||||
dateTo: z.string(),
|
||||
eventTypeId: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getAvailabilityTool;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Booking } from "../types/booking";
|
||||
import { BOOKING_STATUS } from "../types/booking";
|
||||
|
||||
/**
|
||||
* Fetches bookings for a user by date range.
|
||||
*/
|
||||
const fetchBookings = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<Booking[] | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
const bookings: Booking[] = data.bookings
|
||||
.filter((booking: Booking) => {
|
||||
const afterFrom = new Date(booking.startTime).getTime() > new Date(from).getTime();
|
||||
const beforeTo = new Date(booking.endTime).getTime() < new Date(to).getTime();
|
||||
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
|
||||
|
||||
return afterFrom && beforeTo && notCancelled;
|
||||
})
|
||||
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
|
||||
endTime,
|
||||
eventTypeId,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
}));
|
||||
|
||||
return bookings;
|
||||
};
|
||||
|
||||
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get bookings for a user between two dates.",
|
||||
func: async ({ from, to }) => {
|
||||
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||
},
|
||||
name: "getBookings",
|
||||
schema: z.object({
|
||||
from: z.string().describe("ISO 8601 datetime string"),
|
||||
to: z.string().describe("ISO 8601 datetime string"),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getBookingsTool;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { EventType } from "../types/eventType";
|
||||
|
||||
/**
|
||||
* Fetches event types by user ID.
|
||||
*/
|
||||
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return data.event_types.map((eventType: EventType) => ({
|
||||
id: eventType.id,
|
||||
length: eventType.length,
|
||||
title: eventType.title,
|
||||
}));
|
||||
};
|
||||
|
||||
const getEventTypesTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async () => {
|
||||
return JSON.stringify(
|
||||
await fetchEventTypes({
|
||||
apiKey,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getEventTypes",
|
||||
schema: z.object({}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getEventTypesTool;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Edits a booking for a user by booking ID with new times, title, description, or status.
|
||||
*/
|
||||
const editBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
id,
|
||||
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
|
||||
endTime, // Same here: it says end but it's endTime.
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
id: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ description, endTime, startTime, status, title }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking edited";
|
||||
};
|
||||
|
||||
const editBookingTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Edit a booking",
|
||||
func: async ({ description, endTime, id, startTime, status, title }) => {
|
||||
return JSON.stringify(
|
||||
await editBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
description,
|
||||
endTime,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "editBooking",
|
||||
schema: z.object({
|
||||
description: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
id: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default editBookingTool;
|
||||
@@ -0,0 +1,25 @@
|
||||
export type Availability = {
|
||||
busy: {
|
||||
start: string;
|
||||
end: string;
|
||||
title?: string;
|
||||
}[];
|
||||
timeZone: string;
|
||||
dateRanges: {
|
||||
start: string;
|
||||
end: string;
|
||||
}[];
|
||||
workingHours: {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
}[];
|
||||
dateOverrides: {
|
||||
date: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
};
|
||||
currentSeats: number;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export enum BOOKING_STATUS {
|
||||
ACCEPTED = "ACCEPTED",
|
||||
PENDING = "PENDING",
|
||||
CANCELLED = "CANCELLED",
|
||||
REJECTED = "REJECTED",
|
||||
}
|
||||
|
||||
export type Booking = {
|
||||
id: number;
|
||||
userId: number;
|
||||
description: string | null;
|
||||
eventTypeId: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
payment: { id: number; success: boolean; paymentOption: string }[];
|
||||
metadata: object | null;
|
||||
status: BOOKING_STATUS;
|
||||
responses: { email: string; name: string; location: string } | null;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export type EventType = {
|
||||
id: number;
|
||||
title: string;
|
||||
length: number;
|
||||
metadata: object;
|
||||
slug: string;
|
||||
hosts: {
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
}[];
|
||||
hidden: boolean;
|
||||
// ...
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { EventType } from "./eventType";
|
||||
import type { WorkingHours } from "./workingHours";
|
||||
|
||||
export type User = {
|
||||
email: string;
|
||||
timeZone: string;
|
||||
eventTypes: EventType[];
|
||||
workingHours: WorkingHours[];
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type WorkingHours = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
||||
import deleteBooking from "../tools/deleteBooking";
|
||||
import getAvailability from "../tools/getAvailability";
|
||||
import getBookings from "../tools/getBookings";
|
||||
import updateBooking from "../tools/updateBooking";
|
||||
import type { EventType } from "../types/eventType";
|
||||
import type { User } from "../types/user";
|
||||
import type { WorkingHours } from "../types/workingHours";
|
||||
import now from "./now";
|
||||
|
||||
const gptModel = "gpt-4";
|
||||
|
||||
/**
|
||||
* Core of the Cal AI booking agent: a LangChain Agent Executor.
|
||||
* Uses a toolchain to book meetings, list available slots, etc.
|
||||
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||
*/
|
||||
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
|
||||
const tools = [
|
||||
createBookingIfAvailable(apiKey, userId),
|
||||
getAvailability(apiKey, userId),
|
||||
getBookings(apiKey, userId),
|
||||
updateBooking(apiKey, userId),
|
||||
deleteBooking(apiKey),
|
||||
];
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
modelName: gptModel,
|
||||
openAIApiKey: env.OPENAI_API_KEY,
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize the agent executor with arguments.
|
||||
*/
|
||||
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||
agentArgs: {
|
||||
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
|
||||
Make sure your final answers are definitive, complete and well formatted.
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
|
||||
|
||||
Current UTC time is: ${now}
|
||||
The user's time zone is: ${user.timeZone}
|
||||
The user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
||||
.join("\n")}
|
||||
The user's working hours are: ${user.workingHours
|
||||
.map(
|
||||
(w: WorkingHours) =>
|
||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||
w.startTime
|
||||
}, End Time (minutes in UTC): ${w.endTime}`
|
||||
)
|
||||
.join("\n")}
|
||||
`,
|
||||
},
|
||||
agentType: "openai-functions",
|
||||
returnIntermediateSteps: env.NODE_ENV === "development",
|
||||
verbose: env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
const result = await executor.call({ input });
|
||||
const { output } = result;
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export default agent;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
|
||||
return `https://${headers.get("host")}`;
|
||||
};
|
||||
|
||||
export default getHostFromHeaders;
|
||||
@@ -0,0 +1 @@
|
||||
export default new Date().toISOString();
|
||||
@@ -0,0 +1,40 @@
|
||||
import mail from "@sendgrid/mail";
|
||||
|
||||
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
||||
|
||||
/**
|
||||
* Simply send an email by address, subject, and body.
|
||||
*/
|
||||
const send = async ({
|
||||
subject,
|
||||
to,
|
||||
from,
|
||||
text,
|
||||
html,
|
||||
}: {
|
||||
subject: string;
|
||||
to: string;
|
||||
from: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}): Promise<boolean> => {
|
||||
mail.setApiKey(sendgridAPIKey);
|
||||
|
||||
const msg = {
|
||||
to,
|
||||
from: {
|
||||
email: from,
|
||||
name: "Cal AI",
|
||||
},
|
||||
text,
|
||||
html,
|
||||
subject,
|
||||
};
|
||||
|
||||
const res = await mail.send(msg);
|
||||
const success = !!res;
|
||||
|
||||
return success;
|
||||
};
|
||||
|
||||
export default send;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Verifies that the request contains the correct parse key.
|
||||
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
|
||||
*/
|
||||
export const verifyParseKey = (url: NextRequest["url"]) => {
|
||||
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
|
||||
|
||||
return verified;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -40,6 +40,6 @@
|
||||
"typescript": "^4.9.4",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,10 +248,10 @@
|
||||
--cal-bg-inverted: #f3f4f6;
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #dee9fc;
|
||||
--cal-bg-success: #e2fbe8;
|
||||
--cal-bg-attention: #fceed8;
|
||||
--cal-bg-error: #f9e3e2;
|
||||
--cal-bg-info: #263fa9;
|
||||
--cal-bg-success: #306339;
|
||||
--cal-bg-attention: #8e3b1f;
|
||||
--cal-bg-error: #8c2822;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
/* Borders */
|
||||
@@ -269,10 +269,10 @@
|
||||
--cal-text-inverted: #101010;
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321b;
|
||||
--cal-text-error: #752522;
|
||||
--cal-text-info: #dee9fc;
|
||||
--cal-text-success: #e2fbe8;
|
||||
--cal-text-attention: #fceed8;
|
||||
--cal-text-error: #f9e3e2;
|
||||
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, TextField } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
|
||||
return (
|
||||
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||
<Label className="mt-4">{t("backup_code")}</Label>
|
||||
|
||||
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
|
||||
|
||||
<TextField
|
||||
id="backup-code"
|
||||
label=""
|
||||
defaultValue=""
|
||||
placeholder="XXXXX-XXXXX"
|
||||
minLength={10} // without dash
|
||||
maxLength={11} // with dash
|
||||
required
|
||||
{...methods.register("backupCode")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, Input } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
export default function TwoFactor({ center = true, autoFocus = true }) {
|
||||
const [value, onChange] = useState("");
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
|
||||
name={`2fa${index + 1}`}
|
||||
inputMode="decimal"
|
||||
{...digit}
|
||||
autoFocus={index === 0}
|
||||
autoFocus={autoFocus && index === 0}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}}
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<DialogFooter>
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<Clock className="m-auto h-6 w-6" />
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { Options } from "react-select";
|
||||
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select } from "@calcom/ui";
|
||||
@@ -18,13 +17,14 @@ interface IUserToValue {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const mapUserToValue = ({ id, name, username, email }: IUserToValue, pendingString: string) => ({
|
||||
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
|
||||
value: `${id || ""}`,
|
||||
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
||||
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
||||
avatar,
|
||||
email,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
|
||||
}
|
||||
|
||||
interface DisableTwoFactorValues {
|
||||
backupCode: string;
|
||||
totpCode: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
|
||||
}: DisableTwoFactorAuthModalProps) => {
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useForm<DisableTwoFactorValues>();
|
||||
|
||||
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
|
||||
const resetForm = (clearPassword = true) => {
|
||||
if (clearPassword) form.setValue("password", "");
|
||||
form.setValue("backupCode", "");
|
||||
form.setValue("totpCode", "");
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
@@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||
if (response.status === 200) {
|
||||
setTwoFactorLostAccess(false);
|
||||
resetForm();
|
||||
onDisable();
|
||||
return;
|
||||
}
|
||||
@@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
|
||||
const body = await response.json();
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
}
|
||||
if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
setErrorMessage(t("2fa_required"));
|
||||
}
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(t("incorrect_2fa"));
|
||||
} else if (body.error === ErrorCode.IncorrectBackupCode) {
|
||||
setErrorMessage(t("incorrect_backup_code"));
|
||||
} else if (body.error === ErrorCode.MissingBackupCodes) {
|
||||
setErrorMessage(t("missing_backup_codes"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
@@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
<div className="mb-8">
|
||||
{!disablePassword && (
|
||||
<PasswordField
|
||||
required
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
@@ -85,12 +100,25 @@ const DisableTwoFactorAuthModal = ({
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
)}
|
||||
<TwoFactor center={false} />
|
||||
{twoFactorLostAccess ? (
|
||||
<BackupCode center={false} />
|
||||
) : (
|
||||
<TwoFactor center={false} autoFocus={false} />
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter showDivider className="relative mt-5">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(!twoFactorLostAccess);
|
||||
resetForm(false);
|
||||
}}>
|
||||
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
|
||||
|
||||
enum SetupStep {
|
||||
ConfirmPassword,
|
||||
DisplayBackupCodes,
|
||||
DisplayQrCode,
|
||||
EnterTotpCode,
|
||||
}
|
||||
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||
};
|
||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||
const [password, setPassword] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||
const [dataUri, setDataUri] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
setPassword("");
|
||||
setErrorMessage(null);
|
||||
setStep(SetupStep.ConfirmPassword);
|
||||
};
|
||||
|
||||
async function handleSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setBackupCodes(body.backupCodes);
|
||||
|
||||
// create backup codes download url
|
||||
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
|
||||
type: "text/plain",
|
||||
});
|
||||
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
|
||||
setBackupCodesUrl(URL.createObjectURL(textBlob));
|
||||
|
||||
setDataUri(body.dataUri);
|
||||
setSecret(body.secret);
|
||||
setStep(SetupStep.DisplayQrCode);
|
||||
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
onEnable();
|
||||
setStep(SetupStep.DisplayBackupCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||
<DialogContent
|
||||
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
|
||||
description={setupDescriptions[step]}
|
||||
type="creation">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
<TextField
|
||||
<PasswordField
|
||||
label={t("password")}
|
||||
type="password"
|
||||
name="password"
|
||||
@@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
|
||||
{backupCodes.map((code) => (
|
||||
<div key={code}>{formatBackupCode(code)}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<div className="-mt-4 pb-2">
|
||||
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</div>
|
||||
</WithStep>
|
||||
<DialogFooter className="mt-8" showDivider>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
{step !== SetupStep.DisplayBackupCodes ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
resetState();
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetState();
|
||||
onEnable();
|
||||
}}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-copy"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
|
||||
showToast(t("backup_codes_copied"), "success");
|
||||
}}>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
|
||||
<Button color="primary" data-testid="backup-codes-download">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
</WithStep>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
async disable(password: string, code: string) {
|
||||
async disable(password: string, code: string, backupCode: string) {
|
||||
return fetch("/api/auth/two-factor/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code }),
|
||||
body: JSON.stringify({ password, code, backupCode }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
}),
|
||||
});
|
||||
|
||||
const formMethods = useForm<{
|
||||
username: string;
|
||||
email_address: string;
|
||||
full_name: string;
|
||||
password: string;
|
||||
}>({
|
||||
type formSchemaType = z.infer<typeof formSchema>;
|
||||
|
||||
const formMethods = useForm<formSchemaType>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
@@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
props.onError();
|
||||
};
|
||||
|
||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = formMethods.handleSubmit(async (data) => {
|
||||
props.onSubmit();
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
@@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
||||
onBlur={onBlur}
|
||||
name="username"
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("username", e.target.value);
|
||||
await formMethods.trigger("username");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<TextField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("full_name", e.target.value);
|
||||
await formMethods.trigger("full_name");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
||||
type="text"
|
||||
name="full_name"
|
||||
@@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<EmailField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("email_address", e.target.value);
|
||||
await formMethods.trigger("email_address");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="my-0"
|
||||
name="email_address"
|
||||
/>
|
||||
@@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<PasswordField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("password", e.target.value);
|
||||
await formMethods.trigger("password");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
hintErrors={["caplow", "admin_min", "num"]}
|
||||
name="password"
|
||||
className="my-0"
|
||||
|
||||
+36
-33
@@ -14,7 +14,8 @@ const {
|
||||
|
||||
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
||||
|
||||
const isOrganizationsEnabled =
|
||||
process.env.ORGANIZATIONS_ENABLED === "1" || process.env.ORGANIZATIONS_ENABLED === "true";
|
||||
// To be able to use the version in the app without having to import package.json
|
||||
process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
|
||||
|
||||
@@ -226,7 +227,7 @@ const nextConfig = {
|
||||
async rewrites() {
|
||||
const beforeFiles = [
|
||||
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
||||
...(process.env.ORGANIZATIONS_ENABLED
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
@@ -333,44 +334,46 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
...[
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
headers: [
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/team/:orgSlug",
|
||||
...matcherConfigRootPath,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/team/:orgSlug",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user",
|
||||
...matcherConfigUserRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type",
|
||||
...matcherConfigUserTypeRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type/embed",
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type/embed",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
@@ -463,7 +466,7 @@ const nextConfig = {
|
||||
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
|
||||
...(process.env.NODE_ENV === "development" &&
|
||||
// Safer to enable the redirect only when the user is opting to test out organizations
|
||||
process.env.ORGANIZATIONS_ENABLED &&
|
||||
isOrganizationsEnabled &&
|
||||
// Prevent infinite redirect by checking that we aren't already on localhost
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
|
||||
? [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.2.6",
|
||||
"version": "3.2.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -129,7 +129,7 @@
|
||||
"turndown": "^7.1.1",
|
||||
"uuid": "^8.3.2",
|
||||
"web3": "^1.7.5",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.6",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
||||
@@ -11,18 +10,9 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().refine((value) => !value.includes("+"), {
|
||||
message: "String should not contain a plus symbol (+).",
|
||||
}),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(7),
|
||||
language: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).end();
|
||||
|
||||
@@ -43,8 +43,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
|
||||
}
|
||||
}
|
||||
// if user has 2fa
|
||||
if (user.twoFactorEnabled) {
|
||||
|
||||
// if user has 2fa and using backup code
|
||||
if (user.twoFactorEnabled && req.body.backupCode) {
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
if (!user.backupCodes) {
|
||||
return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
|
||||
}
|
||||
|
||||
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) {
|
||||
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
|
||||
}
|
||||
|
||||
// we delete all stored backup codes at the end, no need to do this here
|
||||
|
||||
// if user has 2fa and NOT using backup code, try totp
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!req.body.code) {
|
||||
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
|
||||
// throw new Error(ErrorCode.SecondFactorRequired);
|
||||
@@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: null,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authenticator } from "otplib";
|
||||
import qrcode from "qrcode";
|
||||
@@ -56,11 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
// bytes without updating the sanity checks in the enable and login endpoints.
|
||||
const secret = authenticator.generateSecret(20);
|
||||
|
||||
// generate backup codes with 10 character length
|
||||
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
},
|
||||
@@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const keyUri = authenticator.keyuri(name, "Cal", secret);
|
||||
const dataUri = await qrcode.toDataURL(keyUri);
|
||||
|
||||
return res.json({ secret, keyUri, dataUri });
|
||||
return res.json({ secret, keyUri, dataUri, backupCodes });
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
@@ -127,7 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
||||
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
|
||||
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
|
||||
};
|
||||
|
||||
await sendOrganizerRequestReminderEmail(evt);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/webhooks/lib/cron";
|
||||
@@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||
import { ArrowLeft } from "@calcom/ui/components/icon";
|
||||
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
@@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
|
||||
@@ -39,6 +40,7 @@ interface LoginValues {
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode: string;
|
||||
backupCode: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
export default function Login({
|
||||
@@ -65,6 +67,7 @@ export default function Login({
|
||||
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
|
||||
const { register, formState } = methods;
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
@@ -98,15 +101,35 @@ export default function Login({
|
||||
);
|
||||
|
||||
const TwoFactorFooter = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTwoFactorRequired(false);
|
||||
methods.setValue("totpCode", "");
|
||||
}}
|
||||
StartIcon={ArrowLeft}
|
||||
color="minimal">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (twoFactorLostAccess) {
|
||||
setTwoFactorLostAccess(false);
|
||||
methods.setValue("backupCode", "");
|
||||
} else {
|
||||
setTwoFactorRequired(false);
|
||||
methods.setValue("totpCode", "");
|
||||
}
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
StartIcon={ArrowLeft}
|
||||
color="minimal">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
{!twoFactorLostAccess ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(true);
|
||||
setErrorMessage(null);
|
||||
methods.setValue("totpCode", "");
|
||||
}}
|
||||
StartIcon={Lock}
|
||||
color="minimal">
|
||||
{t("lost_access")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const ExternalTotpFooter = (
|
||||
@@ -130,8 +153,9 @@ export default function Login({
|
||||
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
|
||||
// we're logged in! let's do a hard refresh to the desired url
|
||||
else if (!res.error) router.push(callbackUrl);
|
||||
// reveal two factor input if required
|
||||
else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
|
||||
else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
|
||||
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
|
||||
// fallback if error not found
|
||||
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
||||
};
|
||||
@@ -194,7 +218,7 @@ export default function Login({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{twoFactorRequired && <TwoFactor center />}
|
||||
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<Button
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -17,11 +18,6 @@ import {
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
Label,
|
||||
showToast,
|
||||
@@ -32,7 +28,7 @@ import {
|
||||
Tooltip,
|
||||
VerticalDivider,
|
||||
} from "@calcom/ui";
|
||||
import { Info, MoreHorizontal, Plus, Trash } from "@calcom/ui/components/icon";
|
||||
import { Info, MoreVertical, ArrowLeft, Plus, Trash } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
@@ -95,7 +91,7 @@ export default function Availability() {
|
||||
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
|
||||
const fromEventType = searchParams?.get("fromEventType");
|
||||
const { timeFormat } = me.data || { timeFormat: null };
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [openSidebar, setOpenSidebar] = useState(false);
|
||||
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId },
|
||||
{
|
||||
@@ -225,33 +221,60 @@ export default function Availability() {
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<VerticalDivider className="hidden sm:inline" />
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="sm:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent style={{ minWidth: "200px" }}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon={Trash}
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_schedule")}
|
||||
confirmBtnText={t("delete")}
|
||||
loadingText={t("delete")}
|
||||
onConfirm={() => {
|
||||
schedule !== undefined && deleteMutation.mutate({ scheduleId: schedule.id });
|
||||
}}>
|
||||
{t("delete_schedule_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex h-9 flex-row items-center justify-between px-4 py-2 hover:bg-gray-100">
|
||||
<div
|
||||
className={classNames(
|
||||
openSidebar
|
||||
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
|
||||
: ""
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
|
||||
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
|
||||
)}>
|
||||
<div className="flex flex-row items-center pt-5">
|
||||
<Button StartIcon={ArrowLeft} color="minimal" onClick={() => setOpenSidebar(false)} />
|
||||
<p className="-ml-2">{t("availability_settings")}</p>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
StartIcon={Trash}
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
aria-label={t("delete")}
|
||||
className="ml-16 inline"
|
||||
disabled={schedule?.isLastSchedule}
|
||||
tooltip={schedule?.isLastSchedule ? t("requires_at_least_one_schedule") : t("delete")}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_schedule")}
|
||||
confirmBtnText={t("delete")}
|
||||
loadingText={t("delete")}
|
||||
onConfirm={() => {
|
||||
scheduleId && deleteMutation.mutate({ scheduleId });
|
||||
setOpenSidebar(false);
|
||||
}}>
|
||||
{t("delete_schedule_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
<Skeleton as={Label}>{t("name")}</Skeleton>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<input
|
||||
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:border-neutral-300 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-9 flex-row-reverse items-center justify-end gap-3 px-2">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
@@ -267,9 +290,44 @@ export default function Availability() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
|
||||
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
|
||||
{t("timezone")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="timeZone"
|
||||
render={({ field: { onChange, value } }) =>
|
||||
value ? (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
) : (
|
||||
<SelectSkeletonLoader className="mt-1 w-72" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle my-7" />
|
||||
<div className="rounded-md md:block">
|
||||
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
|
||||
{t("something_doesnt_look_right")}
|
||||
</Skeleton>
|
||||
<div className="mt-3 flex">
|
||||
<Skeleton as={Button} href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-default border-l-2" />
|
||||
<Button
|
||||
className="ml-4 lg:ml-0"
|
||||
@@ -278,6 +336,13 @@ export default function Availability() {
|
||||
loading={updateMutation.isLoading}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-3 sm:hidden"
|
||||
StartIcon={MoreVertical}
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
onClick={() => setOpenSidebar(true)}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="mt-4 w-full md:mt-0">
|
||||
@@ -313,7 +378,7 @@ export default function Availability() {
|
||||
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
|
||||
@@ -335,7 +400,7 @@ export default function Availability() {
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle my-6 mr-8" />
|
||||
<div className="hidden rounded-md md:block">
|
||||
<div className="rounded-md">
|
||||
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
|
||||
{t("something_doesnt_look_right")}
|
||||
</Skeleton>
|
||||
|
||||
@@ -183,12 +183,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
created: true,
|
||||
}))
|
||||
);
|
||||
showToast(
|
||||
t("event_type_updated_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
showToast(t("event_type_updated_successfully"), "success");
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
|
||||
@@ -52,10 +52,9 @@ import {
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
ArrowButton,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Clipboard,
|
||||
Code,
|
||||
Copy,
|
||||
@@ -378,19 +377,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<div className="hover:bg-muted flex w-full items-center justify-between">
|
||||
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
|
||||
{!(firstItem && firstItem.id === type.id) && (
|
||||
<button
|
||||
className="bg-default text-muted hover:text-emphasis border-default hover:border-emphasis invisible absolute left-[5px] -ml-4 -mt-4 mb-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={() => moveEventType(index, -1)}>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === type.id) && (
|
||||
<button
|
||||
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-[5px] -ml-4 mt-8 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={() => moveEventType(index, 1)}>
|
||||
<ArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
@@ -830,6 +821,25 @@ const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<EmptyScreen
|
||||
headline={t("team_no_event_types")}
|
||||
buttonRaw={
|
||||
<Button
|
||||
href={`?dialog=new&eventPage=${group.profile.slug}&teamId=${group.teamId}`}
|
||||
variant="button"
|
||||
className="mt-5">
|
||||
{t("create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Main = ({
|
||||
status,
|
||||
errorMessage,
|
||||
@@ -871,12 +881,16 @@ const Main = ({
|
||||
orgSlug={orgBranding?.slug}
|
||||
/>
|
||||
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
/>
|
||||
{group.eventTypes.length ? (
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
/>
|
||||
) : (
|
||||
<EmptyEventTypeList group={group} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
|
||||
|
||||
@@ -25,14 +26,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
|
||||
import { ssrInit } from "../server/lib/ssr";
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().refine((value) => !value.includes("+"), {
|
||||
message: "String should not contain a plus symbol (+).",
|
||||
}),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(7),
|
||||
language: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
const signupSchema = apiSignupSchema.extend({
|
||||
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
|
||||
});
|
||||
|
||||
@@ -46,6 +40,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
const { t, i18n } = useLocale();
|
||||
const flags = useFlagMap();
|
||||
const methods = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(signupSchema),
|
||||
defaultValues: prepopulateFormValues,
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ test.describe("Booking with Seats", () => {
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
const eventTitle = "My 2-seated event";
|
||||
await createNewSeatedEventType(page, { eventTitle });
|
||||
await expect(page.locator(`text=${eventTitle} event type updated successfully`)).toBeVisible();
|
||||
await expect(page.locator(`text=Event type updated successfully`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
// TODO: add more backup code tests, e.g. login + disabling 2fa with backup
|
||||
|
||||
// a test to logout requires both a succesfull login as logout, to prevent
|
||||
// a doubling of tests failing on logout & logout, we can group them.
|
||||
test.describe("2FA Tests", async () => {
|
||||
@@ -45,6 +47,8 @@ test.describe("2FA Tests", async () => {
|
||||
secret: secret!,
|
||||
});
|
||||
|
||||
// FIXME: this passes even when switch is not checked, compare to test
|
||||
// below which checks for data-state="checked" and works as expected
|
||||
await page.waitForSelector(`[data-testid=two-factor-switch]`);
|
||||
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
|
||||
|
||||
@@ -103,6 +107,23 @@ test.describe("2FA Tests", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await fillOtp({ page, secret: secret! });
|
||||
|
||||
// backup codes are now showing, so run a few tests
|
||||
|
||||
// click download button
|
||||
const promise = page.waitForEvent("download");
|
||||
await page.getByTestId("backup-codes-download").click();
|
||||
const download = await promise;
|
||||
expect(download.suggestedFilename()).toBe("cal-backup-codes.txt");
|
||||
// TODO: check file content
|
||||
|
||||
// click copy button
|
||||
await page.getByTestId("backup-codes-copy").click();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
// TODO: check clipboard content
|
||||
|
||||
// close backup code dialog
|
||||
await page.getByTestId("backup-codes-close").click();
|
||||
|
||||
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
|
||||
|
||||
return user;
|
||||
|
||||
@@ -246,7 +246,7 @@ test.describe("BOOKING_REJECTED", async () => {
|
||||
},
|
||||
],
|
||||
location: "[redacted/dynamic]",
|
||||
destinationCalendar: null,
|
||||
destinationCalendar: [],
|
||||
// hideCalendarNotes: false,
|
||||
requiresConfirmation: "[redacted/dynamic]",
|
||||
eventTypeId: "[redacted/dynamic]",
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"create_account": "Konto erstellen",
|
||||
"confirm_password": "Passwort bestätigen",
|
||||
"confirm_auth_change": "Dies ändert die Art und Weise, wie Sie sich anmelden",
|
||||
"confirm_auth_email_change": "Wenn Sie Ihre E-Mail-Adresse ändern, wird Ihre derzeitige Authentifizierungsmethode für die Anmeldung bei Cal.com unterbrochen. Wir werden Sie bitten, Ihre neue E-Mail-Adresse zu verifizieren. In der Folge werden Sie abgemeldet und verwenden Ihre neue E-Mail-Adresse, um sich anstelle Ihrer aktuellen Authentifizierungsmethode anzumelden, nachdem Sie Ihr Passwort anhand der Anweisungen, die Ihnen per E-Mail zugesandt werden, festgelegt haben.",
|
||||
"reset_your_password": "Legen Sie Ihr neues Passwort mit den Anweisungen fest, die an Ihre E-Mail-Adresse gesendet wurden.",
|
||||
"email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.",
|
||||
"create_your_account": "Erstellen Sie Ihr Konto",
|
||||
@@ -255,6 +256,7 @@
|
||||
"available_apps": "Verfügbare Apps",
|
||||
"available_apps_lower_case": "Verfügbare Apps",
|
||||
"available_apps_desc": "Sie haben keine Apps installiert. Sehen Sie sich beliebte Apps unten an und entdecken Sie noch mehr in unserem <1>App Store</1>",
|
||||
"fixed_host_helper": "Füge jeden hinzu, der an der Veranstaltung teilnehmen muss. <1>Mehr erfahren</1>",
|
||||
"check_email_reset_password": "Überprüfen Sie Ihre E-Mail. Wir haben Ihnen einen Link zum Zurücksetzen Ihres Passworts gesendet.",
|
||||
"finish": "Fertig",
|
||||
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
|
||||
@@ -527,7 +529,7 @@
|
||||
"location": "Ort",
|
||||
"address": "Adresse",
|
||||
"enter_address": "Geben Sie eine Adresse ein",
|
||||
"in_person_attendee_address": "In Person (Adresse von Ihnen)",
|
||||
"in_person_attendee_address": "Vor Ort (Adresse von Ihnen)",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"additional_notes": "Zusätzliche Notizen",
|
||||
@@ -537,7 +539,7 @@
|
||||
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
|
||||
"in_person_meeting": "Vor-Ort-Termin",
|
||||
"in_person": "Persönlich (Organisator-Adresse)",
|
||||
"in_person": "Vor Ort (Organisator-Adresse)",
|
||||
"link_meeting": "Termin verknüpfen",
|
||||
"phone_number": "Telefonnummer",
|
||||
"attendee_phone_number": "Telefonnummer",
|
||||
@@ -559,6 +561,7 @@
|
||||
"leave": "Verlassen",
|
||||
"profile": "Profil",
|
||||
"my_team_url": "Meine Team-URL",
|
||||
"my_teams": "Meine Teams",
|
||||
"team_name": "Teamname",
|
||||
"your_team_name": "Ihr Teamname",
|
||||
"team_updated_successfully": "Team erfolgreich aktualisiert",
|
||||
@@ -1974,6 +1977,8 @@
|
||||
"org_team_names_example_5": "z.B. Data Analytics Team",
|
||||
"org_max_team_warnings": "Weitere Teams können Sie zu einem späteren Zeitpunkt hinzufügen.",
|
||||
"what_is_this_meeting_about": "Worum geht es in diesem Termin?",
|
||||
"add_to_team": "Zum Team hinzufügen",
|
||||
"user_isnt_in_any_teams": "Dieser Benutzer ist in keinem Team",
|
||||
"kyc_verification_information": "Aus Sicherheitsgründen müssen Sie Ihren {{teamOrAccount}} verifizieren, bevor Sie Textnachrichten an die Teilnehmer senden können. Bitte kontaktieren Sie uns unter <a>{{supportEmail}}</a> und geben Sie folgende Informationen an:",
|
||||
"org_admin_other_teams": "Weitere Teams",
|
||||
"no_other_teams_found": "Keine weiteren Teams gefunden",
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
"nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who they’re booking with.",
|
||||
"set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.",
|
||||
"set_availability": "Set your availability",
|
||||
"availability_settings": "Availability Settings",
|
||||
"continue_without_calendar": "Continue without calendar",
|
||||
"connect_your_calendar": "Connect your calendar",
|
||||
"connect_your_video_app": "Connect your video apps",
|
||||
@@ -678,8 +679,8 @@
|
||||
"new_event_type_btn": "New event type",
|
||||
"new_event_type_heading": "Create your first event type",
|
||||
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
|
||||
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
|
||||
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
|
||||
"event_type_created_successfully": "Event type created successfully",
|
||||
"event_type_updated_successfully": "Event type updated successfully",
|
||||
"event_type_deleted_successfully": "Event type deleted successfully",
|
||||
"hours": "Hours",
|
||||
"people": "People",
|
||||
@@ -2010,6 +2011,13 @@
|
||||
"member_removed": "Member removed",
|
||||
"my_availability": "My Availability",
|
||||
"team_availability": "Team Availability",
|
||||
"backup_code": "Backup Code",
|
||||
"backup_codes": "Backup Codes",
|
||||
"backup_code_instructions": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"backup_codes_copied": "Backup codes copied!",
|
||||
"incorrect_backup_code": "Backup code is incorrect.",
|
||||
"lost_access": "Lost access",
|
||||
"missing_backup_codes": "No backup codes found. Please generate them in your settings.",
|
||||
"admin_org_notification_email_subject": "New organization created: pending action",
|
||||
"hi_admin": "Hi Administrator",
|
||||
"admin_org_notification_email_title": "An organization requires DNS setup",
|
||||
@@ -2025,6 +2033,8 @@
|
||||
"mark_dns_configured": "Mark as DNS configured",
|
||||
"value": "Value",
|
||||
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||
"team_no_event_types": "This team has no event types",
|
||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||
"include_calendar_event": "Include calendar event",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
"nearly_there_instructions": "Pour finir, une brève description de vous et une photo vous aideront vraiment à obtenir des réservations et à faire savoir aux gens avec qui ils prennent rendez-vous.",
|
||||
"set_availability_instructions": "Définissez des plages de temps pendant lesquelles vous êtes disponible de manière récurrente. Vous pourrez en créer d'autres ultérieurement et les assigner à différents calendriers.",
|
||||
"set_availability": "Définissez vos disponibilités",
|
||||
"availability_settings": "Paramètres de disponibilité",
|
||||
"continue_without_calendar": "Continuer sans calendrier",
|
||||
"connect_your_calendar": "Connectez votre calendrier",
|
||||
"connect_your_video_app": "Connectez vos applications vidéo",
|
||||
@@ -678,8 +679,8 @@
|
||||
"new_event_type_btn": "Nouveau type d'événement",
|
||||
"new_event_type_heading": "Créez votre premier type d'événement",
|
||||
"new_event_type_description": "Les types d'événements vous permettent de partager des liens qui affichent vos disponibilités sur votre calendrier et permettent aux personnes de réserver des créneaux.",
|
||||
"event_type_created_successfully": "Type d'événement {{eventTypeTitle}} créé avec succès",
|
||||
"event_type_updated_successfully": "Type d'événement {{eventTypeTitle}} mis à jour avec succès",
|
||||
"event_type_created_successfully": "Type d'événement créé avec succès",
|
||||
"event_type_updated_successfully": "Type d'événement mis à jour avec succès",
|
||||
"event_type_deleted_successfully": "Type d'événement supprimé avec succès",
|
||||
"hours": "heures",
|
||||
"people": "Personnes",
|
||||
@@ -1988,6 +1989,8 @@
|
||||
"remove_users_from_org_confirm": "Voulez-vous vraiment supprimer {{userCount}} utilisateurs de cette organisation ?",
|
||||
"user_has_no_schedules": "Cet utilisateur n'a pas encore configuré de planning",
|
||||
"user_isnt_in_any_teams": "Cet utilisateur n'est dans aucune équipe",
|
||||
"requires_booker_email_verification": "Nécessite la vérification par e-mail du participant",
|
||||
"description_requires_booker_email_verification": "Pour assurer la vérification par e-mail du participant avant la planification des événements.",
|
||||
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
|
||||
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
|
||||
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
|
||||
@@ -2001,16 +2004,28 @@
|
||||
"no_other_teams_found_description": "Il n'y a pas d'autres équipes dans cette organisation.",
|
||||
"attendee_first_name_variable": "Prénom du participant",
|
||||
"attendee_last_name_variable": "Nom du participant",
|
||||
"attendee_first_name_info": "Le prénom du participant",
|
||||
"attendee_last_name_info": "Le nom de famille du participant",
|
||||
"me": "Moi",
|
||||
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
||||
"member_removed": "Membre supprimé",
|
||||
"my_availability": "Mes disponibilités",
|
||||
"team_availability": "Disponibilités de l'équipe",
|
||||
"backup_code": "Code de récupération",
|
||||
"backup_codes": "Codes de récupération",
|
||||
"backup_code_instructions": "Chaque code de récupération peut être utilisé une seule fois pour accorder l'accès sans votre authentificateur.",
|
||||
"backup_codes_copied": "Codes de récupération copiés !",
|
||||
"incorrect_backup_code": "Le code de récupération est incorrect.",
|
||||
"lost_access": "Accès perdu",
|
||||
"missing_backup_codes": "Aucun code de récupération trouvé. Merci de les générer dans vos paramètres.",
|
||||
"admin_org_notification_email_subject": "Nouvelle organisation créée : action en attente",
|
||||
"hi_admin": "Bonjour administrateur",
|
||||
"admin_org_notification_email_title": "Une organisation nécessite une configuration DNS",
|
||||
"admin_org_notification_email_body_part1": "Une organisation avec le slug « {{orgSlug}} » a été créée.<br /><br />Assurez-vous de configurer votre registre DNS pour pointer le sous-domaine correspondant à la nouvelle organisation où l'application principale est en cours d'exécution. Autrement, l'organisation ne fonctionnera pas.<br /><br />Voici les options de base pour configurer un sous-domaine pour qu'il pointe vers son application afin qu'il charge la page de profil de l'organisation.<br /><br />Vous pouvez le faire soit avec l'enregistrement A :",
|
||||
"admin_org_notification_email_body_part2": "Ou l'enregistrement CNAME :",
|
||||
"admin_org_notification_email_body_part3": "Une fois le sous-domaine configuré, veuillez marquer la configuration DNS comme terminée dans les paramètres d'administration des organisations.",
|
||||
"admin_org_notification_email_cta": "Accéder aux paramètres d'administration des organisations",
|
||||
"org_has_been_processed": "L'organisation a été traitée",
|
||||
"org_error_processing": "Une erreur s'est produite lors du traitement de cette organisation",
|
||||
"orgs_page_description": "Une liste de toutes les organisations. Accepter une organisation permettra à tous les utilisateurs avec ce domaine de messagerie de s'inscrire SANS vérification d'e-mail.",
|
||||
"unverified": "Non vérifié",
|
||||
@@ -2018,5 +2033,8 @@
|
||||
"mark_dns_configured": "Marquer comme DNS configuré",
|
||||
"value": "Valeur",
|
||||
"your_organization_updated_sucessfully": "Votre organisation a été mise à jour avec succès",
|
||||
"team_no_event_types": "Cette équipe n'a aucun type d'événement",
|
||||
"seat_options_doesnt_multiple_durations": "L'option par place ne prend pas en charge les durées multiples",
|
||||
"include_calendar_event": "Inclure l'événement du calendrier",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1944,5 +1944,6 @@
|
||||
"insights_user_filter": "用户:{{userName}}",
|
||||
"insights_subtitle": "查看您活动的预约 insights",
|
||||
"custom_plan": "自定义计划",
|
||||
"include_calendar_event": "包括日历活动",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1854,7 +1854,7 @@
|
||||
"payment_app_commission": "需付款 ({{paymentFeePercentage}}% + {{fee, currency}} 每筆交易佣金)",
|
||||
"email_invite_team": "已邀請 {{email}}",
|
||||
"email_invite_team_bulk": "已邀請 {{userCount}} 位使用者",
|
||||
"error_collecting_card": "卡片發生錯誤",
|
||||
"error_collecting_card": "收集卡片時發生錯誤",
|
||||
"image_size_limit_exceed": "上傳的圖片大小不得超過 5MB 限制",
|
||||
"unauthorized_workflow_error_message": "{{errorCode}}:您未獲得授權,無法啟用或停用此工作流程",
|
||||
"inline_embed": "內嵌式嵌入",
|
||||
|
||||
@@ -58,10 +58,10 @@
|
||||
--cal-bg-inverted: #f3f4f6;
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #dee9fc;
|
||||
--cal-bg-success: #e2fbe8;
|
||||
--cal-bg-attention: #fceed8;
|
||||
--cal-bg-error: #f9e3e2;
|
||||
--cal-bg-info: #263fa9;
|
||||
--cal-bg-success: #306339;
|
||||
--cal-bg-attention: #8e3b1f;
|
||||
--cal-bg-error: #8c2822;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
/* Borders */
|
||||
@@ -80,10 +80,10 @@
|
||||
--cal-text-inverted: #101010;
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321b;
|
||||
--cal-text-error: #752522;
|
||||
--cal-text-info: #dee9fc;
|
||||
--cal-text-success: #e2fbe8;
|
||||
--cal-text-attention: #fceed8;
|
||||
--cal-text-error: #f9e3e2;
|
||||
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"builds": [
|
||||
{
|
||||
"src": "package.json",
|
||||
"use": "https://profile-next-cold-starts.vercel.app/builder.tgz"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"db-studio": "yarn prisma studio",
|
||||
"deploy": "turbo run deploy",
|
||||
"dev:all": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"",
|
||||
"dev:ai": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\" --scope=\"@calcom/ai\"",
|
||||
"dev:api": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\"",
|
||||
"dev:api:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\" --scope=\"@calcom/console\"",
|
||||
"dev:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"",
|
||||
|
||||
@@ -5,15 +5,21 @@ export type GetAppData = (key: string) => unknown;
|
||||
export type SetAppData = (key: string, value: unknown) => void;
|
||||
type LockedIcon = JSX.Element | false | undefined;
|
||||
type Disabled = boolean | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const EventTypeAppContext = React.createContext<[GetAppData, SetAppData, LockedIcon, Disabled]>([
|
||||
() => ({}),
|
||||
() => ({}),
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
|
||||
export type SetAppDataGeneric<TAppData extends ZodType> = <
|
||||
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]
|
||||
>(
|
||||
@@ -21,7 +27,7 @@ export type SetAppDataGeneric<TAppData extends ZodType> = <
|
||||
value: TValue
|
||||
) => void;
|
||||
|
||||
export type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
|
||||
type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
|
||||
key: TKey
|
||||
) => z.infer<TAppData>[TKey];
|
||||
|
||||
@@ -29,7 +35,12 @@ 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, SetAppData, LockedIcon, Disabled];
|
||||
const context = React.useContext(EventTypeAppContext) as {
|
||||
getAppData: GetAppData;
|
||||
setAppData: SetAppData;
|
||||
LockedIcon: LockedIcon;
|
||||
disabled: Disabled;
|
||||
};
|
||||
return context;
|
||||
};
|
||||
export default EventTypeAppContext;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Switch, Badge, Avatar } from "@calcom/ui";
|
||||
|
||||
import type { SetAppDataGeneric } from "../EventTypeAppContext";
|
||||
import type { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
||||
import type { CredentialOwner } from "../types";
|
||||
import OmniInstallAppButton from "./OmniInstallAppButton";
|
||||
|
||||
@@ -16,24 +15,20 @@ export default function AppCard({
|
||||
switchOnClick,
|
||||
switchChecked,
|
||||
children,
|
||||
setAppData,
|
||||
returnTo,
|
||||
teamId,
|
||||
disableSwitch,
|
||||
LockedIcon,
|
||||
}: {
|
||||
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
|
||||
description?: React.ReactNode;
|
||||
switchChecked?: boolean;
|
||||
switchOnClick?: (e: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
setAppData: SetAppDataGeneric<typeof eventTypeAppCardZod>;
|
||||
returnTo?: string;
|
||||
teamId?: number;
|
||||
disableSwitch?: boolean;
|
||||
LockedIcon?: React.ReactNode;
|
||||
}) {
|
||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -92,7 +87,7 @@ export default function AppCard({
|
||||
{app?.isInstalled || app.credentialOwner ? (
|
||||
<div className="ml-auto flex items-center">
|
||||
<Switch
|
||||
disabled={!app.enabled || disableSwitch}
|
||||
disabled={!app.enabled || disabled}
|
||||
onCheckedChange={(enabled) => {
|
||||
if (switchOnClick) {
|
||||
switchOnClick(enabled);
|
||||
|
||||
@@ -19,7 +19,7 @@ export const EventTypeAppCard = (props: {
|
||||
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]}>
|
||||
<EventTypeAppContext.Provider value={{ getAppData, setAppData, LockedIcon, disabled }}>
|
||||
<DynamicComponent
|
||||
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
|
||||
componentMap={EventTypeAddonMap}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import type { EventTypeAppCardApp } from "../types";
|
||||
|
||||
function useIsAppEnabled(app: EventTypeAppCardApp) {
|
||||
const [getAppData, setAppData] = useAppContextWithSchema();
|
||||
const { getAppData, setAppData } = useAppContextWithSchema();
|
||||
const [enabled, setEnabled] = useState(() => {
|
||||
if (!app.credentialOwner) {
|
||||
return getAppData("enabled");
|
||||
|
||||
@@ -6,6 +6,7 @@ import amie_config_json from "./amie/config.json";
|
||||
import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
|
||||
import around_config_json from "./around/config.json";
|
||||
import basecamp3_config_json from "./basecamp3/config.json";
|
||||
import cal_ai_config_json from "./cal-ai/config.json";
|
||||
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
|
||||
import campfire_config_json from "./campfire/config.json";
|
||||
import closecom_config_json from "./closecom/config.json";
|
||||
@@ -75,6 +76,7 @@ export const appStoreMetadata = {
|
||||
applecalendar: applecalendar__metadata_ts,
|
||||
around: around_config_json,
|
||||
basecamp3: basecamp3_config_json,
|
||||
"cal-ai": cal_ai_config_json,
|
||||
caldavcalendar: caldavcalendar__metadata_ts,
|
||||
campfire: campfire_config_json,
|
||||
closecom: closecom_config_json,
|
||||
|
||||
@@ -7,6 +7,7 @@ export const apiHandlers = {
|
||||
applecalendar: import("./applecalendar/api"),
|
||||
around: import("./around/api"),
|
||||
basecamp3: import("./basecamp3/api"),
|
||||
"cal-ai": import("./cal-ai/api"),
|
||||
caldavcalendar: import("./caldavcalendar/api"),
|
||||
campfire: import("./campfire/api"),
|
||||
closecom: import("./closecom/api"),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Select } from "@calcom/ui";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
|
||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
||||
const [projects, setProjects] = useState();
|
||||
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
|
||||
@@ -32,7 +32,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
switchOnClick={(e) => {
|
||||
if (!e) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
items:
|
||||
- 1.png
|
||||
- 2.png
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
||||
|
||||
import checkSession from "../../_utils/auth";
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
|
||||
import appConfig from "../config.json";
|
||||
|
||||
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = checkSession(req);
|
||||
const slug = appConfig.slug;
|
||||
const appType = appConfig.type;
|
||||
|
||||
const ctx = await createContext({ req, res });
|
||||
const caller = viewerRouter.createCaller(ctx);
|
||||
|
||||
const apiKey = await caller.apiKeys.create({
|
||||
note: "Cal AI",
|
||||
expiresAt: null,
|
||||
appId: "cal-ai",
|
||||
});
|
||||
|
||||
await checkInstalled(slug, session.user.id);
|
||||
await createDefaultInstallation({
|
||||
appType,
|
||||
userId: session.user.id,
|
||||
slug,
|
||||
key: {
|
||||
apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) };
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
|
||||
export default defaultHandler({
|
||||
GET: import("./_getAdd"),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default as add } from "./add";
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Cal AI",
|
||||
"slug": "cal-ai",
|
||||
"type": "cal-ai_automation",
|
||||
"logo": "icon.png",
|
||||
"url": "https://cal.ai",
|
||||
"variant": "automation",
|
||||
"categories": ["automation"],
|
||||
"publisher": "Rubric Labs",
|
||||
"email": "hi@cal.ai",
|
||||
"description": "Cal AI is a scheduling assistant powered by GPT. Email hi@cal.ai to chat with your calendar or book, edit and cancel meetings.",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic",
|
||||
"dirName": "cal-ai"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as api from "./api";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/cal-ai",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Cal AI is a scheduling assistant powered by GPT. Email hi@cal.ai to chat with your calendar or book, edit and cancel meetings"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
@@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const trackingId = getAppData("trackingId");
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
disableSwitch={disabled}
|
||||
LockedIcon={LockedIcon}
|
||||
switchOnClick={(e) => {
|
||||
updateEnabled(e);
|
||||
}}
|
||||
|
||||
@@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const trackingId = getAppData("trackingId");
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
disableSwitch={disabled}
|
||||
LockedIcon={LockedIcon}
|
||||
switchOnClick={(e) => {
|
||||
updateEnabled(e);
|
||||
}}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const thankYouPage = getAppData("thankYouPage");
|
||||
const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app);
|
||||
|
||||
@@ -16,11 +16,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
description={t("confirmation_page_gif")}
|
||||
disableSwitch={disabled}
|
||||
LockedIcon={LockedIcon}
|
||||
switchOnClick={(e) => {
|
||||
setShowGifSelection(e);
|
||||
}}
|
||||
|
||||
@@ -84,7 +84,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
};
|
||||
};
|
||||
|
||||
async createEvent(calEventRaw: CalendarEvent): Promise<NewCalendarEventType> {
|
||||
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
|
||||
const eventAttendees = calEventRaw.attendees.map(({ id: _id, ...rest }) => ({
|
||||
...rest,
|
||||
responseStatus: "accepted",
|
||||
@@ -97,6 +97,10 @@ export default class GoogleCalendarService implements Calendar {
|
||||
responseStatus: "accepted",
|
||||
})) || [];
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const [mainHostDestinationCalendar] =
|
||||
calEventRaw?.destinationCalendar && calEventRaw?.destinationCalendar.length > 0
|
||||
? calEventRaw.destinationCalendar
|
||||
: [];
|
||||
const myGoogleAuth = await this.auth.getToken();
|
||||
const payload: calendar_v3.Schema$Event = {
|
||||
summary: calEventRaw.title,
|
||||
@@ -115,8 +119,8 @@ export default class GoogleCalendarService implements Calendar {
|
||||
id: String(calEventRaw.organizer.id),
|
||||
responseStatus: "accepted",
|
||||
organizer: true,
|
||||
email: calEventRaw.destinationCalendar?.externalId
|
||||
? calEventRaw.destinationCalendar.externalId
|
||||
email: mainHostDestinationCalendar?.externalId
|
||||
? mainHostDestinationCalendar.externalId
|
||||
: calEventRaw.organizer.email,
|
||||
},
|
||||
...eventAttendees,
|
||||
@@ -138,13 +142,16 @@ export default class GoogleCalendarService implements Calendar {
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
});
|
||||
const selectedCalendar = calEventRaw.destinationCalendar?.externalId
|
||||
? calEventRaw.destinationCalendar.externalId
|
||||
: "primary";
|
||||
// Find in calEventRaw.destinationCalendar the one with the same credentialId
|
||||
|
||||
const selectedCalendar = calEventRaw.destinationCalendar?.find(
|
||||
(cal) => cal.credentialId === credentialId
|
||||
)?.externalId;
|
||||
|
||||
calendar.events.insert(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
calendarId: selectedCalendar,
|
||||
calendarId: selectedCalendar || "primary",
|
||||
requestBody: payload,
|
||||
conferenceDataVersion: 1,
|
||||
sendUpdates: "none",
|
||||
@@ -188,6 +195,8 @@ export default class GoogleCalendarService implements Calendar {
|
||||
|
||||
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const [mainHostDestinationCalendar] =
|
||||
event?.destinationCalendar && event?.destinationCalendar.length > 0 ? event.destinationCalendar : [];
|
||||
const myGoogleAuth = await this.auth.getToken();
|
||||
const eventAttendees = event.attendees.map(({ ...rest }) => ({
|
||||
...rest,
|
||||
@@ -216,8 +225,8 @@ export default class GoogleCalendarService implements Calendar {
|
||||
id: String(event.organizer.id),
|
||||
organizer: true,
|
||||
responseStatus: "accepted",
|
||||
email: event.destinationCalendar?.externalId
|
||||
? event.destinationCalendar.externalId
|
||||
email: mainHostDestinationCalendar?.externalId
|
||||
? mainHostDestinationCalendar.externalId
|
||||
: event.organizer.email,
|
||||
},
|
||||
...(eventAttendees as any),
|
||||
@@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
|
||||
const selectedCalendar = externalCalendarId
|
||||
? externalCalendarId
|
||||
: event.destinationCalendar?.externalId;
|
||||
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
|
||||
|
||||
calendar.events.update(
|
||||
{
|
||||
@@ -303,7 +312,9 @@ export default class GoogleCalendarService implements Calendar {
|
||||
});
|
||||
|
||||
const defaultCalendarId = "primary";
|
||||
const calendarId = externalCalendarId ? externalCalendarId : event.destinationCalendar?.externalId;
|
||||
const calendarId = externalCalendarId
|
||||
? externalCalendarId
|
||||
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
|
||||
|
||||
calendar.events.delete(
|
||||
{
|
||||
|
||||
@@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const trackingId = getAppData("trackingId");
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
disableSwitch={disabled}
|
||||
LockedIcon={LockedIcon}
|
||||
switchOnClick={(e) => {
|
||||
updateEnabled(e);
|
||||
}}
|
||||
|
||||
@@ -125,7 +125,8 @@ export default class LarkCalendarService implements Calendar {
|
||||
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
||||
let eventId = "";
|
||||
let eventRespData;
|
||||
const calendarId = event.destinationCalendar?.externalId;
|
||||
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||
const calendarId = mainHostDestinationCalendar?.externalId;
|
||||
if (!calendarId) {
|
||||
throw new Error("no calendar id");
|
||||
}
|
||||
@@ -160,7 +161,8 @@ export default class LarkCalendarService implements Calendar {
|
||||
}
|
||||
|
||||
private createAttendees = async (event: CalendarEvent, eventId: string) => {
|
||||
const calendarId = event.destinationCalendar?.externalId;
|
||||
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||
const calendarId = mainHostDestinationCalendar?.externalId;
|
||||
if (!calendarId) {
|
||||
this.log.error("no calendar id provided in createAttendees");
|
||||
throw new Error("no calendar id provided in createAttendees");
|
||||
@@ -187,7 +189,8 @@ export default class LarkCalendarService implements Calendar {
|
||||
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
|
||||
const eventId = uid;
|
||||
let eventRespData;
|
||||
const calendarId = externalCalendarId || event.destinationCalendar?.externalId;
|
||||
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||
const calendarId = externalCalendarId || mainHostDestinationCalendar?.externalId;
|
||||
if (!calendarId) {
|
||||
this.log.error("no calendar id provided in updateEvent");
|
||||
throw new Error("no calendar id provided in updateEvent");
|
||||
@@ -231,7 +234,8 @@ export default class LarkCalendarService implements Calendar {
|
||||
* @returns
|
||||
*/
|
||||
async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
|
||||
const calendarId = externalCalendarId || event.destinationCalendar?.externalId;
|
||||
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||
const calendarId = externalCalendarId || mainHostDestinationCalendar?.externalId;
|
||||
if (!calendarId) {
|
||||
this.log.error("no calendar id provided in deleteEvent");
|
||||
throw new Error("no calendar id provided in deleteEvent");
|
||||
|
||||
@@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const trackingId = getAppData("trackingId");
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
disableSwitch={disabled}
|
||||
LockedIcon={LockedIcon}
|
||||
switchOnClick={updateEnabled}
|
||||
switchChecked={enabled}
|
||||
teamId={eventType.team?.id || undefined}>
|
||||
|
||||
@@ -70,9 +70,10 @@ export default class Office365CalendarService implements Calendar {
|
||||
}
|
||||
|
||||
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
||||
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||
try {
|
||||
const eventsUrl = event.destinationCalendar?.externalId
|
||||
? `/me/calendars/${event.destinationCalendar?.externalId}/events`
|
||||
const eventsUrl = mainHostDestinationCalendar?.externalId
|
||||
? `/me/calendars/${mainHostDestinationCalendar?.externalId}/events`
|
||||
: "/me/calendar/events";
|
||||
|
||||
const response = await this.fetcher(eventsUrl, {
|
||||
|
||||
@@ -16,7 +16,7 @@ type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const { asPath } = useRouter();
|
||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
@@ -38,7 +38,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={WEBAPP_URL + asPath}
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(enabled) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ class Paypal {
|
||||
}
|
||||
|
||||
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
|
||||
this.getAccessToken();
|
||||
await this.getAccessToken();
|
||||
return fetch(`${this.url}${endpoint}`, {
|
||||
method: "get",
|
||||
...init,
|
||||
|
||||
@@ -7,17 +7,14 @@ import { TextField } from "@calcom/ui";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const plausibleUrl = getAppData("PLAUSIBLE_URL");
|
||||
const trackingId = getAppData("trackingId");
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
disableSwitch={disabled}
|
||||
LockedIcon={LockedIcon}
|
||||
switchOnClick={(e) => {
|
||||
updateEnabled(e);
|
||||
}}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
|
||||
const { t } = useLocale();
|
||||
const [_getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const [additionalParameters, setAdditionalParameters] = useState("");
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
@@ -37,10 +37,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
disableSwitch={disabled}
|
||||
LockedIcon={LockedIcon}
|
||||
switchOnClick={(e) => {
|
||||
updateEnabled(e);
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user ",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*",
|
||||
"dotenv": "^16.0.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"json-logic-js": "^2.0.2",
|
||||
"react-awesome-query-builder": "^5.1.2"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// TODO: i18n
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useEffect } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
List,
|
||||
ListLinkItem,
|
||||
Tooltip,
|
||||
ArrowButton,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
BarChart,
|
||||
@@ -83,6 +85,20 @@ export default function RoutingForms({
|
||||
const { hasPaidPlan } = useHasPaidPlan();
|
||||
const routerQuery = useRouterQuery();
|
||||
const hookForm = useFormContext<RoutingFormWithResponseCount>();
|
||||
const utils = trpc.useContext();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
const mutation = trpc.viewer.routingFormOrder.useMutation({
|
||||
onError: async (err) => {
|
||||
console.error(err.message);
|
||||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
await utils.viewer.appRoutingForms.invalidate();
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.appRoutingForms.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
hookForm.reset({});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -128,6 +144,29 @@ export default function RoutingForms({
|
||||
},
|
||||
];
|
||||
|
||||
async function moveRoutingForm(index: number, increment: 1 | -1) {
|
||||
const types = forms?.map((type) => {
|
||||
return type.form;
|
||||
});
|
||||
|
||||
if (types?.length) {
|
||||
const newList = [...types];
|
||||
|
||||
const type = types[index];
|
||||
const tmp = types[index + increment];
|
||||
if (tmp) {
|
||||
newList[index] = tmp;
|
||||
newList[index + increment] = type;
|
||||
}
|
||||
|
||||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
|
||||
mutation.mutate({
|
||||
ids: newList?.map((type) => type.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<ShellMain
|
||||
@@ -177,8 +216,8 @@ export default function RoutingForms({
|
||||
}
|
||||
SkeletonLoader={SkeletonLoaderTeamList}>
|
||||
<div className="bg-default mb-16 overflow-hidden">
|
||||
<List data-testid="routing-forms-list">
|
||||
{forms?.map(({ form, readOnly }) => {
|
||||
<List data-testid="routing-forms-list" ref={parent}>
|
||||
{forms?.map(({ form, readOnly }, index) => {
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
@@ -187,116 +226,129 @@ export default function RoutingForms({
|
||||
form.routes = form.routes || [];
|
||||
const fields = form.fields || [];
|
||||
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
|
||||
const firstItem = forms[0].form;
|
||||
const lastItem = forms[forms.length - 1].form;
|
||||
|
||||
return (
|
||||
<ListLinkItem
|
||||
key={form.id}
|
||||
href={appUrl + "/form-edit/" + form.id}
|
||||
heading={form.name}
|
||||
disabled={readOnly}
|
||||
subHeading={description}
|
||||
className="space-x-2 rtl:space-x-reverse"
|
||||
actions={
|
||||
<>
|
||||
{form.team?.name && (
|
||||
<div className="border-r-2 border-neutral-300">
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{form.team.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<FormAction
|
||||
disabled={readOnly}
|
||||
className="self-center"
|
||||
action="toggle"
|
||||
routingForm={form}
|
||||
/>
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("preview")}>
|
||||
<div
|
||||
className="group flex w-full max-w-full items-center justify-between overflow-hidden"
|
||||
key={form.id}>
|
||||
{!(firstItem && firstItem.id === form.id) && (
|
||||
<ArrowButton onClick={() => moveRoutingForm(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === form.id) && (
|
||||
<ArrowButton onClick={() => moveRoutingForm(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<ListLinkItem
|
||||
href={appUrl + "/form-edit/" + form.id}
|
||||
heading={form.name}
|
||||
disabled={readOnly}
|
||||
subHeading={description}
|
||||
className="space-x-2 rtl:space-x-reverse"
|
||||
actions={
|
||||
<>
|
||||
{form.team?.name && (
|
||||
<div className="border-r-2 border-neutral-300">
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{form.team.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<FormAction
|
||||
disabled={readOnly}
|
||||
className="self-center"
|
||||
action="toggle"
|
||||
routingForm={form}
|
||||
/>
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("preview")}>
|
||||
<FormAction
|
||||
action="preview"
|
||||
routingForm={form}
|
||||
target="_blank"
|
||||
StartIcon={ExternalLink}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
action="preview"
|
||||
routingForm={form}
|
||||
target="_blank"
|
||||
StartIcon={ExternalLink}
|
||||
action="copyLink"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={LinkIcon}
|
||||
tooltip={t("copy_link_to_form")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="copyLink"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={LinkIcon}
|
||||
tooltip={t("copy_link_to_form")}
|
||||
/>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="embed"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={Code}
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<FormActionsDropdown disabled={readOnly}>
|
||||
<FormAction
|
||||
action="edit"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="!flex"
|
||||
StartIcon={Edit}>
|
||||
{t("edit")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="download"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
StartIcon={Download}>
|
||||
{t("download_responses")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="duplicate"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
StartIcon={Copy}>
|
||||
{t("duplicate")}
|
||||
</FormAction>
|
||||
{typeformApp?.isInstalled ? (
|
||||
action="embed"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={Code}
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<FormActionsDropdown disabled={readOnly}>
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
action="edit"
|
||||
routingForm={form}
|
||||
action="copyRedirectUrl"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon={LinkIcon}>
|
||||
{t("Copy Typeform Redirect Url")}
|
||||
className="!flex"
|
||||
StartIcon={Edit}>
|
||||
{t("edit")}
|
||||
</FormAction>
|
||||
) : null}
|
||||
<FormAction
|
||||
action="_delete"
|
||||
routingForm={form}
|
||||
color="destructive"
|
||||
className="w-full"
|
||||
StartIcon={Trash}>
|
||||
{t("delete")}
|
||||
</FormAction>
|
||||
</FormActionsDropdown>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="gray" startIcon={Menu}>
|
||||
{fields.length} {fields.length === 1 ? "field" : "fields"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={GitMerge}>
|
||||
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={MessageCircle}>
|
||||
{form._count.responses}{" "}
|
||||
{form._count.responses === 1 ? "response" : "responses"}
|
||||
</Badge>
|
||||
</div>
|
||||
</ListLinkItem>
|
||||
<FormAction
|
||||
action="download"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
StartIcon={Download}>
|
||||
{t("download_responses")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="duplicate"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
StartIcon={Copy}>
|
||||
{t("duplicate")}
|
||||
</FormAction>
|
||||
{typeformApp?.isInstalled ? (
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
routingForm={form}
|
||||
action="copyRedirectUrl"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon={LinkIcon}>
|
||||
{t("Copy Typeform Redirect Url")}
|
||||
</FormAction>
|
||||
) : null}
|
||||
<FormAction
|
||||
action="_delete"
|
||||
routingForm={form}
|
||||
color="destructive"
|
||||
className="w-full"
|
||||
StartIcon={Trash}>
|
||||
{t("delete")}
|
||||
</FormAction>
|
||||
</FormActionsDropdown>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="gray" startIcon={Menu}>
|
||||
{fields.length} {fields.length === 1 ? "field" : "fields"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={GitMerge}>
|
||||
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={MessageCircle}>
|
||||
{form._count.responses}{" "}
|
||||
{form._count.responses === 1 ? "response" : "responses"}
|
||||
</Badge>
|
||||
</div>
|
||||
</ListLinkItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
@@ -21,7 +21,7 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
await page.waitForSelector('[data-testid="routing-forms-list"]');
|
||||
// Ensure that it's visible in forms list
|
||||
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
|
||||
expect(await page.locator('[data-testid="routing-forms-list"] > div').count()).toBe(1);
|
||||
|
||||
await gotoRoutingLink({ page, formId });
|
||||
await expect(page.locator("text=Test Form Name")).toBeVisible();
|
||||
|
||||
@@ -59,6 +59,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
||||
fields: true,
|
||||
settings: true,
|
||||
teamId: true,
|
||||
position: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -26,9 +26,14 @@ export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
|
||||
|
||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
createdAt: "asc",
|
||||
},
|
||||
],
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
|
||||
@@ -15,7 +15,7 @@ type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const pathname = usePathname();
|
||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency");
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
@@ -38,10 +38,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={WEBAPP_URL + pathname + "?tabName=apps"}
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
disableSwitch={disabled}
|
||||
LockedIcon={LockedIcon}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(enabled) => {
|
||||
setRequirePayment(enabled);
|
||||
|
||||
@@ -50,7 +50,8 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"],
|
||||
bookerEmail: string,
|
||||
paymentOption: PaymentOption
|
||||
paymentOption: PaymentOption,
|
||||
eventTitle?: string
|
||||
) {
|
||||
try {
|
||||
// Ensure that the payment service can support the passed payment option
|
||||
@@ -78,6 +79,12 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
currency: this.credentials.default_currency,
|
||||
payment_method_types: ["card"],
|
||||
customer: customer.id,
|
||||
metadata: {
|
||||
identifier: "cal.com",
|
||||
bookingId,
|
||||
bookerEmail,
|
||||
eventName: eventTitle || "",
|
||||
},
|
||||
};
|
||||
|
||||
const paymentIntent = await this.stripe.paymentIntents.create(params, {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"stripe": "^9.16.0",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*",
|
||||
|
||||
+1
-2
@@ -8,13 +8,12 @@ import { TextField } from "@calcom/ui";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const trackingId = getAppData("trackingId");
|
||||
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
switchOnClick={(e) => {
|
||||
if (!e) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user