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 | ||
|
|
ccc814eb54 | ||
|
|
2ee0ec11d6 | ||
|
|
3223beb9ac | ||
|
|
c1bcfcfa3d | ||
|
|
83aea7d28c | ||
|
|
a810a538e9 | ||
|
|
1cd0d7b5ca |
No files matched your search
@@ -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"]
|
||||||
|
}
|
||||||
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
|
|||||||
teamId: z.number(),
|
teamId: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const membershipCreateBodySchema = Membership.partial({
|
export const membershipCreateBodySchema = Membership.omit({ id: true })
|
||||||
accepted: true,
|
.partial({
|
||||||
role: true,
|
accepted: true,
|
||||||
disableImpersonation: true,
|
role: true,
|
||||||
}).transform((v) => ({
|
disableImpersonation: true,
|
||||||
accepted: false,
|
})
|
||||||
role: MembershipRole.MEMBER,
|
.transform((v) => ({
|
||||||
disableImpersonation: false,
|
accepted: false,
|
||||||
...v,
|
role: MembershipRole.MEMBER,
|
||||||
}));
|
disableImpersonation: false,
|
||||||
|
...v,
|
||||||
|
}));
|
||||||
|
|
||||||
export const membershipEditBodySchema = Membership.omit({
|
export const membershipEditBodySchema = Membership.omit({
|
||||||
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
||||||
|
|||||||
@@ -40,6 +40,6 @@
|
|||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.22.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,10 +248,10 @@
|
|||||||
--cal-bg-inverted: #f3f4f6;
|
--cal-bg-inverted: #f3f4f6;
|
||||||
|
|
||||||
/* background -> components*/
|
/* background -> components*/
|
||||||
--cal-bg-info: #dee9fc;
|
--cal-bg-info: #263fa9;
|
||||||
--cal-bg-success: #e2fbe8;
|
--cal-bg-success: #306339;
|
||||||
--cal-bg-attention: #fceed8;
|
--cal-bg-attention: #8e3b1f;
|
||||||
--cal-bg-error: #f9e3e2;
|
--cal-bg-error: #8c2822;
|
||||||
--cal-bg-dark-error: #752522;
|
--cal-bg-dark-error: #752522;
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
@@ -269,10 +269,10 @@
|
|||||||
--cal-text-inverted: #101010;
|
--cal-text-inverted: #101010;
|
||||||
|
|
||||||
/* Content/Text -> components */
|
/* Content/Text -> components */
|
||||||
--cal-text-info: #253985;
|
--cal-text-info: #dee9fc;
|
||||||
--cal-text-success: #285231;
|
--cal-text-success: #e2fbe8;
|
||||||
--cal-text-attention: #73321b;
|
--cal-text-attention: #fceed8;
|
||||||
--cal-text-error: #752522;
|
--cal-text-error: #f9e3e2;
|
||||||
|
|
||||||
/* Brand shenanigans
|
/* Brand shenanigans
|
||||||
-> These will be computed for the users theme at runtime.
|
-> 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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Label, Input } from "@calcom/ui";
|
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 [value, onChange] = useState("");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const methods = useFormContext();
|
const methods = useFormContext();
|
||||||
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
|
|||||||
name={`2fa${index + 1}`}
|
name={`2fa${index + 1}`}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
{...digit}
|
{...digit}
|
||||||
autoFocus={index === 0}
|
autoFocus={autoFocus && index === 0}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{selectedLocation && LocationOptions}
|
{selectedLocation && LocationOptions}
|
||||||
<DialogFooter>
|
<DialogFooter className="mt-4">
|
||||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
<Button
|
||||||
<Button
|
onClick={() => {
|
||||||
onClick={() => {
|
setShowLocationModal(false);
|
||||||
setShowLocationModal(false);
|
setSelectedLocation?.(undefined);
|
||||||
setSelectedLocation?.(undefined);
|
setEditingLocationType?.("");
|
||||||
setEditingLocationType?.("");
|
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
}}
|
||||||
}}
|
type="button"
|
||||||
type="button"
|
color="secondary">
|
||||||
color="secondary">
|
{t("cancel")}
|
||||||
{t("cancel")}
|
</Button>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button data-testid="update-location" type="submit">
|
<Button data-testid="update-location" type="submit">
|
||||||
{t("update")}
|
{t("update")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
<DialogContent>
|
<DialogContent enableOverflow>
|
||||||
<div className="flex flex-row space-x-3">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||||
<Clock className="m-auto h-6 w-6" />
|
<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 type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { SchedulingType } from "@calcom/prisma/enums";
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
import { Label, Select } from "@calcom/ui";
|
import { Label, Select } from "@calcom/ui";
|
||||||
@@ -18,13 +17,14 @@ interface IUserToValue {
|
|||||||
id: number | null;
|
id: number | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
avatar: string;
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapUserToValue = ({ id, name, username, email }: IUserToValue, pendingString: string) => ({
|
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
|
||||||
value: `${id || ""}`,
|
value: `${id || ""}`,
|
||||||
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
||||||
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
avatar,
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||||
|
|
||||||
|
import BackupCode from "@components/auth/BackupCode";
|
||||||
import TwoFactor from "@components/auth/TwoFactor";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
|
|
||||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||||
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DisableTwoFactorValues {
|
interface DisableTwoFactorValues {
|
||||||
|
backupCode: string;
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
@@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
}: DisableTwoFactorAuthModalProps) => {
|
}: DisableTwoFactorAuthModalProps) => {
|
||||||
const [isDisabling, setIsDisabling] = useState(false);
|
const [isDisabling, setIsDisabling] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const form = useForm<DisableTwoFactorValues>();
|
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) {
|
if (isDisabling) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
|
setTwoFactorLostAccess(false);
|
||||||
|
resetForm();
|
||||||
onDisable();
|
onDisable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
if (body.error === ErrorCode.IncorrectPassword) {
|
if (body.error === ErrorCode.IncorrectPassword) {
|
||||||
setErrorMessage(t("incorrect_password"));
|
setErrorMessage(t("incorrect_password"));
|
||||||
}
|
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||||
if (body.error === ErrorCode.SecondFactorRequired) {
|
|
||||||
setErrorMessage(t("2fa_required"));
|
setErrorMessage(t("2fa_required"));
|
||||||
}
|
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
|
||||||
setErrorMessage(t("incorrect_2fa"));
|
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 {
|
} else {
|
||||||
setErrorMessage(t("something_went_wrong"));
|
setErrorMessage(t("something_went_wrong"));
|
||||||
}
|
}
|
||||||
@@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{!disablePassword && (
|
{!disablePassword && (
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
required
|
||||||
labelProps={{
|
labelProps={{
|
||||||
className: "block text-sm font-medium text-default",
|
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"
|
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>}
|
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter showDivider className="relative mt-5">
|
<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}>
|
<Button color="secondary" onClick={onCancel}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
|
|||||||
|
|
||||||
enum SetupStep {
|
enum SetupStep {
|
||||||
ConfirmPassword,
|
ConfirmPassword,
|
||||||
|
DisplayBackupCodes,
|
||||||
DisplayQrCode,
|
DisplayQrCode,
|
||||||
EnterTotpCode,
|
EnterTotpCode,
|
||||||
}
|
}
|
||||||
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
|
|
||||||
const setupDescriptions = {
|
const setupDescriptions = {
|
||||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||||
|
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||||
};
|
};
|
||||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [backupCodes, setBackupCodes] = useState([]);
|
||||||
|
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||||
const [dataUri, setDataUri] = useState("");
|
const [dataUri, setDataUri] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
setPassword("");
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStep(SetupStep.ConfirmPassword);
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSetup(e: React.FormEvent) {
|
async function handleSetup(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
if (response.status === 200) {
|
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);
|
setDataUri(body.dataUri);
|
||||||
setSecret(body.secret);
|
setSecret(body.secret);
|
||||||
setStep(SetupStep.DisplayQrCode);
|
setStep(SetupStep.DisplayQrCode);
|
||||||
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
onEnable();
|
setStep(SetupStep.DisplayBackupCodes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
}
|
}
|
||||||
}, [form, handleEnableRef, totpCode]);
|
}, [form, handleEnableRef, totpCode]);
|
||||||
|
|
||||||
|
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<form onSubmit={handleSetup}>
|
<form onSubmit={handleSetup}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<TextField
|
<PasswordField
|
||||||
label={t("password")}
|
label={t("password")}
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
@@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
</WithStep>
|
</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}>
|
<Form handleSubmit={handleEnable} form={form}>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||||
<div className="-mt-4 pb-2">
|
<div className="-mt-4 pb-2">
|
||||||
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
</div>
|
</div>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<DialogFooter className="mt-8" showDivider>
|
<DialogFooter className="mt-8" showDivider>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
{step !== SetupStep.DisplayBackupCodes ? (
|
||||||
{t("cancel")}
|
<Button
|
||||||
</Button>
|
color="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
onCancel();
|
||||||
|
resetState();
|
||||||
|
}}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
{t("enable")}
|
{t("enable")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</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>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</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", {
|
return fetch("/api/auth/two-factor/totp/disable", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ password, code }),
|
body: JSON.stringify({ password, code, backupCode }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formMethods = useForm<{
|
type formSchemaType = z.infer<typeof formSchema>;
|
||||||
username: string;
|
|
||||||
email_address: string;
|
const formMethods = useForm<formSchemaType>({
|
||||||
full_name: string;
|
mode: "onChange",
|
||||||
password: string;
|
|
||||||
}>({
|
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
|||||||
props.onError();
|
props.onError();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
const onSubmit = formMethods.handleSubmit(async (data) => {
|
||||||
props.onSubmit();
|
props.onSubmit();
|
||||||
const response = await fetch("/api/auth/setup", {
|
const response = await fetch("/api/auth/setup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
|||||||
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
name="username"
|
name="username"
|
||||||
onChange={async (e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange(e.target.value);
|
|
||||||
formMethods.setValue("username", e.target.value);
|
|
||||||
await formMethods.trigger("username");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
|||||||
<TextField
|
<TextField
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={async (e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange(e.target.value);
|
|
||||||
formMethods.setValue("full_name", e.target.value);
|
|
||||||
await formMethods.trigger("full_name");
|
|
||||||
}}
|
|
||||||
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
||||||
type="text"
|
type="text"
|
||||||
name="full_name"
|
name="full_name"
|
||||||
@@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
|||||||
<EmailField
|
<EmailField
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={async (e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange(e.target.value);
|
|
||||||
formMethods.setValue("email_address", e.target.value);
|
|
||||||
await formMethods.trigger("email_address");
|
|
||||||
}}
|
|
||||||
className="my-0"
|
className="my-0"
|
||||||
name="email_address"
|
name="email_address"
|
||||||
/>
|
/>
|
||||||
@@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
|||||||
<PasswordField
|
<PasswordField
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={async (e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange(e.target.value);
|
|
||||||
formMethods.setValue("password", e.target.value);
|
|
||||||
await formMethods.trigger("password");
|
|
||||||
}}
|
|
||||||
hintErrors={["caplow", "admin_min", "num"]}
|
hintErrors={["caplow", "admin_min", "num"]}
|
||||||
name="password"
|
name="password"
|
||||||
className="my-0"
|
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.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
|
||||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
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
|
// To be able to use the version in the app without having to import package.json
|
||||||
process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
|
process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
|
||||||
|
|
||||||
@@ -226,7 +227,7 @@ const nextConfig = {
|
|||||||
async rewrites() {
|
async rewrites() {
|
||||||
const beforeFiles = [
|
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
|
// 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,
|
...matcherConfigRootPath,
|
||||||
@@ -333,44 +334,46 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...[
|
...(isOrganizationsEnabled
|
||||||
{
|
? [
|
||||||
...matcherConfigRootPath,
|
|
||||||
headers: [
|
|
||||||
{
|
{
|
||||||
key: "X-Cal-Org-path",
|
...matcherConfigRootPath,
|
||||||
value: "/team/:orgSlug",
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Cal-Org-path",
|
||||||
|
value: "/team/:orgSlug",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...matcherConfigUserRoute,
|
|
||||||
headers: [
|
|
||||||
{
|
{
|
||||||
key: "X-Cal-Org-path",
|
...matcherConfigUserRoute,
|
||||||
value: "/org/:orgSlug/:user",
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Cal-Org-path",
|
||||||
|
value: "/org/:orgSlug/:user",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...matcherConfigUserTypeRoute,
|
|
||||||
headers: [
|
|
||||||
{
|
{
|
||||||
key: "X-Cal-Org-path",
|
...matcherConfigUserTypeRoute,
|
||||||
value: "/org/:orgSlug/:user/:type",
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Cal-Org-path",
|
||||||
|
value: "/org/:orgSlug/:user/:type",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...matcherConfigUserTypeEmbedRoute,
|
|
||||||
headers: [
|
|
||||||
{
|
{
|
||||||
key: "X-Cal-Org-path",
|
...matcherConfigUserTypeEmbedRoute,
|
||||||
value: "/org/:orgSlug/:user/:type/embed",
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Cal-Org-path",
|
||||||
|
value: "/org/:orgSlug/:user/:type/embed",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
},
|
: []),
|
||||||
],
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
async redirects() {
|
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
|
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
|
||||||
...(process.env.NODE_ENV === "development" &&
|
...(process.env.NODE_ENV === "development" &&
|
||||||
// Safer to enable the redirect only when the user is opting to test out organizations
|
// 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
|
// Prevent infinite redirect by checking that we aren't already on localhost
|
||||||
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
|
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.2.4",
|
"version": "3.2.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
"turndown": "^7.1.1",
|
"turndown": "^7.1.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"web3": "^1.7.5",
|
"web3": "^1.7.5",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.22.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.19.6",
|
"@babel/core": "^7.19.6",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
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 { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||||
|
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { teamMetadataSchema } 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) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).end();
|
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 });
|
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) {
|
if (!req.body.code) {
|
||||||
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
|
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
|
||||||
// throw new 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,
|
id: session.user.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
backupCodes: null,
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
twoFactorSecret: null,
|
twoFactorSecret: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { authenticator } from "otplib";
|
import { authenticator } from "otplib";
|
||||||
import qrcode from "qrcode";
|
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.
|
// bytes without updating the sanity checks in the enable and login endpoints.
|
||||||
const secret = authenticator.generateSecret(20);
|
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({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
|
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 keyUri = authenticator.keyuri(name, "Cal", secret);
|
||||||
const dataUri = await qrcode.toDataURL(keyUri);
|
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 attendeesList = await Promise.all(attendeesListPromises);
|
||||||
|
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
|
||||||
const evt: CalendarEvent = {
|
const evt: CalendarEvent = {
|
||||||
type: booking.title,
|
type: booking.title,
|
||||||
title: booking.title,
|
title: booking.title,
|
||||||
@@ -127,7 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
attendees: attendeesList,
|
attendees: attendeesList,
|
||||||
uid: booking.uid,
|
uid: booking.uid,
|
||||||
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
||||||
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
|
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
await sendOrganizerRequestReminderEmail(evt);
|
await sendOrganizerRequestReminderEmail(evt);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@calcom/features/webhooks/lib/cron";
|
||||||
@@ -34,13 +34,17 @@ const triggerWebhook = async ({
|
|||||||
booking: {
|
booking: {
|
||||||
userId: number | undefined;
|
userId: number | undefined;
|
||||||
eventTypeId: number | null;
|
eventTypeId: number | null;
|
||||||
|
eventTypeParentId: number | null | undefined;
|
||||||
teamId?: number | null;
|
teamId?: number | null;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
|
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
|
||||||
// Send Webhook call if hooked to BOOKING.RECORDING_READY
|
// Send Webhook call if hooked to BOOKING.RECORDING_READY
|
||||||
|
|
||||||
|
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
|
||||||
|
|
||||||
const subscriberOptions = {
|
const subscriberOptions = {
|
||||||
userId: booking.userId,
|
userId: triggerForUser ? booking.userId : null,
|
||||||
eventTypeId: booking.eventTypeId,
|
eventTypeId: booking.eventTypeId,
|
||||||
triggerEvent: eventTrigger,
|
triggerEvent: eventTrigger,
|
||||||
teamId: booking.teamId,
|
teamId: booking.teamId,
|
||||||
@@ -183,6 +187,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
booking: {
|
booking: {
|
||||||
userId: booking?.user?.id,
|
userId: booking?.user?.id,
|
||||||
eventTypeId: booking.eventTypeId,
|
eventTypeId: booking.eventTypeId,
|
||||||
|
eventTypeParentId: booking.eventType?.parentId,
|
||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
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 { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import type { WithNonceProps } from "@lib/withNonce";
|
import type { WithNonceProps } from "@lib/withNonce";
|
||||||
@@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce";
|
|||||||
|
|
||||||
import AddToHomescreen from "@components/AddToHomescreen";
|
import AddToHomescreen from "@components/AddToHomescreen";
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
import BackupCode from "@components/auth/BackupCode";
|
||||||
import TwoFactor from "@components/auth/TwoFactor";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
import AuthContainer from "@components/ui/AuthContainer";
|
import AuthContainer from "@components/ui/AuthContainer";
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ interface LoginValues {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
|
backupCode: string;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}
|
}
|
||||||
export default function Login({
|
export default function Login({
|
||||||
@@ -65,6 +67,7 @@ export default function Login({
|
|||||||
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
|
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
|
||||||
const { register, formState } = methods;
|
const { register, formState } = methods;
|
||||||
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
|
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
|
||||||
|
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const errorMessages: { [key: string]: string } = {
|
const errorMessages: { [key: string]: string } = {
|
||||||
@@ -98,15 +101,35 @@ export default function Login({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const TwoFactorFooter = (
|
const TwoFactorFooter = (
|
||||||
<Button
|
<>
|
||||||
onClick={() => {
|
<Button
|
||||||
setTwoFactorRequired(false);
|
onClick={() => {
|
||||||
methods.setValue("totpCode", "");
|
if (twoFactorLostAccess) {
|
||||||
}}
|
setTwoFactorLostAccess(false);
|
||||||
StartIcon={ArrowLeft}
|
methods.setValue("backupCode", "");
|
||||||
color="minimal">
|
} else {
|
||||||
{t("go_back")}
|
setTwoFactorRequired(false);
|
||||||
</Button>
|
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 = (
|
const ExternalTotpFooter = (
|
||||||
@@ -130,8 +153,9 @@ export default function Login({
|
|||||||
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
|
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
|
||||||
// we're logged in! let's do a hard refresh to the desired url
|
// we're logged in! let's do a hard refresh to the desired url
|
||||||
else if (!res.error) router.push(callbackUrl);
|
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.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
|
// fallback if error not found
|
||||||
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
||||||
};
|
};
|
||||||
@@ -194,7 +218,7 @@ export default function Login({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{twoFactorRequired && <TwoFactor center />}
|
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
|
||||||
|
|
||||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
|
|||||||
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
||||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||||
import Shell from "@calcom/features/shell/Shell";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
import { availabilityAsString } from "@calcom/lib/availability";
|
import { availabilityAsString } from "@calcom/lib/availability";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -17,11 +18,6 @@ import {
|
|||||||
ConfirmationDialogContent,
|
ConfirmationDialogContent,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
Dropdown,
|
|
||||||
DropdownItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Form,
|
Form,
|
||||||
Label,
|
Label,
|
||||||
showToast,
|
showToast,
|
||||||
@@ -32,7 +28,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
VerticalDivider,
|
VerticalDivider,
|
||||||
} from "@calcom/ui";
|
} 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 PageWrapper from "@components/PageWrapper";
|
||||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
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 scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
|
||||||
const fromEventType = searchParams?.get("fromEventType");
|
const fromEventType = searchParams?.get("fromEventType");
|
||||||
const { timeFormat } = me.data || { timeFormat: null };
|
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(
|
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
|
||||||
{ scheduleId },
|
{ scheduleId },
|
||||||
{
|
{
|
||||||
@@ -225,33 +221,60 @@ export default function Availability() {
|
|||||||
</ConfirmationDialogContent>
|
</ConfirmationDialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<VerticalDivider className="hidden sm:inline" />
|
<VerticalDivider className="hidden sm:inline" />
|
||||||
<Dropdown>
|
<div
|
||||||
<DropdownMenuTrigger asChild>
|
className={classNames(
|
||||||
<Button className="sm:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
openSidebar
|
||||||
</DropdownMenuTrigger>
|
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
|
||||||
<DropdownMenuContent style={{ minWidth: "200px" }}>
|
: ""
|
||||||
<DropdownItem
|
)}>
|
||||||
type="button"
|
<div
|
||||||
color="destructive"
|
className={classNames(
|
||||||
StartIcon={Trash}
|
"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",
|
||||||
onClick={() => setDeleteDialogOpen(true)}>
|
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
|
||||||
{t("delete")}
|
)}>
|
||||||
</DropdownItem>
|
<div className="flex flex-row items-center pt-5">
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Button StartIcon={ArrowLeft} color="minimal" onClick={() => setOpenSidebar(false)} />
|
||||||
<ConfirmationDialogContent
|
<p className="-ml-2">{t("availability_settings")}</p>
|
||||||
isLoading={deleteMutation.isLoading}
|
<Dialog>
|
||||||
variety="danger"
|
<DialogTrigger asChild>
|
||||||
title={t("delete_schedule")}
|
<Button
|
||||||
confirmBtnText={t("delete")}
|
StartIcon={Trash}
|
||||||
loadingText={t("delete")}
|
variant="icon"
|
||||||
onConfirm={() => {
|
color="destructive"
|
||||||
schedule !== undefined && deleteMutation.mutate({ scheduleId: schedule.id });
|
aria-label={t("delete")}
|
||||||
}}>
|
className="ml-16 inline"
|
||||||
{t("delete_schedule_description")}
|
disabled={schedule?.isLastSchedule}
|
||||||
</ConfirmationDialogContent>
|
tooltip={schedule?.isLastSchedule ? t("requires_at_least_one_schedule") : t("delete")}
|
||||||
</Dialog>
|
/>
|
||||||
<DropdownMenuSeparator />
|
</DialogTrigger>
|
||||||
<div className="flex h-9 flex-row items-center justify-between px-4 py-2 hover:bg-gray-100">
|
<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
|
<Skeleton
|
||||||
as={Label}
|
as={Label}
|
||||||
htmlFor="hiddenSwitch"
|
htmlFor="hiddenSwitch"
|
||||||
@@ -267,9 +290,44 @@ export default function Availability() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<div className="border-default border-l-2" />
|
||||||
<Button
|
<Button
|
||||||
className="ml-4 lg:ml-0"
|
className="ml-4 lg:ml-0"
|
||||||
@@ -278,6 +336,13 @@ export default function Availability() {
|
|||||||
loading={updateMutation.isLoading}>
|
loading={updateMutation.isLoading}>
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="ml-3 sm:hidden"
|
||||||
|
StartIcon={MoreVertical}
|
||||||
|
variant="icon"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => setOpenSidebar(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<div className="mt-4 w-full md:mt-0">
|
<div className="mt-4 w-full md:mt-0">
|
||||||
@@ -313,7 +378,7 @@ export default function Availability() {
|
|||||||
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
|
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
|
||||||
</div>
|
</div>
|
||||||
</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 className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||||
<div>
|
<div>
|
||||||
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
|
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
|
||||||
@@ -335,7 +400,7 @@ export default function Availability() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr className="border-subtle my-6 mr-8" />
|
<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">
|
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
|
||||||
{t("something_doesnt_look_right")}
|
{t("something_doesnt_look_right")}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
|
|||||||
@@ -183,12 +183,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
created: true,
|
created: true,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
showToast(
|
showToast(t("event_type_updated_successfully"), "success");
|
||||||
t("event_type_updated_successfully", {
|
|
||||||
eventTypeTitle: eventType.title,
|
|
||||||
}),
|
|
||||||
"success"
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await utils.viewer.eventTypes.get.invalidate();
|
await utils.viewer.eventTypes.get.invalidate();
|
||||||
|
|||||||
@@ -52,10 +52,9 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
Switch,
|
Switch,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
ArrowButton,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Code,
|
Code,
|
||||||
Copy,
|
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="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">
|
<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) && (
|
{!(firstItem && firstItem.id === type.id) && (
|
||||||
<button
|
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!(lastItem && lastItem.id === type.id) && (
|
{!(lastItem && lastItem.id === type.id) && (
|
||||||
<button
|
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
<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 = ({
|
const Main = ({
|
||||||
status,
|
status,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -871,12 +881,16 @@ const Main = ({
|
|||||||
orgSlug={orgBranding?.slug}
|
orgSlug={orgBranding?.slug}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EventTypeList
|
{group.eventTypes.length ? (
|
||||||
types={group.eventTypes}
|
<EventTypeList
|
||||||
group={group}
|
types={group.eventTypes}
|
||||||
groupIndex={index}
|
group={group}
|
||||||
readOnly={group.metadata.readOnly}
|
groupIndex={index}
|
||||||
/>
|
readOnly={group.metadata.readOnly}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyEventTypeList group={group} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||||
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
|
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 { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
|
||||||
import { ssrInit } from "../server/lib/ssr";
|
import { ssrInit } from "../server/lib/ssr";
|
||||||
|
|
||||||
const signupSchema = z.object({
|
const signupSchema = apiSignupSchema.extend({
|
||||||
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(),
|
|
||||||
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
|
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 { t, i18n } = useLocale();
|
||||||
const flags = useFlagMap();
|
const flags = useFlagMap();
|
||||||
const methods = useForm<FormValues>({
|
const methods = useForm<FormValues>({
|
||||||
|
mode: "onChange",
|
||||||
resolver: zodResolver(signupSchema),
|
resolver: zodResolver(signupSchema),
|
||||||
defaultValues: prepopulateFormValues,
|
defaultValues: prepopulateFormValues,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -158,9 +158,10 @@ test.describe("pro user", () => {
|
|||||||
|
|
||||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
|
|
||||||
additionalGuests.forEach(async (email) => {
|
const promises = additionalGuests.map(async (email) => {
|
||||||
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
|
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
|
||||||
});
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Time slots should be reserved when selected", async ({ context, page }) => {
|
test("Time slots should be reserved when selected", async ({ context, page }) => {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ test.describe("Booking with Seats", () => {
|
|||||||
await page.waitForSelector('[data-testid="event-types"]');
|
await page.waitForSelector('[data-testid="event-types"]');
|
||||||
const eventTitle = "My 2-seated event";
|
const eventTitle = "My 2-seated event";
|
||||||
await createNewSeatedEventType(page, { eventTitle });
|
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 }) => {
|
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" });
|
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 test to logout requires both a succesfull login as logout, to prevent
|
||||||
// a doubling of tests failing on logout & logout, we can group them.
|
// a doubling of tests failing on logout & logout, we can group them.
|
||||||
test.describe("2FA Tests", async () => {
|
test.describe("2FA Tests", async () => {
|
||||||
@@ -45,6 +47,8 @@ test.describe("2FA Tests", async () => {
|
|||||||
secret: secret!,
|
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 page.waitForSelector(`[data-testid=two-factor-switch]`);
|
||||||
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
|
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
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
await fillOtp({ page, secret: secret! });
|
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();
|
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ test.describe("BOOKING_REJECTED", async () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
location: "[redacted/dynamic]",
|
location: "[redacted/dynamic]",
|
||||||
destinationCalendar: null,
|
destinationCalendar: [],
|
||||||
// hideCalendarNotes: false,
|
// hideCalendarNotes: false,
|
||||||
requiresConfirmation: "[redacted/dynamic]",
|
requiresConfirmation: "[redacted/dynamic]",
|
||||||
eventTypeId: "[redacted/dynamic]",
|
eventTypeId: "[redacted/dynamic]",
|
||||||
|
|||||||
@@ -225,6 +225,7 @@
|
|||||||
"create_account": "Konto erstellen",
|
"create_account": "Konto erstellen",
|
||||||
"confirm_password": "Passwort bestätigen",
|
"confirm_password": "Passwort bestätigen",
|
||||||
"confirm_auth_change": "Dies ändert die Art und Weise, wie Sie sich anmelden",
|
"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.",
|
"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.",
|
"email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.",
|
||||||
"create_your_account": "Erstellen Sie Ihr Konto",
|
"create_your_account": "Erstellen Sie Ihr Konto",
|
||||||
@@ -255,6 +256,7 @@
|
|||||||
"available_apps": "Verfügbare Apps",
|
"available_apps": "Verfügbare Apps",
|
||||||
"available_apps_lower_case": "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>",
|
"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.",
|
"check_email_reset_password": "Überprüfen Sie Ihre E-Mail. Wir haben Ihnen einen Link zum Zurücksetzen Ihres Passworts gesendet.",
|
||||||
"finish": "Fertig",
|
"finish": "Fertig",
|
||||||
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
|
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
|
||||||
@@ -527,7 +529,7 @@
|
|||||||
"location": "Ort",
|
"location": "Ort",
|
||||||
"address": "Adresse",
|
"address": "Adresse",
|
||||||
"enter_address": "Geben Sie eine Adresse ein",
|
"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",
|
"yes": "Ja",
|
||||||
"no": "Nein",
|
"no": "Nein",
|
||||||
"additional_notes": "Zusätzliche Notizen",
|
"additional_notes": "Zusätzliche Notizen",
|
||||||
@@ -537,7 +539,7 @@
|
|||||||
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
|
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
|
||||||
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
|
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
|
||||||
"in_person_meeting": "Vor-Ort-Termin",
|
"in_person_meeting": "Vor-Ort-Termin",
|
||||||
"in_person": "Persönlich (Organisator-Adresse)",
|
"in_person": "Vor Ort (Organisator-Adresse)",
|
||||||
"link_meeting": "Termin verknüpfen",
|
"link_meeting": "Termin verknüpfen",
|
||||||
"phone_number": "Telefonnummer",
|
"phone_number": "Telefonnummer",
|
||||||
"attendee_phone_number": "Telefonnummer",
|
"attendee_phone_number": "Telefonnummer",
|
||||||
@@ -559,6 +561,7 @@
|
|||||||
"leave": "Verlassen",
|
"leave": "Verlassen",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"my_team_url": "Meine Team-URL",
|
"my_team_url": "Meine Team-URL",
|
||||||
|
"my_teams": "Meine Teams",
|
||||||
"team_name": "Teamname",
|
"team_name": "Teamname",
|
||||||
"your_team_name": "Ihr Teamname",
|
"your_team_name": "Ihr Teamname",
|
||||||
"team_updated_successfully": "Team erfolgreich aktualisiert",
|
"team_updated_successfully": "Team erfolgreich aktualisiert",
|
||||||
@@ -1974,6 +1977,8 @@
|
|||||||
"org_team_names_example_5": "z.B. Data Analytics Team",
|
"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.",
|
"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?",
|
"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:",
|
"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",
|
"org_admin_other_teams": "Weitere Teams",
|
||||||
"no_other_teams_found": "Keine weiteren Teams gefunden",
|
"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.",
|
"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_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",
|
"set_availability": "Set your availability",
|
||||||
|
"availability_settings": "Availability Settings",
|
||||||
"continue_without_calendar": "Continue without calendar",
|
"continue_without_calendar": "Continue without calendar",
|
||||||
"connect_your_calendar": "Connect your calendar",
|
"connect_your_calendar": "Connect your calendar",
|
||||||
"connect_your_video_app": "Connect your video apps",
|
"connect_your_video_app": "Connect your video apps",
|
||||||
@@ -678,8 +679,8 @@
|
|||||||
"new_event_type_btn": "New event type",
|
"new_event_type_btn": "New event type",
|
||||||
"new_event_type_heading": "Create your first 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.",
|
"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_created_successfully": "Event type created successfully",
|
||||||
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
|
"event_type_updated_successfully": "Event type updated successfully",
|
||||||
"event_type_deleted_successfully": "Event type deleted successfully",
|
"event_type_deleted_successfully": "Event type deleted successfully",
|
||||||
"hours": "Hours",
|
"hours": "Hours",
|
||||||
"people": "People",
|
"people": "People",
|
||||||
@@ -2010,6 +2011,13 @@
|
|||||||
"member_removed": "Member removed",
|
"member_removed": "Member removed",
|
||||||
"my_availability": "My Availability",
|
"my_availability": "My Availability",
|
||||||
"team_availability": "Team 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",
|
"admin_org_notification_email_subject": "New organization created: pending action",
|
||||||
"hi_admin": "Hi Administrator",
|
"hi_admin": "Hi Administrator",
|
||||||
"admin_org_notification_email_title": "An organization requires DNS setup",
|
"admin_org_notification_email_title": "An organization requires DNS setup",
|
||||||
@@ -2025,6 +2033,8 @@
|
|||||||
"mark_dns_configured": "Mark as DNS configured",
|
"mark_dns_configured": "Mark as DNS configured",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"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.",
|
"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_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",
|
"set_availability": "Définissez vos disponibilités",
|
||||||
|
"availability_settings": "Paramètres de disponibilité",
|
||||||
"continue_without_calendar": "Continuer sans calendrier",
|
"continue_without_calendar": "Continuer sans calendrier",
|
||||||
"connect_your_calendar": "Connectez votre calendrier",
|
"connect_your_calendar": "Connectez votre calendrier",
|
||||||
"connect_your_video_app": "Connectez vos applications vidéo",
|
"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_btn": "Nouveau type d'événement",
|
||||||
"new_event_type_heading": "Créez votre premier 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.",
|
"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_created_successfully": "Type d'événement créé avec succès",
|
||||||
"event_type_updated_successfully": "Type d'événement {{eventTypeTitle}} mis à jour 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",
|
"event_type_deleted_successfully": "Type d'événement supprimé avec succès",
|
||||||
"hours": "heures",
|
"hours": "heures",
|
||||||
"people": "Personnes",
|
"people": "Personnes",
|
||||||
@@ -1988,6 +1989,8 @@
|
|||||||
"remove_users_from_org_confirm": "Voulez-vous vraiment supprimer {{userCount}} utilisateurs de cette organisation ?",
|
"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_has_no_schedules": "Cet utilisateur n'a pas encore configuré de planning",
|
||||||
"user_isnt_in_any_teams": "Cet utilisateur n'est dans aucune équipe",
|
"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.",
|
"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_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>",
|
"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.",
|
"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_first_name_variable": "Prénom du participant",
|
||||||
"attendee_last_name_variable": "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",
|
"me": "Moi",
|
||||||
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
||||||
"member_removed": "Membre supprimé",
|
"member_removed": "Membre supprimé",
|
||||||
"my_availability": "Mes disponibilités",
|
"my_availability": "Mes disponibilités",
|
||||||
"team_availability": "Disponibilités de l'équipe",
|
"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",
|
"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_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_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_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",
|
"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",
|
"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.",
|
"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é",
|
"unverified": "Non vérifié",
|
||||||
@@ -2018,5 +2033,8 @@
|
|||||||
"mark_dns_configured": "Marquer comme DNS configuré",
|
"mark_dns_configured": "Marquer comme DNS configuré",
|
||||||
"value": "Valeur",
|
"value": "Valeur",
|
||||||
"your_organization_updated_sucessfully": "Votre organisation a été mise à jour avec succès",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"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_user_filter": "用户:{{userName}}",
|
||||||
"insights_subtitle": "查看您活动的预约 insights",
|
"insights_subtitle": "查看您活动的预约 insights",
|
||||||
"custom_plan": "自定义计划",
|
"custom_plan": "自定义计划",
|
||||||
|
"include_calendar_event": "包括日历活动",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
@@ -1854,7 +1854,7 @@
|
|||||||
"payment_app_commission": "需付款 ({{paymentFeePercentage}}% + {{fee, currency}} 每筆交易佣金)",
|
"payment_app_commission": "需付款 ({{paymentFeePercentage}}% + {{fee, currency}} 每筆交易佣金)",
|
||||||
"email_invite_team": "已邀請 {{email}}",
|
"email_invite_team": "已邀請 {{email}}",
|
||||||
"email_invite_team_bulk": "已邀請 {{userCount}} 位使用者",
|
"email_invite_team_bulk": "已邀請 {{userCount}} 位使用者",
|
||||||
"error_collecting_card": "卡片發生錯誤",
|
"error_collecting_card": "收集卡片時發生錯誤",
|
||||||
"image_size_limit_exceed": "上傳的圖片大小不得超過 5MB 限制",
|
"image_size_limit_exceed": "上傳的圖片大小不得超過 5MB 限制",
|
||||||
"unauthorized_workflow_error_message": "{{errorCode}}:您未獲得授權,無法啟用或停用此工作流程",
|
"unauthorized_workflow_error_message": "{{errorCode}}:您未獲得授權,無法啟用或停用此工作流程",
|
||||||
"inline_embed": "內嵌式嵌入",
|
"inline_embed": "內嵌式嵌入",
|
||||||
|
|||||||
@@ -58,10 +58,10 @@
|
|||||||
--cal-bg-inverted: #f3f4f6;
|
--cal-bg-inverted: #f3f4f6;
|
||||||
|
|
||||||
/* background -> components*/
|
/* background -> components*/
|
||||||
--cal-bg-info: #dee9fc;
|
--cal-bg-info: #263fa9;
|
||||||
--cal-bg-success: #e2fbe8;
|
--cal-bg-success: #306339;
|
||||||
--cal-bg-attention: #fceed8;
|
--cal-bg-attention: #8e3b1f;
|
||||||
--cal-bg-error: #f9e3e2;
|
--cal-bg-error: #8c2822;
|
||||||
--cal-bg-dark-error: #752522;
|
--cal-bg-dark-error: #752522;
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
@@ -80,10 +80,10 @@
|
|||||||
--cal-text-inverted: #101010;
|
--cal-text-inverted: #101010;
|
||||||
|
|
||||||
/* Content/Text -> components */
|
/* Content/Text -> components */
|
||||||
--cal-text-info: #253985;
|
--cal-text-info: #dee9fc;
|
||||||
--cal-text-success: #285231;
|
--cal-text-success: #e2fbe8;
|
||||||
--cal-text-attention: #73321b;
|
--cal-text-attention: #fceed8;
|
||||||
--cal-text-error: #752522;
|
--cal-text-error: #f9e3e2;
|
||||||
|
|
||||||
/* Brand shenanigans
|
/* Brand shenanigans
|
||||||
-> These will be computed for the users theme at runtime.
|
-> 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",
|
"db-studio": "yarn prisma studio",
|
||||||
"deploy": "turbo run deploy",
|
"deploy": "turbo run deploy",
|
||||||
"dev:all": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"",
|
"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": "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: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\"",
|
"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;
|
export type SetAppData = (key: string, value: unknown) => void;
|
||||||
type LockedIcon = JSX.Element | false | undefined;
|
type LockedIcon = JSX.Element | false | undefined;
|
||||||
type Disabled = boolean | 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>,
|
TKey extends keyof z.infer<TAppData>,
|
||||||
TValue extends z.infer<TAppData>[TKey]
|
TValue extends z.infer<TAppData>[TKey]
|
||||||
>(
|
>(
|
||||||
@@ -21,7 +27,7 @@ export type SetAppDataGeneric<TAppData extends ZodType> = <
|
|||||||
value: TValue
|
value: TValue
|
||||||
) => void;
|
) => 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
|
key: TKey
|
||||||
) => z.infer<TAppData>[TKey];
|
) => z.infer<TAppData>[TKey];
|
||||||
|
|
||||||
@@ -29,7 +35,12 @@ export const useAppContextWithSchema = <TAppData extends ZodType>() => {
|
|||||||
type GetAppData = GetAppDataGeneric<TAppData>;
|
type GetAppData = GetAppDataGeneric<TAppData>;
|
||||||
type SetAppData = SetAppDataGeneric<TAppData>;
|
type SetAppData = SetAppDataGeneric<TAppData>;
|
||||||
// TODO: Not able to do it without type assertion here
|
// 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;
|
return context;
|
||||||
};
|
};
|
||||||
export default EventTypeAppContext;
|
export default EventTypeAppContext;
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { Switch, Badge, Avatar } from "@calcom/ui";
|
import { Switch, Badge, Avatar } from "@calcom/ui";
|
||||||
|
|
||||||
import type { SetAppDataGeneric } from "../EventTypeAppContext";
|
|
||||||
import type { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
|
||||||
import type { CredentialOwner } from "../types";
|
import type { CredentialOwner } from "../types";
|
||||||
import OmniInstallAppButton from "./OmniInstallAppButton";
|
import OmniInstallAppButton from "./OmniInstallAppButton";
|
||||||
|
|
||||||
@@ -16,24 +15,20 @@ export default function AppCard({
|
|||||||
switchOnClick,
|
switchOnClick,
|
||||||
switchChecked,
|
switchChecked,
|
||||||
children,
|
children,
|
||||||
setAppData,
|
|
||||||
returnTo,
|
returnTo,
|
||||||
teamId,
|
teamId,
|
||||||
disableSwitch,
|
|
||||||
LockedIcon,
|
|
||||||
}: {
|
}: {
|
||||||
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
|
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
|
||||||
description?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
switchChecked?: boolean;
|
switchChecked?: boolean;
|
||||||
switchOnClick?: (e: boolean) => void;
|
switchOnClick?: (e: boolean) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
setAppData: SetAppDataGeneric<typeof eventTypeAppCardZod>;
|
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
disableSwitch?: boolean;
|
|
||||||
LockedIcon?: React.ReactNode;
|
LockedIcon?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
|
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -92,7 +87,7 @@ export default function AppCard({
|
|||||||
{app?.isInstalled || app.credentialOwner ? (
|
{app?.isInstalled || app.credentialOwner ? (
|
||||||
<div className="ml-auto flex items-center">
|
<div className="ml-auto flex items-center">
|
||||||
<Switch
|
<Switch
|
||||||
disabled={!app.enabled || disableSwitch}
|
disabled={!app.enabled || disabled}
|
||||||
onCheckedChange={(enabled) => {
|
onCheckedChange={(enabled) => {
|
||||||
if (switchOnClick) {
|
if (switchOnClick) {
|
||||||
switchOnClick(enabled);
|
switchOnClick(enabled);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const EventTypeAppCard = (props: {
|
|||||||
const { app, getAppData, setAppData, LockedIcon, disabled } = props;
|
const { app, getAppData, setAppData, LockedIcon, disabled } = props;
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
|
<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
|
<DynamicComponent
|
||||||
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
|
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
|
||||||
componentMap={EventTypeAddonMap}
|
componentMap={EventTypeAddonMap}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
|||||||
import type { EventTypeAppCardApp } from "../types";
|
import type { EventTypeAppCardApp } from "../types";
|
||||||
|
|
||||||
function useIsAppEnabled(app: EventTypeAppCardApp) {
|
function useIsAppEnabled(app: EventTypeAppCardApp) {
|
||||||
const [getAppData, setAppData] = useAppContextWithSchema();
|
const { getAppData, setAppData } = useAppContextWithSchema();
|
||||||
const [enabled, setEnabled] = useState(() => {
|
const [enabled, setEnabled] = useState(() => {
|
||||||
if (!app.credentialOwner) {
|
if (!app.credentialOwner) {
|
||||||
return getAppData("enabled");
|
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 { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
|
||||||
import around_config_json from "./around/config.json";
|
import around_config_json from "./around/config.json";
|
||||||
import basecamp3_config_json from "./basecamp3/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 { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
|
||||||
import campfire_config_json from "./campfire/config.json";
|
import campfire_config_json from "./campfire/config.json";
|
||||||
import closecom_config_json from "./closecom/config.json";
|
import closecom_config_json from "./closecom/config.json";
|
||||||
@@ -75,6 +76,7 @@ export const appStoreMetadata = {
|
|||||||
applecalendar: applecalendar__metadata_ts,
|
applecalendar: applecalendar__metadata_ts,
|
||||||
around: around_config_json,
|
around: around_config_json,
|
||||||
basecamp3: basecamp3_config_json,
|
basecamp3: basecamp3_config_json,
|
||||||
|
"cal-ai": cal_ai_config_json,
|
||||||
caldavcalendar: caldavcalendar__metadata_ts,
|
caldavcalendar: caldavcalendar__metadata_ts,
|
||||||
campfire: campfire_config_json,
|
campfire: campfire_config_json,
|
||||||
closecom: closecom_config_json,
|
closecom: closecom_config_json,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const apiHandlers = {
|
|||||||
applecalendar: import("./applecalendar/api"),
|
applecalendar: import("./applecalendar/api"),
|
||||||
around: import("./around/api"),
|
around: import("./around/api"),
|
||||||
basecamp3: import("./basecamp3/api"),
|
basecamp3: import("./basecamp3/api"),
|
||||||
|
"cal-ai": import("./cal-ai/api"),
|
||||||
caldavcalendar: import("./caldavcalendar/api"),
|
caldavcalendar: import("./caldavcalendar/api"),
|
||||||
campfire: import("./campfire/api"),
|
campfire: import("./campfire/api"),
|
||||||
closecom: import("./closecom/api"),
|
closecom: import("./closecom/api"),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Select } from "@calcom/ui";
|
|||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
|
||||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
||||||
const [projects, setProjects] = useState();
|
const [projects, setProjects] = useState();
|
||||||
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
|
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
|
||||||
@@ -32,7 +32,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
if (!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";
|
||||||
Whitespace-only changes.
@@ -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";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
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 trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
|||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
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 trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
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 thankYouPage = getAppData("thankYouPage");
|
||||||
const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app);
|
const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app);
|
||||||
|
|
||||||
@@ -16,11 +16,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
description={t("confirmation_page_gif")}
|
description={t("confirmation_page_gif")}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
setShowGifSelection(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 }) => ({
|
const eventAttendees = calEventRaw.attendees.map(({ id: _id, ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
responseStatus: "accepted",
|
responseStatus: "accepted",
|
||||||
@@ -97,6 +97,10 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
responseStatus: "accepted",
|
responseStatus: "accepted",
|
||||||
})) || [];
|
})) || [];
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const [mainHostDestinationCalendar] =
|
||||||
|
calEventRaw?.destinationCalendar && calEventRaw?.destinationCalendar.length > 0
|
||||||
|
? calEventRaw.destinationCalendar
|
||||||
|
: [];
|
||||||
const myGoogleAuth = await this.auth.getToken();
|
const myGoogleAuth = await this.auth.getToken();
|
||||||
const payload: calendar_v3.Schema$Event = {
|
const payload: calendar_v3.Schema$Event = {
|
||||||
summary: calEventRaw.title,
|
summary: calEventRaw.title,
|
||||||
@@ -115,8 +119,8 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
id: String(calEventRaw.organizer.id),
|
id: String(calEventRaw.organizer.id),
|
||||||
responseStatus: "accepted",
|
responseStatus: "accepted",
|
||||||
organizer: true,
|
organizer: true,
|
||||||
email: calEventRaw.destinationCalendar?.externalId
|
email: mainHostDestinationCalendar?.externalId
|
||||||
? calEventRaw.destinationCalendar.externalId
|
? mainHostDestinationCalendar.externalId
|
||||||
: calEventRaw.organizer.email,
|
: calEventRaw.organizer.email,
|
||||||
},
|
},
|
||||||
...eventAttendees,
|
...eventAttendees,
|
||||||
@@ -138,13 +142,16 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
const calendar = google.calendar({
|
const calendar = google.calendar({
|
||||||
version: "v3",
|
version: "v3",
|
||||||
});
|
});
|
||||||
const selectedCalendar = calEventRaw.destinationCalendar?.externalId
|
// Find in calEventRaw.destinationCalendar the one with the same credentialId
|
||||||
? calEventRaw.destinationCalendar.externalId
|
|
||||||
: "primary";
|
const selectedCalendar = calEventRaw.destinationCalendar?.find(
|
||||||
|
(cal) => cal.credentialId === credentialId
|
||||||
|
)?.externalId;
|
||||||
|
|
||||||
calendar.events.insert(
|
calendar.events.insert(
|
||||||
{
|
{
|
||||||
auth: myGoogleAuth,
|
auth: myGoogleAuth,
|
||||||
calendarId: selectedCalendar,
|
calendarId: selectedCalendar || "primary",
|
||||||
requestBody: payload,
|
requestBody: payload,
|
||||||
conferenceDataVersion: 1,
|
conferenceDataVersion: 1,
|
||||||
sendUpdates: "none",
|
sendUpdates: "none",
|
||||||
@@ -188,6 +195,8 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
|
|
||||||
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
|
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const [mainHostDestinationCalendar] =
|
||||||
|
event?.destinationCalendar && event?.destinationCalendar.length > 0 ? event.destinationCalendar : [];
|
||||||
const myGoogleAuth = await this.auth.getToken();
|
const myGoogleAuth = await this.auth.getToken();
|
||||||
const eventAttendees = event.attendees.map(({ ...rest }) => ({
|
const eventAttendees = event.attendees.map(({ ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
@@ -216,8 +225,8 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
id: String(event.organizer.id),
|
id: String(event.organizer.id),
|
||||||
organizer: true,
|
organizer: true,
|
||||||
responseStatus: "accepted",
|
responseStatus: "accepted",
|
||||||
email: event.destinationCalendar?.externalId
|
email: mainHostDestinationCalendar?.externalId
|
||||||
? event.destinationCalendar.externalId
|
? mainHostDestinationCalendar.externalId
|
||||||
: event.organizer.email,
|
: event.organizer.email,
|
||||||
},
|
},
|
||||||
...(eventAttendees as any),
|
...(eventAttendees as any),
|
||||||
@@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
|
|
||||||
const selectedCalendar = externalCalendarId
|
const selectedCalendar = externalCalendarId
|
||||||
? externalCalendarId
|
? externalCalendarId
|
||||||
: event.destinationCalendar?.externalId;
|
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
|
||||||
|
|
||||||
calendar.events.update(
|
calendar.events.update(
|
||||||
{
|
{
|
||||||
@@ -303,7 +312,9 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const defaultCalendarId = "primary";
|
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(
|
calendar.events.delete(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
|||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
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 trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ export default class LarkCalendarService implements Calendar {
|
|||||||
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
||||||
let eventId = "";
|
let eventId = "";
|
||||||
let eventRespData;
|
let eventRespData;
|
||||||
const calendarId = event.destinationCalendar?.externalId;
|
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||||
|
const calendarId = mainHostDestinationCalendar?.externalId;
|
||||||
if (!calendarId) {
|
if (!calendarId) {
|
||||||
throw new Error("no calendar id");
|
throw new Error("no calendar id");
|
||||||
}
|
}
|
||||||
@@ -160,7 +161,8 @@ export default class LarkCalendarService implements Calendar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createAttendees = async (event: CalendarEvent, eventId: string) => {
|
private createAttendees = async (event: CalendarEvent, eventId: string) => {
|
||||||
const calendarId = event.destinationCalendar?.externalId;
|
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||||
|
const calendarId = mainHostDestinationCalendar?.externalId;
|
||||||
if (!calendarId) {
|
if (!calendarId) {
|
||||||
this.log.error("no calendar id provided in createAttendees");
|
this.log.error("no calendar id provided in createAttendees");
|
||||||
throw new 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) {
|
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
|
||||||
const eventId = uid;
|
const eventId = uid;
|
||||||
let eventRespData;
|
let eventRespData;
|
||||||
const calendarId = externalCalendarId || event.destinationCalendar?.externalId;
|
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||||
|
const calendarId = externalCalendarId || mainHostDestinationCalendar?.externalId;
|
||||||
if (!calendarId) {
|
if (!calendarId) {
|
||||||
this.log.error("no calendar id provided in updateEvent");
|
this.log.error("no calendar id provided in updateEvent");
|
||||||
throw new 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
|
* @returns
|
||||||
*/
|
*/
|
||||||
async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
|
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) {
|
if (!calendarId) {
|
||||||
this.log.error("no calendar id provided in deleteEvent");
|
this.log.error("no calendar id provided in deleteEvent");
|
||||||
throw new 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";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
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 trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={updateEnabled}
|
switchOnClick={updateEnabled}
|
||||||
switchChecked={enabled}
|
switchChecked={enabled}
|
||||||
teamId={eventType.team?.id || undefined}>
|
teamId={eventType.team?.id || undefined}>
|
||||||
|
|||||||
@@ -70,9 +70,10 @@ export default class Office365CalendarService implements Calendar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
||||||
|
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||||
try {
|
try {
|
||||||
const eventsUrl = event.destinationCalendar?.externalId
|
const eventsUrl = mainHostDestinationCalendar?.externalId
|
||||||
? `/me/calendars/${event.destinationCalendar?.externalId}/events`
|
? `/me/calendars/${mainHostDestinationCalendar?.externalId}/events`
|
||||||
: "/me/calendar/events";
|
: "/me/calendar/events";
|
||||||
|
|
||||||
const response = await this.fetcher(eventsUrl, {
|
const response = await this.fetcher(eventsUrl, {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type Option = { value: string; label: string };
|
|||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const { asPath } = useRouter();
|
const { asPath } = useRouter();
|
||||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const price = getAppData("price");
|
const price = getAppData("price");
|
||||||
const currency = getAppData("currency");
|
const currency = getAppData("currency");
|
||||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||||
@@ -38,7 +38,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
returnTo={WEBAPP_URL + asPath}
|
returnTo={WEBAPP_URL + asPath}
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
switchChecked={requirePayment}
|
switchChecked={requirePayment}
|
||||||
switchOnClick={(enabled) => {
|
switchOnClick={(enabled) => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Paypal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
|
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
|
||||||
this.getAccessToken();
|
await this.getAccessToken();
|
||||||
return fetch(`${this.url}${endpoint}`, {
|
return fetch(`${this.url}${endpoint}`, {
|
||||||
method: "get",
|
method: "get",
|
||||||
...init,
|
...init,
|
||||||
|
|||||||
@@ -7,17 +7,14 @@ import { TextField } from "@calcom/ui";
|
|||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
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 plausibleUrl = getAppData("PLAUSIBLE_URL");
|
||||||
const trackingId = getAppData("trackingId");
|
const trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { appDataSchema } from "../zod";
|
|||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [_getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const [additionalParameters, setAdditionalParameters] = useState("");
|
const [additionalParameters, setAdditionalParameters] = useState("");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
@@ -37,10 +37,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(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 ",
|
"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": {
|
"dependencies": {
|
||||||
"@calcom/lib": "*",
|
"@calcom/lib": "*",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.3.1",
|
||||||
"json-logic-js": "^2.0.2",
|
"json-logic-js": "^2.0.2",
|
||||||
"react-awesome-query-builder": "^5.1.2"
|
"react-awesome-query-builder": "^5.1.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// TODO: i18n
|
// TODO: i18n
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
List,
|
List,
|
||||||
ListLinkItem,
|
ListLinkItem,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
ArrowButton,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -83,6 +85,20 @@ export default function RoutingForms({
|
|||||||
const { hasPaidPlan } = useHasPaidPlan();
|
const { hasPaidPlan } = useHasPaidPlan();
|
||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
const hookForm = useFormContext<RoutingFormWithResponseCount>();
|
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(() => {
|
useEffect(() => {
|
||||||
hookForm.reset({});
|
hookForm.reset({});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 (
|
return (
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
<ShellMain
|
<ShellMain
|
||||||
@@ -177,8 +216,8 @@ export default function RoutingForms({
|
|||||||
}
|
}
|
||||||
SkeletonLoader={SkeletonLoaderTeamList}>
|
SkeletonLoader={SkeletonLoaderTeamList}>
|
||||||
<div className="bg-default mb-16 overflow-hidden">
|
<div className="bg-default mb-16 overflow-hidden">
|
||||||
<List data-testid="routing-forms-list">
|
<List data-testid="routing-forms-list" ref={parent}>
|
||||||
{forms?.map(({ form, readOnly }) => {
|
{forms?.map(({ form, readOnly }, index) => {
|
||||||
if (!form) {
|
if (!form) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -187,116 +226,129 @@ export default function RoutingForms({
|
|||||||
form.routes = form.routes || [];
|
form.routes = form.routes || [];
|
||||||
const fields = form.fields || [];
|
const fields = form.fields || [];
|
||||||
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
|
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
|
||||||
|
const firstItem = forms[0].form;
|
||||||
|
const lastItem = forms[forms.length - 1].form;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListLinkItem
|
<div
|
||||||
key={form.id}
|
className="group flex w-full max-w-full items-center justify-between overflow-hidden"
|
||||||
href={appUrl + "/form-edit/" + form.id}
|
key={form.id}>
|
||||||
heading={form.name}
|
{!(firstItem && firstItem.id === form.id) && (
|
||||||
disabled={readOnly}
|
<ArrowButton onClick={() => moveRoutingForm(index, -1)} arrowDirection="up" />
|
||||||
subHeading={description}
|
)}
|
||||||
className="space-x-2 rtl:space-x-reverse"
|
|
||||||
actions={
|
{!(lastItem && lastItem.id === form.id) && (
|
||||||
<>
|
<ArrowButton onClick={() => moveRoutingForm(index, 1)} arrowDirection="down" />
|
||||||
{form.team?.name && (
|
)}
|
||||||
<div className="border-r-2 border-neutral-300">
|
<ListLinkItem
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
href={appUrl + "/form-edit/" + form.id}
|
||||||
{form.team.name}
|
heading={form.name}
|
||||||
</Badge>
|
disabled={readOnly}
|
||||||
</div>
|
subHeading={description}
|
||||||
)}
|
className="space-x-2 rtl:space-x-reverse"
|
||||||
<FormAction
|
actions={
|
||||||
disabled={readOnly}
|
<>
|
||||||
className="self-center"
|
{form.team?.name && (
|
||||||
action="toggle"
|
<div className="border-r-2 border-neutral-300">
|
||||||
routingForm={form}
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||||
/>
|
{form.team.name}
|
||||||
<ButtonGroup combined>
|
</Badge>
|
||||||
<Tooltip content={t("preview")}>
|
</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
|
<FormAction
|
||||||
action="preview"
|
|
||||||
routingForm={form}
|
routingForm={form}
|
||||||
target="_blank"
|
action="copyLink"
|
||||||
StartIcon={ExternalLink}
|
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="icon"
|
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
|
<FormAction
|
||||||
action="edit"
|
|
||||||
routingForm={form}
|
routingForm={form}
|
||||||
color="minimal"
|
action="embed"
|
||||||
className="!flex"
|
color="secondary"
|
||||||
StartIcon={Edit}>
|
variant="icon"
|
||||||
{t("edit")}
|
StartIcon={Code}
|
||||||
</FormAction>
|
tooltip={t("embed")}
|
||||||
<FormAction
|
/>
|
||||||
action="download"
|
<FormActionsDropdown disabled={readOnly}>
|
||||||
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
|
<FormAction
|
||||||
data-testid="copy-redirect-url"
|
action="edit"
|
||||||
routingForm={form}
|
routingForm={form}
|
||||||
action="copyRedirectUrl"
|
|
||||||
color="minimal"
|
color="minimal"
|
||||||
type="button"
|
className="!flex"
|
||||||
StartIcon={LinkIcon}>
|
StartIcon={Edit}>
|
||||||
{t("Copy Typeform Redirect Url")}
|
{t("edit")}
|
||||||
</FormAction>
|
</FormAction>
|
||||||
) : null}
|
<FormAction
|
||||||
<FormAction
|
action="download"
|
||||||
action="_delete"
|
routingForm={form}
|
||||||
routingForm={form}
|
color="minimal"
|
||||||
color="destructive"
|
StartIcon={Download}>
|
||||||
className="w-full"
|
{t("download_responses")}
|
||||||
StartIcon={Trash}>
|
</FormAction>
|
||||||
{t("delete")}
|
<FormAction
|
||||||
</FormAction>
|
action="duplicate"
|
||||||
</FormActionsDropdown>
|
routingForm={form}
|
||||||
</ButtonGroup>
|
color="minimal"
|
||||||
</>
|
className="w-full"
|
||||||
}>
|
StartIcon={Copy}>
|
||||||
<div className="flex flex-wrap gap-1">
|
{t("duplicate")}
|
||||||
<Badge variant="gray" startIcon={Menu}>
|
</FormAction>
|
||||||
{fields.length} {fields.length === 1 ? "field" : "fields"}
|
{typeformApp?.isInstalled ? (
|
||||||
</Badge>
|
<FormAction
|
||||||
<Badge variant="gray" startIcon={GitMerge}>
|
data-testid="copy-redirect-url"
|
||||||
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
|
routingForm={form}
|
||||||
</Badge>
|
action="copyRedirectUrl"
|
||||||
<Badge variant="gray" startIcon={MessageCircle}>
|
color="minimal"
|
||||||
{form._count.responses}{" "}
|
type="button"
|
||||||
{form._count.responses === 1 ? "response" : "responses"}
|
StartIcon={LinkIcon}>
|
||||||
</Badge>
|
{t("Copy Typeform Redirect Url")}
|
||||||
</div>
|
</FormAction>
|
||||||
</ListLinkItem>
|
) : 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>
|
</List>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ test.describe("Routing Forms", () => {
|
|||||||
|
|
||||||
await page.waitForSelector('[data-testid="routing-forms-list"]');
|
await page.waitForSelector('[data-testid="routing-forms-list"]');
|
||||||
// Ensure that it's visible in 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 gotoRoutingLink({ page, formId });
|
||||||
await expect(page.locator("text=Test Form Name")).toBeVisible();
|
await expect(page.locator("text=Test Form Name")).toBeVisible();
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
|||||||
fields: true,
|
fields: true,
|
||||||
settings: true,
|
settings: true,
|
||||||
teamId: true,
|
teamId: true,
|
||||||
|
position: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
|
|||||||
|
|
||||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: {
|
orderBy: [
|
||||||
createdAt: "desc",
|
{
|
||||||
},
|
position: "desc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
|
],
|
||||||
include: {
|
include: {
|
||||||
team: {
|
team: {
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type Option = { value: string; label: string };
|
|||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const price = getAppData("price");
|
const price = getAppData("price");
|
||||||
const currency = getAppData("currency");
|
const currency = getAppData("currency");
|
||||||
const paymentOption = getAppData("paymentOption");
|
const paymentOption = getAppData("paymentOption");
|
||||||
@@ -37,15 +37,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||||||
.trim();
|
.trim();
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
returnTo={WEBAPP_URL + pathname}
|
returnTo={WEBAPP_URL + pathname + "?tabName=apps"}
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchChecked={requirePayment}
|
switchChecked={requirePayment}
|
||||||
switchOnClick={(enabled) => {
|
switchOnClick={(enabled) => {
|
||||||
setRequirePayment(enabled);
|
setRequirePayment(enabled);
|
||||||
}}>
|
}}
|
||||||
|
teamId={eventType.team?.id || undefined}>
|
||||||
<>
|
<>
|
||||||
{recurringEventDefined && (
|
{recurringEventDefined && (
|
||||||
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
||||||
|
|||||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user