Merge branch 'main' into teste2e-multiSelectQuestion

teste2e-multiSelectQuestion
GitStart-Cal.com 2023-10-19 23:21:25 +00:00 committed by GitHub
commit 2a378bc047
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
327 changed files with 11146 additions and 3389 deletions

View File

@ -0,0 +1,18 @@
name: Add comment
on:
issues:
types:
- labeled
jobs:
add-comment:
if: github.event.label.name == '🚨 needs approval'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Add comment
uses: peter-evans/create-or-update-comment@5f728c3dae25f329afbe34ee4d08eef25569d79f
with:
issue-number: ${{ github.event.issue.number }}
body: |
This feature request has not been reviewed yet by the Product Team and needs approval beforehand. Once approved, this issue is available for anyone to work on. **Make sure to reference this issue in your pull request.** :sparkles: Thank you for your contribution! :sparkles:

View File

@ -13,4 +13,4 @@ jobs:
with:
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
organization-name: calcom
ignore-labels: "app-store, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"

View File

@ -161,3 +161,48 @@ If you get errors, be sure to fix them before committing.
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Be sure to fill the PR Template accordingly.
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
## Guidelines for committing yarn lockfile
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
```bash
git checkout HEAD~1 yarn.lock
git commit -m "Revert yarn.lock changes"
```
If you've pushed the commit with the `yarn.lock`:
1. Correct the commit locally using the above method.
2. Carefully force push:
```bash
git push origin <your-branch-name> --force
```
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
1. **Checkout a Previous Version**:
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
```bash
git log yarn.lock
```
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
```bash
git checkout <commit_hash> yarn.lock
```
2. **Commit the Reverted Version**:
- After checking out the previous version of the `yarn.lock`, commit this change:
```bash
git commit -m "Revert yarn.lock to its state before unintended changes"
```
3. **Proceed with Caution**:
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
```bash
git pull origin <your-branch-name>
```
- Then push the updated branch:
```bash
git push origin <your-branch-name>
```

View File

@ -253,6 +253,8 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
#### Setting up your first user
##### Approach 1
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
```sh
@ -264,6 +266,17 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file.
1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
##### Approach 2
Seed the local db by running
```sh
cd packages/prisma
yarn db-seed
```
The above command will populate the local db with dummy users.
### E2E-Testing
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
@ -368,6 +381,10 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/calcom/docker)
### Elestio
[![Deploy on Elestio](https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png)](https://elest.io/open-source/cal.com)
<!-- ROADMAP -->
## Roadmap

View File

@ -6,6 +6,9 @@ FRONTEND_URL=http://localhost:3000
APP_ID=cal-ai
APP_URL=http://localhost:3000/apps/cal-ai
# This is for the onboard route. Which domain should we send emails from?
SENDER_DOMAIN=cal.ai
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
PARSE_KEY=

View File

@ -1,12 +1,12 @@
# Cal.com Email Assistant
# Cal.ai
Welcome to the first stage of Cal.ai!
Welcome to [Cal.ai](https://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?"
- List and rearrange your bookings eg. "clear my afternoon"
- 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.
@ -14,7 +14,11 @@ _The AI agent can only choose from a set of tools, without ever seeing your API
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.
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
## Recognition
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
## Getting Started
@ -22,27 +26,39 @@ Incoming emails are routed by email address. Addresses are verified by [DKIM rec
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:
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. Run `cp .env.example .env` in this folder to get started. 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))
- A default sender email (for example, `me@dev.example.com`)
- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
- A unique value for `PARSE_KEY` with `openssl rand -hex 32`
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
### Agent Architecture
The scheduling agent in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts) calls an LLM (in this case, GPT-4) in a loop to accomplish a multi-step task. We use an [OpenAI Functions agent](https://js.langchain.com/docs/modules/agents/agent_types/openai_functions_agent), which is fine-tuned to output text suited for passing to tools.
Tools (eg. [`createBooking`](/apps/ai/src/tools/createBooking.ts)) are simply JavaScript methods wrapped by Zod schemas, telling the agent what format to output.
Here is the full architecture:
![Cal.ai architecture](/apps/ai/src/public/architecture.png)
### 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 expose the AI app, run `ngrok http 3005` (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).
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](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.
1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`, both with priority `10` if prompted.
4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
5. In the Destination URL field, use the nGrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
6. Activate "POST the raw, full MIME message".
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the nGrok listener and server.
8. 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.
Please feel free to improve any part of this architecture!

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/ai",
"version": "1.1.1",
"version": "1.2.1",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
@ -8,7 +8,7 @@
"@t3-oss/env-nextjs": "^0.6.1",
"langchain": "^0.0.131",
"mailparser": "^3.6.5",
"next": "^13.4.6",
"next": "^13.5.4",
"supports-color": "8.1.1",
"zod": "^3.22.2"
},

View File

@ -5,6 +5,9 @@ import agent from "../../../utils/agent";
import sendEmail from "../../../utils/sendEmail";
import { verifyParseKey } from "../../../utils/verifyParseKey";
// Allow agent loop to run for up to 5 minutes
export const maxDuration = 300;
/**
* Launches a LangChain agent to process an incoming email,
* then sends the response to the user.
@ -37,6 +40,13 @@ export const POST = async (request: NextRequest) => {
return new NextResponse("ok");
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
to: user.email,
from: agentEmail,
});
return new NextResponse(
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
{ status: 500 }

View File

@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
import sendEmail from "../../../utils/sendEmail";
export const POST = async (request: NextRequest) => {
const { userId } = await request.json();
const user = await prisma.user.findUnique({
select: {
email: true,
name: true,
username: true,
},
where: {
id: userId,
},
});
if (!user) {
return new Response("User not found", { status: 404 });
}
await sendEmail({
subject: "Welcome to Cal AI",
to: user.email,
from: `${user.username}@${env.SENDER_DOMAIN}`,
text: `Hi ${
user.name || `@${user.username}`
},\n\nI'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.\n\nHere are some things you can ask me:\n\n- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)\n- "What meetings do I have today?" (I'll show you your schedule)\n- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)\n\nI'm still learning, so if you have any feedback, please tweet it to @calcom!\n\nRemember, you can always reach me here, at ${
user.username
}@${
env.SENDER_DOMAIN
}.\n\nLooking forward to working together (:\n\n- Cal AI, Your personal booking assistant`,
html: `Hi ${
user.name || `@${user.username}`
},<br><br>I'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.<br><br>Here are some things you can ask me:<br><br>- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)<br>- "What meetings do I have today?" (I'll show you your schedule)<br>- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)<br><br>I'm still learning, so if you have any feedback, please send it to <a href="https://twitter.com/calcom">@calcom</a> on X!<br><br>Remember, you can always reach me here, at ${
user.username
}@${env.SENDER_DOMAIN}.<br><br>Looking forward to working together (:<br><br>- Cal AI`,
});
return new Response("OK", { status: 200 });
};

View File

@ -3,6 +3,7 @@ import { simpleParser } from "mailparser";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
@ -14,6 +15,10 @@ import now from "../../../utils/now";
import sendEmail from "../../../utils/sendEmail";
import { verifyParseKey } from "../../../utils/verifyParseKey";
// Allow receive loop to run for up to 30 seconds
// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete.
export const maxDuration = 30;
/**
* Verifies email signature and app authorization,
* then hands off to booking agent.
@ -27,18 +32,37 @@ export const POST = async (request: NextRequest) => {
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];
const subject = body.subject || "";
try {
await checkRateLimitAndThrowError({
identifier: `ai:email:${envelope.from}`,
rateLimitingType: "ai",
});
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
}
// Parse email from mixed MIME type
const parsed: ParsedMail = await simpleParser(body.email as Source);
if (!parsed.text && !parsed.subject) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Email missing text and subject", { status: 400 });
}
@ -55,14 +79,17 @@ export const POST = async (request: NextRequest) => {
},
},
},
where: { email: envelope.from, credentials: { some: { appId: env.APP_ID } } },
where: { email: envelope.from },
});
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
to: envelope.from,
from: aiEmail,
@ -79,7 +106,7 @@ export const POST = async (request: NextRequest) => {
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}`,
subject: `Re: ${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,
@ -106,7 +133,7 @@ export const POST = async (request: NextRequest) => {
if ("error" in availability) {
await sendEmail({
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your availability. Please try again.",
to: user.email,
from: aiEmail,
@ -117,7 +144,7 @@ export const POST = async (request: NextRequest) => {
if ("error" in eventTypes) {
await sendEmail({
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your event types. Please try again.",
to: user.email,
from: aiEmail,
@ -135,8 +162,8 @@ export const POST = async (request: NextRequest) => {
body: JSON.stringify({
apiKey,
userId: user.id,
message: parsed.text,
subject: parsed.subject,
message: parsed.text || "",
subject: parsed.subject || "",
replyTo: aiEmail,
user: {
email: user.email,

View File

@ -20,6 +20,7 @@ export const env = createEnv({
FRONTEND_URL: process.env.FRONTEND_URL,
APP_ID: process.env.APP_ID,
APP_URL: process.env.APP_URL,
SENDER_DOMAIN: process.env.SENDER_DOMAIN,
PARSE_KEY: process.env.PARSE_KEY,
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
@ -36,6 +37,7 @@ export const env = createEnv({
FRONTEND_URL: z.string().url(),
APP_ID: z.string().min(1),
APP_URL: z.string().url(),
SENDER_DOMAIN: z.string().min(1),
PARSE_KEY: z.string().min(1),
NODE_ENV: z.enum(["development", "test", "production"]),
OPENAI_API_KEY: z.string().min(1),

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -47,7 +47,7 @@ const createBooking = async ({
}
const responses = {
id: invite,
id: invite.toString(),
name: user.username,
email: user.email,
};

View File

@ -0,0 +1,124 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "~/src/env.mjs";
import type { User, UserList } from "~/src/types/user";
import sendEmail from "~/src/utils/sendEmail";
export const sendBookingEmail = async ({
user,
agentEmail,
subject,
to,
message,
eventTypeSlug,
slots,
date,
}: {
apiKey: string;
user: User;
users: UserList;
agentEmail: string;
subject: string;
to: string;
message: string;
eventTypeSlug: string;
slots?: {
time: string;
text: string;
}[];
date: {
date: string;
text: string;
};
}) => {
// const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
const timeUrls = slots?.map(({ time, text }) => {
return {
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?slot=${time}`,
text,
};
});
const dateUrl = {
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date.date}`,
text: date.text,
};
await sendEmail({
subject,
to,
cc: user.email,
from: agentEmail,
text: message
.split("[[[Slots]]]")
.join(timeUrls?.map(({ url, text }) => `${text}: ${url}`).join("\n"))
.split("[[[Link]]]")
.join(`${dateUrl.text}: ${dateUrl.url}`),
html: message
.split("\n")
.join("<br>")
.split("[[[Slots]]]")
.join(timeUrls?.map(({ url, text }) => `<a href="${url}">${text}</a>`).join("<br>"))
.split("[[[Link]]]")
.join(`<a href="${dateUrl.url}">${dateUrl.text}</a>`),
});
return "Booking link sent";
};
const sendBookingEmailTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
return new DynamicStructuredTool({
description:
"Send a booking link via email. Useful for scheduling with non cal users. Be confident, suggesting a good date/time with a fallback to a link to select a date/time.",
func: async ({ message, subject, to, eventTypeSlug, slots, date }) => {
return JSON.stringify(
await sendBookingEmail({
apiKey,
user,
users,
agentEmail,
subject,
to,
message,
eventTypeSlug,
slots,
date,
})
);
},
name: "sendBookingEmail",
schema: z.object({
message: z
.string()
.describe(
"A polite and professional email with an intro and signature at the end. Specify you are the AI booking assistant of the primary user. Use [[[Slots]]] and a fallback [[[Link]]] to inject good times and 'see all times' into messages"
),
subject: z.string(),
to: z
.string()
.describe("email address to send the booking link to. Primary user is automatically CC'd"),
eventTypeSlug: z.string().describe("the slug of the event type to book"),
slots: z
.array(
z.object({
time: z.string().describe("YYYY-MM-DDTHH:mm in UTC"),
text: z.string().describe("minimum readable label. Ex. 4pm."),
})
)
.optional()
.describe("Time slots the external user can click"),
date: z
.object({
date: z.string().describe("YYYY-MM-DD"),
text: z.string().describe('"See all times" or similar'),
})
.describe(
"A booking link that allows the external user to select a date / time. Should be a fallback to time slots"
),
}),
});
};
export default sendBookingEmailTool;

View File

@ -1,81 +0,0 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "~/src/env.mjs";
import type { User, UserList } from "~/src/types/user";
import sendEmail from "~/src/utils/sendEmail";
export const sendBookingLink = async ({
user,
agentEmail,
subject,
to,
message,
eventTypeSlug,
date,
}: {
apiKey: string;
user: User;
users: UserList;
agentEmail: string;
subject: string;
to: string[];
message: string;
eventTypeSlug: string;
date: string;
}) => {
const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
await sendEmail({
subject,
to,
cc: user.email,
from: agentEmail,
text: message.split("[[[Booking Link]]]").join(url),
html: message
.split("\n")
.join("<br>")
.split("[[[Booking Link]]]")
.join(`<a href="${url}">Booking Link</a>`),
});
return "Booking link sent";
};
const sendBookingLinkTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
return new DynamicStructuredTool({
description: "Send a booking link via email. Useful for scheduling with non cal users.",
func: async ({ message, subject, to, eventTypeSlug, date }) => {
return JSON.stringify(
await sendBookingLink({
apiKey,
user,
users,
agentEmail,
subject,
to,
message,
eventTypeSlug,
date,
})
);
},
name: "sendBookingLink",
schema: z.object({
message: z
.string()
.describe(
"Make sure to nicely format the message and introduce yourself as the primary user's booking assistant. Make sure to include a spot for the link using: [[[Booking Link]]]"
),
subject: z.string(),
to: z
.array(z.string())
.describe("array of emails to send the booking link to. Primary user is automatically CC'd"),
eventTypeSlug: z.string().describe("the slug of the event type to book"),
date: z.string().describe("the date (yyyy-mm-dd) to suggest for the booking"),
}),
});
};
export default sendBookingLinkTool;

View File

@ -6,7 +6,7 @@ import createBookingIfAvailable from "../tools/createBooking";
import deleteBooking from "../tools/deleteBooking";
import getAvailability from "../tools/getAvailability";
import getBookings from "../tools/getBookings";
import sendBookingLink from "../tools/sendBookingLink";
import sendBookingEmail from "../tools/sendBookingEmail";
import updateBooking from "../tools/updateBooking";
import type { EventType } from "../types/eventType";
import type { User, UserList } from "../types/user";
@ -35,7 +35,7 @@ const agent = async (
createBookingIfAvailable(apiKey, userId, users),
updateBooking(apiKey, userId),
deleteBooking(apiKey),
sendBookingLink(apiKey, user, users, agentEmail),
sendBookingEmail(apiKey, user, users, agentEmail),
];
const model = new ChatOpenAI({
@ -53,6 +53,8 @@ const agent = async (
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 users should be formatted per that user's timezone.
In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule."
If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds.
The primary user's id is: ${userId}
The primary user's username is: ${user.username}

View File

@ -6,8 +6,12 @@ import type { UserList } from "../types/user";
* Extracts usernames (@Example) and emails (hi@example.com) from a string
*/
export const extractUsers = async (text: string) => {
const usernames = text.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)?.map((username) => username.slice(1));
const emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g);
const usernames = text
.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)
?.map((username) => username.slice(1).toLowerCase());
const emails = text
.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g)
?.map((email) => email.toLowerCase());
const dbUsersFromUsernames = usernames
? await prisma.user.findMany({

View File

@ -0,0 +1,15 @@
import type { NextMiddleware } from "next-api-middleware";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
// TODO: Add a way to add trusted api keys
await checkRateLimitAndThrowError({
identifier: req.query.apiKey as string,
rateLimitingType: "api",
});
await next();
};

View File

@ -4,7 +4,7 @@ import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { isAdminGuard } from "~/lib/utils/isAdmin";
import { isAdminGuard } from "../utils/isAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
export const dateNotInPast = function (date: Date) {

View File

@ -12,24 +12,29 @@ import {
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
} from "./httpMethods";
import { rateLimitApiKey } from "./rateLimitApiKey";
import { verifyApiKey } from "./verifyApiKey";
import { withPagination } from "./withPagination";
const withMiddleware = label(
{
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
HTTP_GET,
HTTP_PATCH,
HTTP_POST,
HTTP_DELETE,
addRequestId,
verifyApiKey,
customPrismaClient,
extendRequest,
pagination: withPagination,
captureErrors,
},
const middleware = {
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
HTTP_GET,
HTTP_PATCH,
HTTP_POST,
HTTP_DELETE,
addRequestId,
verifyApiKey,
rateLimitApiKey,
customPrismaClient,
extendRequest,
pagination: withPagination,
captureErrors,
};
type Middleware = keyof typeof middleware;
const middlewareOrder =
// The order here, determines the order of execution
[
"extendRequest",
@ -37,8 +42,10 @@ const withMiddleware = label(
// - Put customPrismaClient before verifyApiKey always.
"customPrismaClient",
"verifyApiKey",
"rateLimitApiKey",
"addRequestId",
] // <-- Provide a list of middleware to call automatically
);
] as Middleware[]; // <-- Provide a list of middleware to call automatically
export { withMiddleware };
const withMiddleware = label(middleware, middlewareOrder);
export { withMiddleware, middleware, middlewareOrder };

View File

@ -0,0 +1,14 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
export function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) {
/** Guard: Only admins can query other users */
if (!isAdmin) {
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
}
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds];
}

View File

@ -58,7 +58,7 @@ export const schemaBookingReadPublic = Booking.extend({
})
)
.optional(),
responses: z.record(z.any()),
responses: z.record(z.any()).nullable(),
}).pick({
id: true,
userId: true,

View File

@ -3,6 +3,7 @@ import { z } from "zod";
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
credentialId: true,
integration: true,
externalId: true,
eventTypeId: true,
@ -14,9 +15,10 @@ const schemaDestinationCalendarCreateParams = z
.object({
integration: z.string(),
externalId: z.string(),
eventTypeId: z.number(),
bookingId: z.number(),
userId: z.number(),
credentialId: z.number(),
eventTypeId: z.number().optional(),
bookingId: z.number().optional(),
userId: z.number().optional(),
})
.strict();
@ -45,4 +47,5 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
eventTypeId: true,
bookingId: true,
userId: true,
credentialId: true,
});

View File

@ -75,6 +75,7 @@ export const schemaUserBaseBodyParams = User.pick({
theme: true,
defaultScheduleId: true,
locale: true,
hideBranding: true,
timeFormat: true,
brandColor: true,
darkBrandColor: true,
@ -95,6 +96,7 @@ const schemaUserEditParams = z.object({
weekStart: z.nativeEnum(weekdays).optional(),
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(),
@ -115,6 +117,7 @@ const schemaUserCreateParams = z.object({
weekStart: z.nativeEnum(weekdays).optional(),
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(),
@ -157,6 +160,7 @@ export const schemaUserReadPublic = User.pick({
defaultScheduleId: true,
locale: true,
timeFormat: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,

View File

@ -1,240 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import type { DestinationCalendarResponse } from "~/lib/types";
import {
schemaDestinationCalendarEditBodyParams,
schemaDestinationCalendarReadPublic,
} from "~/lib/validations/destination-calendar";
import {
schemaQueryIdParseInt,
withValidQueryIdTransformParseInt,
} from "~/lib/validations/shared/queryIdTransformParseInt";
export async function destionationCalendarById(
{ method, query, body, userId, prisma }: NextApiRequest,
res: NextApiResponse<DestinationCalendarResponse>
) {
const safeQuery = schemaQueryIdParseInt.safeParse(query);
const safeBody = schemaDestinationCalendarEditBodyParams.safeParse(body);
if (!safeQuery.success) {
res.status(400).json({ message: "Your query was invalid" });
return;
}
const data = await prisma.destinationCalendar.findMany({ where: { userId } });
const userDestinationCalendars = data.map((destinationCalendar) => destinationCalendar.id);
// FIXME: Should we also check ownership of bokingId and eventTypeId to avoid users cross-pollinating other users calendars.
// On a related note, moving from sequential integer IDs to UUIDs would be a good idea. and maybe help avoid having this problem.
if (userDestinationCalendars.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" });
else {
switch (method) {
/**
* @swagger
* /destination-calendars/{id}:
* get:
* summary: Find a destination calendar
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the destination calendar to get
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - destination-calendars
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: DestinationCalendar was not found
* patch:
* summary: Edit an existing destination calendar
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the destination calendar to edit
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* requestBody:
* description: Create a new booking related to one of your event-types
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* integration:
* type: string
* description: 'The integration'
* externalId:
* type: string
* description: 'The external ID of the integration'
* eventTypeId:
* type: integer
* description: 'The ID of the eventType it is associated with'
* bookingId:
* type: integer
* description: 'The booking ID it is associated with'
* tags:
* - destination-calendars
* responses:
* 201:
* description: OK, destinationCalendar edited successfuly
* 400:
* description: Bad request. DestinationCalendar body is invalid.
* 401:
* description: Authorization information is missing or invalid.
* delete:
* summary: Remove an existing destination calendar
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the destination calendar to delete
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - destination-calendars
* responses:
* 201:
* description: OK, destinationCalendar removed successfuly
* 400:
* description: Bad request. DestinationCalendar id is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
case "GET":
await prisma.destinationCalendar
.findUnique({ where: { id: safeQuery.data.id } })
.then((data) => schemaDestinationCalendarReadPublic.parse(data))
.then((destination_calendar) => res.status(200).json({ destination_calendar }))
.catch((error: Error) =>
res.status(404).json({
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
error,
})
);
break;
/**
* @swagger
* /destination-calendars/{id}:
* patch:
* summary: Edit an existing destination calendar
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the destination calendar to edit
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - destination-calendars
* responses:
* 201:
* description: OK, destinationCalendar edited successfuly
* 400:
* description: Bad request. DestinationCalendar body is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
case "PATCH":
if (!safeBody.success) {
{
res.status(400).json({ message: "Invalid request body" });
return;
}
}
await prisma.destinationCalendar
.update({ where: { id: safeQuery.data.id }, data: safeBody.data })
.then((data) => schemaDestinationCalendarReadPublic.parse(data))
.then((destination_calendar) => res.status(200).json({ destination_calendar }))
.catch((error: Error) =>
res.status(404).json({
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
error,
})
);
break;
/**
* @swagger
* /destination-calendars/{id}:
* delete:
* summary: Remove an existing destination calendar
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the destination calendar to delete
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - destination-calendars
* responses:
* 201:
* description: OK, destinationCalendar removed successfuly
* 400:
* description: Bad request. DestinationCalendar id is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
case "DELETE":
await prisma.destinationCalendar
.delete({
where: { id: safeQuery.data.id },
})
.then(() =>
res.status(200).json({
message: `DestinationCalendar with id: ${safeQuery.data.id} deleted`,
})
)
.catch((error: Error) =>
res.status(404).json({
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
error,
})
);
break;
default:
res.status(405).json({ message: "Method not allowed" });
break;
}
}
}
export default withMiddleware("HTTP_GET_DELETE_PATCH")(
withValidQueryIdTransformParseInt(destionationCalendarById)
);

View File

@ -0,0 +1,32 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
async function authMiddleware(req: NextApiRequest) {
const { userId, isAdmin, prisma } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
if (isAdmin) return;
const userEventTypes = await prisma.eventType.findMany({
where: { userId },
select: { id: true },
});
const userEventTypeIds = userEventTypes.map((eventType) => eventType.id);
const destinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
AND: [
{ id },
{
OR: [{ userId }, { eventTypeId: { in: userEventTypeIds } }],
},
],
},
});
if (!destinationCalendar)
throw new HttpError({ statusCode: 404, message: "Destination calendar not found" });
}
export default authMiddleware;

View File

@ -0,0 +1,42 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
/**
* @swagger
* /destination-calendars/{id}:
* delete:
* summary: Remove an existing destination calendar
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the destination calendar to delete
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - destination-calendars
* responses:
* 200:
* description: OK, destinationCalendar removed successfully
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: Destination calendar not found
*/
export async function deleteHandler(req: NextApiRequest) {
const { prisma, query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
await prisma.destinationCalendar.delete({ where: { id } });
return { message: `OK, Destination Calendar removed successfully` };
}
export default defaultResponder(deleteHandler);

View File

@ -0,0 +1,47 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
/**
* @swagger
* /destination-calendars/{id}:
* get:
* summary: Find a destination calendar
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the destination calendar to get
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - destination-calendars
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: Destination calendar not found
*/
export async function getHandler(req: NextApiRequest) {
const { prisma, query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const destinationCalendar = await prisma.destinationCalendar.findUnique({
where: { id },
});
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse({ ...destinationCalendar }) };
}
export default defaultResponder(getHandler);

View File

@ -0,0 +1,71 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import {
schemaDestinationCalendarEditBodyParams,
schemaDestinationCalendarReadPublic,
} from "~/lib/validations/destination-calendar";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
/**
* @swagger
* /destination-calendars/{id}:
* patch:
* summary: Edit an existing destination calendar
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the destination calendar to edit
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* requestBody:
* description: Create a new booking related to one of your event-types
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* integration:
* type: string
* description: 'The integration'
* externalId:
* type: string
* description: 'The external ID of the integration'
* eventTypeId:
* type: integer
* description: 'The ID of the eventType it is associated with'
* bookingId:
* type: integer
* description: 'The booking ID it is associated with'
* tags:
* - destination-calendars
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: Destination calendar not found
*/
export async function patchHandler(req: NextApiRequest) {
const { prisma, query, body } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
const destinationCalendar = await prisma.destinationCalendar.update({
where: { id },
data: parsedBody,
});
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
}
export default defaultResponder(patchHandler);

View File

@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import authMiddleware from "./_auth-middleware";
export default withMiddleware()(
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
await authMiddleware(req);
return defaultHandler({
GET: import("./_get"),
PATCH: import("./_patch"),
DELETE: import("./_delete"),
})(req, res);
})
);

View File

@ -0,0 +1,58 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar";
/**
* @swagger
* /destination-calendars:
* get:
* parameters:
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* summary: Find all destination calendars
* tags:
* - destination-calendars
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: No destination calendars were found
*/
async function getHandler(req: NextApiRequest) {
const { userId, prisma } = req;
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
const userEventTypes = await prisma.eventType.findMany({
where: { userId: { in: userIds } },
select: { id: true },
});
const userEventTypeIds = userEventTypes.map((eventType) => eventType.id);
const allDestinationCalendars = await prisma.destinationCalendar.findMany({
where: {
OR: [{ userId: { in: userIds } }, { eventTypeId: { in: userEventTypeIds } }],
},
});
if (allDestinationCalendars.length === 0)
new HttpError({ statusCode: 404, message: "No destination calendars were found" });
return {
destinationCalendars: allDestinationCalendars.map((destinationCalendar) =>
schemaDestinationCalendarReadPublic.parse(destinationCalendar)
),
};
}
export default defaultResponder(getHandler);

View File

@ -0,0 +1,122 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import {
schemaDestinationCalendarReadPublic,
schemaDestinationCalendarCreateBodyParams,
} from "~/lib/validations/destination-calendar";
/**
* @swagger
* /destination-calendars:
* post:
* parameters:
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* summary: Creates a new destination calendar
* requestBody:
* description: Create a new destination calendar for your events
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - integration
* - externalId
* - credentialId
* properties:
* integration:
* type: string
* description: 'The integration'
* externalId:
* type: string
* description: 'The external ID of the integration'
* credentialId:
* type: integer
* description: 'The credential ID it is associated with'
* eventTypeId:
* type: integer
* description: 'The ID of the eventType it is associated with'
* bookingId:
* type: integer
* description: 'The booking ID it is associated with'
* userId:
* type: integer
* description: 'The user it is associated with'
* tags:
* - destination-calendars
* responses:
* 201:
* description: OK, destination calendar created
* 400:
* description: Bad request. DestinationCalendar body is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
const { userId, isAdmin, prisma, body } = req;
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
await checkPermissions(req, userId);
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
/* Check if credentialId data matches the ownership and integration passed in */
const credential = await prisma.credential.findFirst({
where: { type: parsedBody.integration, userId: assignedUserId },
select: { id: true, type: true, userId: true },
});
if (!credential)
throw new HttpError({
statusCode: 400,
message: "Bad request, credential id invalid",
});
if (parsedBody.eventTypeId) {
const eventType = await prisma.eventType.findFirst({
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
});
if (!eventType)
throw new HttpError({
statusCode: 400,
message: "Bad request, eventTypeId invalid",
});
parsedBody.userId = undefined;
}
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
return {
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
message: "Destination calendar created successfully",
};
}
async function checkPermissions(req: NextApiRequest, userId: number) {
const { isAdmin } = req;
const body = schemaDestinationCalendarCreateBodyParams.parse(req.body);
/* Non-admin users can only create destination calendars for themselves */
if (!isAdmin && body.userId)
throw new HttpError({
statusCode: 401,
message: "ADMIN required for `userId`",
});
/* Admin users are required to pass in a userId */
if (isAdmin && !body.userId) throw new HttpError({ statusCode: 400, message: "`userId` required" });
/* User should only be able to create for their own destination calendars*/
if (!isAdmin && body.eventTypeId) {
const ownsEventType = await req.prisma.eventType.findFirst({ where: { id: body.eventTypeId, userId } });
if (!ownsEventType) throw new HttpError({ statusCode: 401, message: "Unauthorized" });
}
// TODO:: Add support for team event types with validation
}
export default defaultResponder(postHandler);

View File

@ -1,114 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultHandler } from "@calcom/lib/server";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import type { DestinationCalendarResponse, DestinationCalendarsResponse } from "~/lib/types";
import {
schemaDestinationCalendarCreateBodyParams,
schemaDestinationCalendarReadPublic,
} from "~/lib/validations/destination-calendar";
async function createOrlistAllDestinationCalendars(
{ method, body, userId, prisma }: NextApiRequest,
res: NextApiResponse<DestinationCalendarsResponse | DestinationCalendarResponse>
) {
if (method === "GET") {
/**
* @swagger
* /destination-calendars:
* get:
* parameters:
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* summary: Find all destination calendars
* tags:
* - destination-calendars
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: No destination calendars were found
*/
const data = await prisma.destinationCalendar.findMany({ where: { userId } });
const destination_calendars = data.map((destinationCalendar) =>
schemaDestinationCalendarReadPublic.parse(destinationCalendar)
);
if (data) res.status(200).json({ destination_calendars });
else
(error: Error) =>
res.status(404).json({
message: "No DestinationCalendars were found",
error,
});
} else if (method === "POST") {
/**
* @swagger
* /destination-calendars:
* post:
* parameters:
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* summary: Creates a new destination calendar
* requestBody:
* description: Create a new destination calendar for your events
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - integration
* - externalId
* properties:
* integration:
* type: string
* description: 'The integration'
* externalId:
* type: string
* description: 'The external ID of the integration'
* eventTypeId:
* type: integer
* description: 'The ID of the eventType it is associated with'
* bookingId:
* type: integer
* description: 'The booking ID it is associated with'
* tags:
* - destination-calendars
* responses:
* 201:
* description: OK, destination calendar created
* 400:
* description: Bad request. DestinationCalendar body is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
const safe = schemaDestinationCalendarCreateBodyParams.safeParse(body);
if (!safe.success) {
res.status(400).json({ message: "Invalid request body" });
return;
}
const data = await prisma.destinationCalendar.create({ data: { ...safe.data, userId } });
const destination_calendar = schemaDestinationCalendarReadPublic.parse(data);
if (destination_calendar)
res.status(201).json({ destination_calendar, message: "DestinationCalendar created successfully" });
else
(error: Error) =>
res.status(400).json({
message: "Could not create new destinationCalendar",
error,
});
} else res.status(405).json({ message: `Method ${method} not allowed` });
}
export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllDestinationCalendars);
export default withMiddleware()(
defaultHandler({
GET: import("./_get"),
POST: import("./_post"),
})
);

View File

@ -209,6 +209,8 @@ export async function patchHandler(req: NextApiRequest) {
hosts = [],
bookingLimits,
durationLimits,
/** FIXME: Updating event-type children from API not supported for now */
children: _,
...parsedBody
} = schemaEventTypeEditBodyParams.parse(body);

View File

@ -268,6 +268,8 @@ async function postHandler(req: NextApiRequest) {
hosts = [],
bookingLimits,
durationLimits,
/** FIXME: Adding event-type children from API not supported for now */
children: _,
...parsedBody
} = schemaEventTypeCreateBodyParams.parse(body || {});
@ -282,8 +284,8 @@ async function postHandler(req: NextApiRequest) {
await checkPermissions(req);
if (parsedBody.parentId) {
await checkParentEventOwnership(parsedBody.parentId, userId);
await checkUserMembership(parsedBody.parentId, parsedBody.userId);
await checkParentEventOwnership(req);
await checkUserMembership(req);
}
if (isAdmin && parsedBody.userId) {

View File

@ -1,17 +1,21 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
/**
* Checks if a user, identified by the provided userId, has ownership (or admin rights) over
* the team associated with the event type identified by the parentId.
*
* @param parentId - The ID of the parent event type.
* @param userId - The ID of the user.
* @param req - The current request
*
* @throws {HttpError} If the parent event type is not found,
* if the parent event type doesn't belong to any team,
* or if the user doesn't have ownership or admin rights to the associated team.
*/
export default async function checkParentEventOwnership(parentId: number, userId: number) {
export default async function checkParentEventOwnership(req: NextApiRequest) {
const { userId, prisma, body } = req;
/** These are already parsed upstream, we can assume they're good here. */
const parentId = Number(body.parentId);
const parentEventType = await prisma.eventType.findUnique({
where: {
id: parentId,

View File

@ -1,17 +1,22 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
/**
* Checks if a user, identified by the provided userId, is a member of the team associated
* with the event type identified by the parentId.
*
* @param parentId - The ID of the event type.
* @param userId - The ID of the user.
* @param req - The current request
*
* @throws {HttpError} If the event type is not found,
* if the event type doesn't belong to any team,
* or if the user isn't a member of the associated team.
*/
export default async function checkUserMembership(parentId: number, userId: number) {
export default async function checkUserMembership(req: NextApiRequest) {
const { prisma, body } = req;
/** These are already parsed upstream, we can assume they're good here. */
const parentId = Number(body.parentId);
const userId = Number(body.userId);
const parentEventType = await prisma.eventType.findUnique({
where: {
id: parentId,

View File

@ -3,18 +3,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
import { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
async function handler(req: NextApiRequest, res: NextApiResponse) {
/** @see https://trpc.io/docs/server-side-calls */
const ctx = await createContext({ req, res });
const caller = slotsRouter.createCaller(ctx);
try {
const input = getScheduleSchema.parse(req.query);
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await caller.getSchedule(req.query as any /* Let tRPC handle this */);
} catch (cause) {
if (cause instanceof TRPCError) {
const statusCode = getHTTPStatusCodeFromError(cause);

View File

@ -53,6 +53,9 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* timeZone:
* description: The user's time zone
* type: string
* hideBranding:
* description: Remove branding from the user's calendar page
* type: boolean
* theme:
* description: Default theme for the user. Acceptable values are one of [DARK, LIGHT]
* type: string
@ -79,7 +82,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* - users
* responses:
* 200:
* description: OK, user edited successfuly
* description: OK, user edited successfully
* 400:
* description: Bad request. User body is invalid.
* 401:
@ -94,9 +97,10 @@ export async function patchHandler(req: NextApiRequest) {
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
const body = await schemaUserEditBodyParams.parseAsync(req.body);
// disable role changes unless admin.
if (!isAdmin && body.role) {
body.role = undefined;
// disable role or branding changes unless admin.
if (!isAdmin) {
if (body.role) body.role = undefined;
if (body.hideBranding) body.hideBranding = undefined;
}
const userSchedules = await prisma.schedule.findMany({

View File

@ -42,6 +42,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
* darkBrandColor:
* description: The new user's brand color for dark mode
* type: string
* hideBranding:
* description: Remove branding from the user's calendar page
* type: boolean
* weekStart:
* description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]
* type: string

View File

@ -1,3 +1,4 @@
// TODO: Fix tests (These test were never running due to the vitest workspace config)
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
@ -21,7 +22,7 @@ vi.mock("@calcom/lib/server/i18n", () => {
};
});
describe("POST /api/bookings", () => {
describe.skipIf(true)("POST /api/bookings", () => {
describe("Errors", () => {
test("Missing required data", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
@ -31,7 +32,7 @@ describe("POST /api/bookings", () => {
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message:

View File

@ -0,0 +1,36 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, vi, it, expect, afterEach } from "vitest";
import { addRequestId } from "../../../lib/helpers/addRequestid";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
afterEach(() => {
vi.resetAllMocks();
});
describe("Adds a request ID", () => {
it("Should attach a request ID to the request", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
});
const middleware = {
fn: addRequestId,
};
const serverNext = vi.fn((next: void) => Promise.resolve(next));
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(200);
expect(res.getHeader("Calcom-Response-ID")).toBeDefined();
});
});

View File

@ -0,0 +1,53 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, vi, it, expect, afterEach } from "vitest";
import { httpMethod } from "../../../lib/helpers/httpMethods";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
afterEach(() => {
vi.resetAllMocks();
});
describe("HTTP Methods function only allows the correct HTTP Methods", () => {
it("Should allow the passed in Method", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
});
const middleware = {
fn: httpMethod("POST"),
};
const serverNext = vi.fn((next: void) => Promise.resolve(next));
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(200);
});
it("Should allow the passed in Method", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
});
const middleware = {
fn: httpMethod("GET"),
};
const serverNext = vi.fn((next: void) => Promise.resolve(next));
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(405);
});
});

View File

@ -0,0 +1,76 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, vi, it, expect, afterEach } from "vitest";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { isAdminGuard } from "~/lib/utils/isAdmin";
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
afterEach(() => {
vi.resetAllMocks();
});
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
return {
default: vi.fn(),
};
});
vi.mock("~/lib/utils/isAdmin", () => {
return {
isAdminGuard: vi.fn(),
};
});
describe("Verify API key", () => {
it("It should throw an error if the api key is not valid", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
});
const middleware = {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(false);
vi.mocked(isAdminGuard).mockResolvedValue(false);
const serverNext = vi.fn((next: void) => Promise.resolve(next));
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
it("It should thow an error if no api key is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
});
const middleware = {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue(false);
const serverNext = vi.fn((next: void) => Promise.resolve(next));
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
});

View File

@ -0,0 +1,17 @@
import { describe, vi, it, expect, afterEach } from "vitest";
import { middlewareOrder } from "../../../lib/helpers/withMiddleware";
afterEach(() => {
vi.resetAllMocks();
});
// Not sure if there is much point testing this order is actually applied via an integration test:
// It is tested internally https://github.com/htunnicliff/next-api-middleware/blob/368b12aa30e79f4bd7cfe7aacc18da263cc3de2f/lib/label.spec.ts#L62
describe("API - withMiddleware test", () => {
it("Custom prisma should be before verifyApiKey", async () => {
const customPrismaClientIndex = middlewareOrder.indexOf("customPrismaClient");
const verifyApiKeyIndex = middlewareOrder.indexOf("verifyApiKey");
expect(customPrismaClientIndex).toBeLessThan(verifyApiKeyIndex);
});
});

1
apps/platform/README.md Normal file
View File

@ -0,0 +1 @@
Hello World

View File

@ -20,7 +20,7 @@ export default function AddToHomescreen() {
<div className="flex w-0 flex-1 items-center">
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
<svg
className="h-7 w-7 fill-current text-indigo-500"
className="h-7 w-7 fill-current text-[#5B93F9]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 50 50"
enableBackground="new 0 0 50 50">
@ -29,7 +29,7 @@ export default function AddToHomescreen() {
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
</svg>
</span>
<p className="text-inverted ms-3 text-xs font-medium">
<p className="text-inverted ms-3 text-xs font-medium dark:text-white">
<span className="inline">{t("add_to_homescreen")}</span>
</p>
</div>
@ -40,7 +40,7 @@ export default function AddToHomescreen() {
type="button"
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
<span className="sr-only">{t("dismiss")}</span>
<X className="text-inverted h-6 w-6" aria-hidden="true" />
<X className="text-inverted h-6 w-6 dark:text-white" aria-hidden="true" />
</button>
</div>
</div>

View File

@ -1,12 +1,7 @@
import { lookup } from "bcp-47-match";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { CALCOM_VERSION } from "@calcom/lib/constants";
import { trpc } from "@calcom/trpc/react";
function useViewerI18n(locale: string) {
export function useViewerI18n(locale: string) {
return trpc.viewer.public.i18n.useQuery(
{ locale, CalComVersion: CALCOM_VERSION },
{
@ -19,46 +14,3 @@ function useViewerI18n(locale: string) {
}
);
}
function useClientLocale(locales: string[]) {
const session = useSession();
// If the user is logged in, use their locale
if (session.data?.user.locale) return session.data.user.locale;
// If the user is not logged in, use the browser locale
if (typeof window !== "undefined") {
// This is the only way I found to ensure the prefetched locale is used on first render
// FIXME: Find a better way to pick the best matching locale from the browser
return lookup(locales, window.navigator.language) || window.navigator.language;
}
// If the browser is not available, use English
return "en";
}
export function useClientViewerI18n(locales: string[]) {
const clientLocale = useClientLocale(locales);
return useViewerI18n(clientLocale);
}
/**
* Auto-switches locale client-side to the logged in user's preference
*/
const I18nLanguageHandler = (props: { locales: string[] }) => {
const { locales } = props;
const { i18n } = useTranslation("common");
const locale = useClientViewerI18n(locales).data?.locale || i18n.language;
useEffect(() => {
// bail early when i18n = {}
if (Object.keys(i18n).length === 0) return;
// if locale is ready and the i18n.language does != locale - changeLanguage
if (locale && i18n.language !== locale) {
i18n.changeLanguage(locale);
}
// set dir="rtl|ltr"
document.dir = i18n.dir();
document.documentElement.setAttribute("lang", locale);
}, [locale, i18n]);
return null;
};
export default I18nLanguageHandler;

View File

@ -13,8 +13,6 @@ import type { AppProps } from "@lib/app-providers";
import AppProviders from "@lib/app-providers";
import { seoConfig } from "@lib/config/next-seo.config";
import I18nLanguageHandler from "@components/I18nLanguageHandler";
export interface CalPageWrapper {
(props?: AppProps): JSX.Element;
PageWrapper?: AppProps["Component"]["PageWrapper"];
@ -72,7 +70,6 @@ function PageWrapper(props: AppProps) {
}
{...seoConfig.defaultNextSeo}
/>
<I18nLanguageHandler locales={props.router.locales || []} />
<Script
nonce={nonce}
id="page-status"

View File

@ -88,6 +88,10 @@ function BookingListItem(booking: BookingItemProps) {
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const eventLocationType = getEventLocationType(booking.location);
const meetingLink = booking.references[0]?.meetingUrl
? booking.references[0]?.meetingUrl
: booking.location;
const paymentAppData = getPaymentAppData(booking.eventType);
@ -353,6 +357,27 @@ function BookingListItem(booking: BookingItemProps) {
attendees={booking.attendees}
/>
</div>
{!isPending && (eventLocationType || booking.location?.startsWith("https://")) && (
<Link
href={meetingLink ? meetingLink.toString() : ""}
className="text-sm leading-6 text-blue-400 hover:underline">
<div className="flex items-center gap-2">
{eventLocationType ? (
<>
<img
src={eventLocationType.iconUrl}
className="h-4 w-4 rounded-sm"
alt={`${eventLocationType.label} logo`}
/>
{t("join_event_location", { eventLocationType: eventLocationType.label })}
</>
) : (
t("join_meeting")
)}
</div>
</Link>
)}
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}

View File

@ -47,7 +47,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<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=" bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full">
<CreditCard className="m-auto h-6 w-6" />
</div>
<div className="pt-1">

View File

@ -43,7 +43,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<div className="flex flex-row space-x-3">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
<Clock className="m-auto h-6 w-6" />
</div>
<div className="pt-1">

View File

@ -130,7 +130,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
* a team event. Since we don't have logic to handle each attendee calendar (for now).
* This will fallback to each user selected destination calendar.
*/}
<div className="border-subtle space-y-6 rounded-md border p-6">
<div className="border-subtle space-y-6 rounded-lg border p-6">
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="flex flex-col">
<div className="flex justify-between">
@ -182,9 +182,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</div>
</div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} isOuterBorder={true} />
<div className="border-subtle space-y-6 rounded-md border p-6">
<div className="border-subtle space-y-6 rounded-lg border p-6">
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
@ -213,8 +213,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
defaultValue={eventType.requiresBookerEmailVerification}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("requires_booker_email_verification")}
{...shouldLockDisableProps("requiresBookerEmailVerification")}
description={t("description_requires_booker_email_verification")}
@ -230,8 +231,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
defaultValue={eventType.hideCalendarNotes}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_notes")}
{...shouldLockDisableProps("hideCalendarNotes")}
description={t("disable_notes_description")}
@ -247,9 +249,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
redirectUrlVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
@ -261,7 +264,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
setRedirectUrlVisible(e);
onChange(e ? value : "");
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<TextField
className="w-full"
label={t("redirect_success_booking")}
@ -287,20 +290,21 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
hashedLinkVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="hashedLinkCheck"
title={t("private_link")}
title={t("enable_private_url")}
Badge={
<a
target="_blank"
rel="noreferrer"
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
<Info className="mb-2 ml-1.5 h-4 w-4 cursor-pointer" />
<Info className="ml-1.5 h-4 w-4 cursor-pointer" />
</a>
}
{...shouldLockDisableProps("hashedLinkCheck")}
@ -310,7 +314,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
setHashedLinkVisible(e);
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{!IS_VISUAL_REGRESSION_TESTING && (
<TextField
disabled
@ -353,9 +357,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
value && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
@ -379,13 +384,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}
onChange(e);
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div className="lg:-ml-2">
<div>
<TextField
required
name="seatsPerTimeSlot"
@ -395,12 +400,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
disabled={seatsLocked.disabled}
defaultValue={value || 2}
min={1}
containerClassName="max-w-80"
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
/>
<div className="mt-2">
<div className="mt-4">
<CheckboxField
description={t("show_attendees")}
disabled={seatsLocked.disabled}
@ -435,8 +441,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_attendees_confirmation_emails")}
description={t("disable_attendees_confirmation_emails_description")}
checked={value || false}
@ -459,8 +466,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_host_confirmation_emails")}
description={t("disable_host_confirmation_emails_description")}
checked={value || false}

View File

@ -7,7 +7,6 @@ import type { UseFormRegisterReturn } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { SingleValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
@ -141,17 +140,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue: periodType?.type,
});
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
const periodTypeLocked = shouldLockDisableProps("periodType");
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
@ -171,13 +159,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<div>
<div className="border-subtle space-y-6 rounded-md border p-6">
<div className="border-subtle space-y-6 rounded-lg border p-6">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="beforeBufferTime">
{t("before_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Label htmlFor="beforeBufferTime">{t("before_event")}</Label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
@ -196,7 +181,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@ -210,10 +194,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">
{t("after_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
<Controller
name="afterBufferTime"
control={formMethods.control}
@ -232,7 +213,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@ -248,20 +228,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="minimumBookingNotice">
{t("minimum_booking_notice")}
{shouldLockIndicator("minimumBookingNotice")}
</Label>
<MinimumBookingNoticeInput
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
{...formMethods.register("minimumBookingNotice")}
/>
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")}</Label>
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
</div>
<div className="w-full">
<Label htmlFor="slotInterval">
{t("slot_interval")}
{shouldLockIndicator("slotInterval")}
</Label>
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
<Controller
name="slotInterval"
control={formMethods.control}
@ -279,7 +250,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("slotInterval").disabled}
onChange={(val) => {
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
}}
@ -303,8 +273,8 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
labelClassName="text-sm"
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={isChecked}
onCheckedChange={(active) => {
@ -317,17 +287,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
}
}}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0">
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
</div>
</SettingsToggle>
);
@ -340,15 +305,15 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
const isChecked = Object.keys(value ?? {}).length > 0;
return (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
@ -359,11 +324,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
formMethods.setValue("durationLimits", {});
}
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
@ -380,25 +344,23 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={isChecked}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
{PERIOD_TYPES.map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
@ -407,14 +369,13 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
{!periodTypeLocked.disabled && (
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
@ -423,14 +384,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={periodTypeLocked.disabled}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
@ -455,7 +414,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
@ -478,15 +436,15 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
}}
/>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
offsetToggle && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
checked={offsetToggle}
onCheckedChange={(active) => {
setOffsetToggle(active);
@ -494,11 +452,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
formMethods.setValue("offsetStart", 0);
}
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<TextField
required
type="number"
{...offsetStartLockedProps}
containerClassName="max-w-80"
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}

View File

@ -392,7 +392,7 @@ export const EventSetupTab = (
return (
<div>
<div className="space-y-4">
<div className="border-subtle space-y-6 rounded-md border p-6">
<div className="border-subtle space-y-6 rounded-lg border p-6">
<TextField
required
label={t("title")}
@ -431,7 +431,7 @@ export const EventSetupTab = (
})}
/>
</div>
<div className="border-subtle rounded-md border p-6">
<div className="border-subtle rounded-lg border p-6">
{multipleDuration ? (
<div className="space-y-6">
<div>
@ -527,7 +527,7 @@ export const EventSetupTab = (
)}
</div>
<div className="border-subtle rounded-md border p-6">
<div className="border-subtle rounded-lg border p-6">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}

View File

@ -268,9 +268,11 @@ function EventTypeSingleLayout({
</Skeleton>
)}
<Tooltip
sideOffset={4}
content={
formMethods.watch("hidden") ? t("show_eventtype_on_profile") : t("hide_from_profile")
}>
}
side="bottom">
<div className="self-center rounded-md p-2">
<Switch
id="hiddenSwitch"
@ -291,7 +293,7 @@ function EventTypeSingleLayout({
{!isManagedEventType && (
<>
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
<Tooltip content={t("preview")}>
<Tooltip content={t("preview")} side="bottom" sideOffset={4}>
<Button
color="secondary"
data-testid="preview-button"
@ -308,6 +310,8 @@ function EventTypeSingleLayout({
variant="icon"
StartIcon={LinkIcon}
tooltip={t("copy_link")}
tooltipSide="bottom"
tooltipOffset={4}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Link copied!", "success");
@ -319,6 +323,8 @@ function EventTypeSingleLayout({
color="secondary"
variant="icon"
tooltip={t("embed")}
tooltipSide="bottom"
tooltipOffset={4}
eventId={eventType.id}
/>
</>
@ -329,6 +335,8 @@ function EventTypeSingleLayout({
variant="icon"
StartIcon={Trash}
tooltip={t("delete")}
tooltipSide="bottom"
tooltipOffset={4}
disabled={!hasPermsToDelete}
onClick={() => setDeleteDialogOpen(true)}
/>

View File

@ -124,7 +124,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
{t("add_webhook_description", { appName: APP_NAME })}
</p>
<div className="border-subtle mt-8 rounded-md border">
<div className="border-subtle my-8 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
@ -141,7 +141,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
})}
</div>
<p className="text-default mt-8 text-sm font-normal">
<p className="text-default text-sm font-normal">
<Trans i18nKey="edit_or_manage_webhooks">
If you wish to edit or manage your web hooks, please head over to &nbsp;
<Link

View File

@ -53,9 +53,10 @@ export default function RecurringEventController({
title="Experimental: Recurring Events are currently experimental and causes some issues sometimes when checking for availability. We are working on fixing this."
/>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
recurringEventState !== null && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
@ -78,7 +79,7 @@ export default function RecurringEventController({
setRecurringEventState(newVal);
}
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">

View File

@ -67,9 +67,10 @@ export default function RequiresConfirmationController({
control={formMethods.control}
render={() => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
requiresConfirmation && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
@ -83,7 +84,7 @@ export default function RequiresConfirmationController({
formMethods.setValue("requiresConfirmation", val);
onRequiresConfirmation(val);
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root
defaultValue={
requiresConfirmation
@ -147,7 +148,7 @@ export default function RequiresConfirmationController({
val
);
}}
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield]"
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield] focus:z-10 focus:border-r"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
<label

View File

@ -15,7 +15,7 @@ interface Props {
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
return (
<div className="flex min-h-screen flex-col justify-center bg-[#f3f4f6] py-12 sm:px-6 lg:px-8">
<div className="bg-subtle dark:bg-darkgray-50 flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
<HeadSeo title={props.title} description={props.description} />
{props.showLogo && <Logo small inline={false} className="mx-auto mb-auto" />}
@ -28,7 +28,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
</div>
)}
<div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-default border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
<div className="bg-default dark:bg-muted border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
{props.children}
</div>
<div className="text-default mt-8 text-center text-sm">{props.footerText}</div>

View File

@ -1,4 +1,5 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react";
import { EventCollectionProvider } from "next-collect/client";
@ -7,7 +8,8 @@ import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
import type { ParsedUrlQuery } from "querystring";
import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
import type { PropsWithChildren, ReactNode } from "react";
import { useEffect } from "react";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
@ -17,9 +19,10 @@ import { useFlags } from "@calcom/features/flags/hooks";
import { MetaProvider } from "@calcom/ui";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import type { WithLocaleProps } from "@lib/withLocale";
import type { WithNonceProps } from "@lib/withNonce";
import { useClientViewerI18n } from "@components/I18nLanguageHandler";
import { useViewerI18n } from "@components/I18nLanguageHandler";
const I18nextAdapter = appWithTranslation<
NextJsAppProps<SSRConfig> & {
@ -30,10 +33,12 @@ const I18nextAdapter = appWithTranslation<
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<
NextAppProps<
WithNonceProps & {
themeBasis?: string;
session: Session;
} & Record<string, unknown>
WithLocaleProps<
WithNonceProps<{
themeBasis?: string;
session: Session;
}>
>
>,
"Component"
> & {
@ -68,17 +73,51 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
/**
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
**/
const clientViewerI18n = useClientViewerI18n(props.router.locales || []);
const { i18n, locale } = clientViewerI18n.data || {};
const session = useSession();
const locale = session?.data?.user.locale ?? props.pageProps.newLocale;
useEffect(() => {
try {
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
delete window.document.documentElement["lang"];
window.document.documentElement.lang = locale;
// Next.js writes the locale to the same attribute
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
// which can result in a race condition
// this property descriptor ensures this never happens
Object.defineProperty(window.document.documentElement, "lang", {
configurable: true,
// value: locale,
set: function (this) {
// empty setter on purpose
},
get: function () {
return locale;
},
});
} catch (error) {
console.error(error);
window.document.documentElement.lang = locale;
}
window.document.dir = dir(locale);
}, [locale]);
const clientViewerI18n = useViewerI18n(locale);
const i18n = clientViewerI18n.data?.i18n;
const passedProps = {
...props,
pageProps: {
...props.pageProps,
...i18n,
},
router: locale ? { locale } : props.router,
} as unknown as ComponentProps<typeof I18nextAdapter>;
};
return <I18nextAdapter {...passedProps} />;
};
@ -233,7 +272,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
// No need to have intercom on public pages - Good for Page Performance
const isBookingPage = useIsBookingPage();
const { pageProps, ...rest } = props;
const { _nonce, ...restPageProps } = pageProps;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { nonce, ...restPageProps } = pageProps;
const propsWithoutNonce = {
pageProps: {
...restPageProps,

View File

@ -0,0 +1,44 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
export const getTemporaryOrgRedirect = async ({
slug,
redirectType,
eventTypeSlug,
}: {
slug: string;
redirectType: RedirectType;
eventTypeSlug: string | null;
}) => {
const prisma = (await import("@calcom/prisma")).default;
log.debug(
`Looking for redirect for`,
safeStringify({
slug,
redirectType,
eventTypeSlug,
})
);
const redirect = await prisma.tempOrgRedirect.findUnique({
where: {
from_type_fromOrgId: {
type: redirectType,
from: slug,
fromOrgId: 0,
},
},
});
if (redirect) {
log.debug(`Redirecting ${slug} to ${redirect.toUrl}`);
return {
redirect: {
permanent: false,
destination: eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl,
},
} as const;
}
return null;
};

View File

@ -0,0 +1,3 @@
export type WithLocaleProps<T extends Record<string, unknown>> = T & {
newLocale: string;
};

View File

@ -1,8 +1,8 @@
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
import type { GetServerSideProps } from "next";
import { csp } from "@lib/csp";
export type WithNonceProps = {
export type WithNonceProps<T extends Record<string, any>> = T & {
nonce?: string;
};
@ -11,9 +11,16 @@ export type WithNonceProps = {
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
*/
export default function withNonce(getServerSideProps: GetServerSideProps) {
return async (context: GetServerSidePropsContext) => {
export default function withNonce<T extends Record<string, any>>(
getServerSideProps: GetServerSideProps<T>
): GetServerSideProps<WithNonceProps<T>> {
return async (context) => {
const ssrResponse = await getServerSideProps(context);
if (!("props" in ssrResponse)) {
return ssrResponse;
}
const { nonce } = csp(context.req, context.res);
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
@ -23,10 +30,6 @@ export default function withNonce(getServerSideProps: GetServerSideProps) {
}
: null;
if (!("props" in ssrResponse)) {
return ssrResponse;
}
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
context.res.setHeader("x-csp", nonce ? "ssr" : "false");

View File

@ -235,7 +235,7 @@ const nextConfig = {
? [
{
...matcherConfigRootPath,
destination: "/team/:orgSlug",
destination: "/team/:orgSlug?isOrgProfile=1",
},
{
...matcherConfigUserRoute,

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.3.6",
"version": "3.4.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@ -166,6 +166,7 @@
"env-cmd": "^10.1.0",
"module-alias": "^2.2.2",
"msw": "^0.42.3",
"node-html-parser": "^6.1.10",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.1",
"tailwindcss-animate": "^1.0.6",

View File

@ -23,7 +23,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
@ -35,6 +35,8 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
@ -261,13 +263,14 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
context.params?.orgSlug
);
const usernameList = getUsernameList(context.query.user as string);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
const usersWithoutAvatar = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
organization: isOrgContext ? getSlugOrRequestedSlug(currentOrgDomain) : null,
},
select: {
id: true,
@ -275,6 +278,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
email: true,
name: true,
bio: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
organizationId: true,
@ -312,6 +316,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
avatar: `/${user.username}/avatar.png`,
}));
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: usernameList[0],
redirectType: RedirectType.User,
eventTypeSlug: null,
});
if (redirect) {
return redirect;
}
}
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
return {
notFound: true,

View File

@ -15,12 +15,15 @@ import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations
import { getUsernameList } from "@calcom/lib/defaultEvents";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type({
@ -93,7 +96,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
if (!users.length) {
return {
notFound: true,
};
} as const;
}
const org = isValidOrgDomain ? currentOrgDomain : null;
@ -115,7 +118,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
if (!eventData) {
return {
notFound: true,
};
} as const;
}
return {
@ -150,6 +153,20 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
context.params?.orgSlug
);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: usernames[0],
redirectType: RedirectType.User,
eventTypeSlug: slug,
});
if (redirect) {
return redirect;
}
}
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const user = await prisma.user.findFirst({
@ -167,7 +184,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
if (!user) {
return {
notFound: true,
};
} as const;
}
let booking: GetBookingType | null = null;
@ -189,7 +206,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
if (!eventData) {
return {
notFound: true,
};
} as const;
}
return {

View File

@ -1,3 +1,5 @@
import type { IncomingMessage } from "http";
import type { AppContextType } from "next/dist/shared/lib/utils";
import React from "react";
import { trpc } from "@calcom/trpc/react";
@ -8,8 +10,36 @@ import "../styles/globals.css";
function MyApp(props: AppProps) {
const { Component, pageProps } = props;
if (Component.PageWrapper !== undefined) return Component.PageWrapper(props);
return <Component {...pageProps} />;
}
export default trpc.withTRPC(MyApp);
declare global {
interface Window {
calNewLocale: string;
}
}
MyApp.getInitialProps = async (ctx: AppContextType) => {
const { req } = ctx.ctx;
let newLocale = "en";
if (req) {
const { getLocale } = await import("@calcom/features/auth/lib/getLocale");
newLocale = await getLocale(req as IncomingMessage & { cookies: Record<string, any> });
} else if (typeof window !== "undefined" && window.calNewLocale) {
newLocale = window.calNewLocale;
}
return {
pageProps: {
newLocale,
},
};
};
const WrappedMyApp = trpc.withTRPC(MyApp);
export default WrappedMyApp;

View File

@ -1,3 +1,5 @@
import type { IncomingMessage } from "http";
import { dir } from "i18next";
import type { NextPageContext } from "next";
import type { DocumentContext, DocumentProps } from "next/document";
import Document, { Head, Html, Main, NextScript } from "next/document";
@ -7,7 +9,7 @@ import { IS_PRODUCTION } from "@calcom/lib/constants";
import { csp } from "@lib/csp";
type Props = Record<string, unknown> & DocumentProps;
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
function setHeader(ctx: NextPageContext, name: string, value: string) {
try {
ctx.res?.setHeader(name, value);
@ -26,6 +28,13 @@ class MyDocument extends Document<Props> {
setHeader(ctx, "x-csp", "initialPropsOnly");
}
const getLocaleModule = ctx.req ? await import("@calcom/features/auth/lib/getLocale") : null;
const newLocale =
ctx.req && getLocaleModule
? await getLocaleModule.getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
: "en";
const asPath = ctx.asPath || "";
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
const parsedUrl = new URL(asPath, "https://dummyurl");
@ -36,17 +45,30 @@ class MyDocument extends Document<Props> {
!isEmbedSnippetGeneratorPath;
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
const initialProps = await Document.getInitialProps(ctx);
return { isEmbed, embedColorScheme, nonce, ...initialProps };
return { isEmbed, embedColorScheme, nonce, ...initialProps, newLocale };
}
render() {
const { locale } = this.props.__NEXT_DATA__;
const { isEmbed, embedColorScheme } = this.props;
const newLocale = this.props.newLocale || "en";
const newDir = dir(newLocale);
const nonceParsed = z.string().safeParse(this.props.nonce);
const nonce = nonceParsed.success ? nonceParsed.data : "";
return (
<Html lang={locale} style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<Html
lang={newLocale}
dir={newDir}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<Head nonce={nonce}>
<script
nonce={nonce}
id="newLocale"
dangerouslySetInnerHTML={{
__html: `window.calNewLocale = "${newLocale}";`,
}}
/>
<link rel="apple-touch-icon" sizes="180x180" href="/api/logo?type=apple-touch-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="/api/logo?type=favicon-32" />
<link rel="icon" type="image/png" sizes="16x16" href="/api/logo?type=favicon-16" />

View File

@ -26,7 +26,7 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
err: AugmentedError;
};
const log = logger.getChildLogger({ prefix: ["[error]"] });
const log = logger.getSubLogger({ prefix: ["[error]"] });
const CustomError: NextPage<CustomErrorProps> = (props) => {
const { statusCode, err, message, hasGetInitialPropsRun } = props;

View File

@ -6,7 +6,7 @@ import { prisma } from "@calcom/prisma";
import type { AppCategories, Prisma } from "@calcom/prisma/client";
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
const log = logger.getChildLogger({
const log = logger.getSubLogger({
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
});

View File

@ -16,7 +16,7 @@ import {
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
const log = logger.getChildLogger({ prefix: ["[api/logo]"] });
const log = logger.getSubLogger({ prefix: ["[api/logo]"] });
function removePort(url: string) {
return url.replace(/:\d+$/, "");

View File

@ -34,13 +34,35 @@ async function getIdentityData(req: NextApiRequest) {
: null;
if (username) {
const user = await prisma.user.findFirst({
let user = await prisma.user.findFirst({
where: {
username,
organization: orgQuery,
},
select: { avatar: true, email: true },
});
/**
* TEMPORARY CODE STARTS - TO BE REMOVED after mono-user schema is implemented
* Try the non-org user temporarily to support users part of a team but not part of the organization
* This is needed because of a situation where we migrate a user and the team to ORG but not all the users in the team to the ORG.
* Eventually, all users will be migrated to the ORG but this is when user by user migration happens initially.
*/
// No user found in the org, try the non-org user that might be part of the team that's part of an org
if (!user && orgQuery) {
// The only side effect this code could have is that it could serve the avatar of a non-org member from the org domain but as long as the username isn't taken by an org member.
user = await prisma.user.findFirst({
where: {
username,
organization: null,
},
select: { avatar: true, email: true },
});
}
/**
* TEMPORARY CODE ENDS
*/
return {
name: username,
email: user?.email,
@ -48,6 +70,7 @@ async function getIdentityData(req: NextApiRequest) {
org,
};
}
if (teamname) {
const team = await prisma.team.findFirst({
where: {

View File

@ -45,6 +45,7 @@ function AppsSearch({
onChange: ChangeEventHandler<HTMLInputElement>;
className?: string;
}) {
const { t } = useLocale();
return (
<TextField
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
@ -54,6 +55,7 @@ function AppsSearch({
type="search"
autoComplete="false"
onChange={onChange}
placeholder={t("search")}
/>
);
}

View File

@ -5,6 +5,7 @@ import Link from "next/link";
import type { CSSProperties } from "react";
import { useForm } from "react-hook-form";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { Button, PasswordField, Form } from "@calcom/ui";
@ -141,7 +142,6 @@ export default function Page({ requestId, isRequestExpired, csrfToken }: Props)
);
}
Page.isThemeSupported = false;
Page.PageWrapper = PageWrapper;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const id = context.params?.id as string;
@ -163,12 +163,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
} catch (e) {
resetPasswordRequest = null;
}
const locale = await getLocale(context.req);
return {
props: {
isRequestExpired: !resetPasswordRequest,
requestId: id,
csrfToken: await getCsrfToken({ req: context.req }),
...(await serverSideTranslations(context.locale || "en", ["common"])),
...(await serverSideTranslations(locale, ["common"])),
},
};
}

View File

@ -7,6 +7,7 @@ import Link from "next/link";
import type { CSSProperties, SyntheticEvent } from "react";
import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, EmailField } from "@calcom/ui";
@ -126,8 +127,9 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
/>
<div className="space-y-2">
<Button
className="w-full justify-center"
className="w-full justify-center dark:bg-white dark:text-black"
type="submit"
color="primary"
disabled={loading}
aria-label={t("request_password_reset")}
loading={loading}>
@ -141,7 +143,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
);
}
ForgotPassword.isThemeSupported = false;
ForgotPassword.PageWrapper = PageWrapper;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
@ -154,11 +155,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
res.end();
return { props: {} };
}
const locale = await getLocale(context.req);
return {
props: {
csrfToken: await getCsrfToken(context),
...(await serverSideTranslations(context.locale || "en", ["common"])),
...(await serverSideTranslations(locale, ["common"])),
},
};
};

View File

@ -15,11 +15,12 @@ import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
@ -50,7 +51,8 @@ export default function Login({
samlTenantID,
samlProductID,
totpEmail,
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
}: // eslint-disable-next-line @typescript-eslint/ban-types
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
const searchParams = useSearchParams();
const { t } = useLocale();
const router = useRouter();
@ -160,6 +162,16 @@ export default function Login({
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
};
const { data, isLoading } = trpc.viewer.public.ssoConnections.useQuery(undefined, {
onError: (err) => {
setErrorMessage(err.message);
},
});
const displaySSOLogin = HOSTED_CAL_FEATURES
? true
: isSAMLLoginEnabled && !isLoading && data?.connectionExists;
return (
<div
style={
@ -225,14 +237,14 @@ export default function Login({
type="submit"
color="primary"
disabled={formState.isSubmitting}
className="w-full justify-center">
className="w-full justify-center dark:bg-white dark:text-black">
{twoFactorRequired ? t("submit") : t("sign_in")}
</Button>
</div>
</form>
{!twoFactorRequired && (
<>
{(isGoogleLoginEnabled || isSAMLLoginEnabled) && <hr className="border-subtle my-8" />}
{(isGoogleLoginEnabled || displaySSOLogin) && <hr className="border-subtle my-8" />}
<div className="space-y-3">
{isGoogleLoginEnabled && (
<Button
@ -247,7 +259,7 @@ export default function Login({
{t("signin_with_google")}
</Button>
)}
{isSAMLLoginEnabled && (
{displaySSOLogin && (
<SAMLLogin
samlTenantID={samlTenantID}
samlProductID={samlProductID}
@ -337,7 +349,6 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
};
};
Login.isThemeSupported = false;
Login.PageWrapper = PageWrapper;
export const getServerSideProps = withNonce(_getServerSideProps);

View File

@ -1,7 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -18,6 +18,7 @@ import { ssrInit } from "@server/lib/ssr";
type Props = inferSSRProps<typeof getServerSideProps>;
export function Logout(props: Props) {
const [btnLoading, setBtnLoading] = useState<boolean>(false);
const { status } = useSession();
if (status === "authenticated") signOut({ redirect: false });
const router = useRouter();
@ -35,6 +36,11 @@ export function Logout(props: Props) {
return "hope_to_see_you_soon";
};
const navigateToLogin = () => {
setBtnLoading(true);
router.push("/auth/login");
};
return (
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
<div className="mb-4">
@ -50,14 +56,17 @@ export function Logout(props: Props) {
</div>
</div>
</div>
<Button href="/auth/login" className="flex w-full justify-center">
<Button
data-testid="logout-btn"
onClick={navigateToLogin}
className="flex w-full justify-center"
loading={btnLoading}>
{t("go_back_login")}
</Button>
</AuthContainer>
);
}
Logout.isThemeSupported = false;
Logout.PageWrapper = PageWrapper;
export default Logout;

View File

@ -1042,7 +1042,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const parsedQuery = querySchema.safeParse(context.query);
if (!parsedQuery.success) return { notFound: true };
if (!parsedQuery.success) return { notFound: true } as const;
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
@ -1100,7 +1100,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!bookingInfoRaw) {
return {
notFound: true,
};
} as const;
}
const eventTypeRaw = !bookingInfoRaw.eventTypeId
@ -1109,7 +1109,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!eventTypeRaw) {
return {
notFound: true,
};
} as const;
}
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
@ -1130,7 +1130,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!eventTypeRaw.owner)
return {
notFound: true,
};
} as const;
eventTypeRaw.users.push({
...eventTypeRaw.owner,
});

View File

@ -0,0 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[uid]";
export { default } from "../[uid]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);

View File

@ -184,7 +184,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
created: true,
}))
);
showToast(t("event_type_updated_successfully"), "success");
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();

View File

@ -6,6 +6,7 @@ import type { CSSProperties } from "react";
import { Suspense } from "react";
import { z } from "zod";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -219,10 +220,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (user.completedOnboarding) {
return { redirect: { permanent: false, destination: "/event-types" } };
}
const locale = await getLocale(context.req);
return {
props: {
...(await serverSideTranslations(context.locale ?? "", ["common"])),
...(await serverSideTranslations(locale, ["common"])),
trpcState: ssr.dehydrate(),
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
},

View File

@ -1,19 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[type]";
export { default } from "../[type]";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssrResponse = await _getServerSideProps(context);
if (ssrResponse.notFound) {
return ssrResponse;
}
return {
...ssrResponse,
props: {
...ssrResponse.props,
isEmbed: true,
},
};
};
export const getServerSideProps = withEmbedSsr(_getServerSideProps);

View File

@ -45,7 +45,7 @@ const BillingView = () => {
return (
<>
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-8 text-sm sm:space-y-8">
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-8 text-sm sm:space-y-8">
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
{t("billing_portal")}

View File

@ -25,7 +25,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
@ -79,7 +79,7 @@ const ApiKeysView = () => {
<div>
{data?.length ? (
<>
<div className="border-subtle rounded-b-md border border-t-0">
<div className="border-subtle rounded-b-lg border border-t-0">
{data.map((apiKey, index) => (
<ApiKeyListItem
key={apiKey.id}
@ -98,7 +98,7 @@ const ApiKeysView = () => {
Icon={LinkIcon}
headline={t("create_first_api_key")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
className="rounded-b-md rounded-t-none border-t-0"
className="rounded-b-lg rounded-t-none border-t-0"
buttonRaw={<NewApiKeyButton />}
/>
)}

View File

@ -6,8 +6,8 @@ import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSele
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib";
import { APP_NAME } from "@calcom/lib/constants";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -35,7 +35,10 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={false} />
<div className="border-subtle mt-6 space-y-6 rounded-t-xl border border-b-0 px-4 py-6 sm:px-6">
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<SkeletonText className="h-8 w-1/3" />
</div>
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
<div className="flex items-center justify-center">
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
@ -48,7 +51,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-xl">
<div className="rounded-b-lg">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
@ -57,9 +60,6 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
);
};
const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
const AppearanceView = ({
user,
hasPaidPlan,
@ -137,7 +137,7 @@ const AppearanceView = ({
return (
<div>
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
<div>
<p className="text-default text-base font-semibold">{t("theme")}</p>
<p className="text-default">{t("theme_applies_note")}</p>
@ -149,13 +149,13 @@ const AppearanceView = ({
mutation.mutate({
// Radio values don't support null as values, therefore we convert an empty string
// back to null here.
theme: values.theme || null,
theme: values.theme ?? null,
});
}}>
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
<ThemeLabel
variant="system"
value={null}
value={undefined}
label={t("theme_system")}
defaultChecked={user.theme === null}
register={userThemeFormMethods.register}
@ -226,11 +226,7 @@ const AppearanceView = ({
});
}
}}
childrenClassName="lg:ml-0"
switchContainerClassName={classNames(
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
isCustomBrandColorChecked && "rounded-b-none"
)}>
childrenClassName="lg:ml-0">
<div className="border-subtle flex flex-col gap-6 border-x p-6">
<Controller
name="brandColor"
@ -241,7 +237,7 @@ const AppearanceView = ({
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={user.brandColor}
resetDefaultValue="#292929"
resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR}
onChange={(value) => {
try {
checkWCAGContrastColor("#ffffff", value);
@ -273,7 +269,7 @@ const AppearanceView = ({
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={user.darkBrandColor}
resetDefaultValue="#fafafa"
resetDefaultValue={DEFAULT_DARK_BRAND_COLOR}
onChange={(value) => {
try {
checkWCAGContrastColor("#101010", value);
@ -328,7 +324,7 @@ const AppearanceView = ({
setHideBrandingValue(checked);
mutation.mutate({ hideBranding: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
switchContainerClassName="mt-6"
/>
</div>
);

View File

@ -35,7 +35,7 @@ import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="border-subtle mt-8 space-y-6 rounded-xl border px-4 py-6 sm:px-6">
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-4 py-6 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
@ -109,11 +109,11 @@ const CalendarsView = () => {
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
return data.connectedCalendars.length ? (
<div>
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
<div className="border-subtle mt-8 rounded-t-lg border px-4 py-6 sm:px-6">
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
{t("add_to_calendar")}
</h2>
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
<p className="text-default text-sm leading-tight">{t("add_to_calendar_description")}</p>
</div>
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
<DestinationCalendarSelector
@ -137,15 +137,15 @@ const CalendarsView = () => {
</Button>
</SectionBottomActions>
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
<div className="border-subtle mt-8 rounded-t-lg border px-4 py-6 sm:px-6">
<h4 className="text-emphasis text-base font-semibold leading-5">
{t("check_for_conflicts")}
</h4>
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
</div>
<List
className="border-subtle flex flex-col gap-6 rounded-b-xl border border-t-0 p-6"
className="border-subtle flex flex-col gap-6 rounded-b-lg border border-t-0 p-6"
noBorderTreatment>
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
@ -173,7 +173,7 @@ const CalendarsView = () => {
/>
)}
{item?.error === undefined && item.calendars && (
<ListItem className="flex-col rounded-md">
<ListItem className="flex-col rounded-lg">
<div className="flex w-full flex-1 items-center space-x-3 p-4 rtl:space-x-reverse">
{
// eslint-disable-next-line @next/next/no-img-element

View File

@ -16,7 +16,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
@ -100,7 +100,7 @@ const ConferencingLayout = () => {
}
return (
<AppList
listClassName="rounded-xl rounded-t-none border-t-0"
listClassName="rounded-lg rounded-t-none border-t-0"
handleDisconnect={handleDisconnect}
data={data}
variant="conferencing"

View File

@ -68,7 +68,11 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
await utils.viewer.me.invalidate();
reset(getValues());
showToast(t("settings_updated_successfully"), "success");
update(res);
await update(res);
if (res.locale) {
window.calNewLocale = res.locale;
}
},
onError: () => {
showToast(t("error_updating_settings"), "error");
@ -233,7 +237,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
setIsAllowDynamicBookingChecked(checked);
mutation.mutate({ allowDynamicBooking: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
switchContainerClassName="mt-6"
/>
<SettingsToggle
@ -246,7 +250,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
setIsAllowSEOIndexingChecked(checked);
mutation.mutate({ allowSEOIndexing: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
switchContainerClassName="mt-6"
/>
<SettingsToggle
@ -259,7 +263,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
setIsReceiveMonthlyDigestEmailChecked(checked);
mutation.mutate({ receiveMonthlyDigestEmail: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
switchContainerClassName="mt-6"
/>
</div>
);

View File

@ -50,7 +50,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-4 py-8">
<div className="flex items-center">
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
@ -279,7 +279,7 @@ const ProfileView = () => {
}
/>
<div className="border-subtle mt-6 rounded-xl rounded-b-none border border-b-0 p-6">
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
</div>

View File

@ -1,9 +1,9 @@
import TeamBillingView from "@calcom/features/ee/teams/pages/team-billing-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = TeamBillingView as CalPageWrapper;
import BillingPage from "../../settings/billing/index";
const Page = BillingPage as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -66,8 +66,8 @@ const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"
onCheckedChange={(checked) => {
mutation.mutate({ disableImpersonation: !checked });
}}
switchContainerClassName="rounded-t-none border-t-0"
disabled={mutation.isLoading}
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
/>
</div>
</>

View File

@ -63,7 +63,7 @@ const TwoFactorAuthView = () => {
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
</Badge>
}
switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6"
switchContainerClassName="rounded-t-none border-t-0"
/>
<EnableTwoFactorModal

View File

@ -1,9 +1,9 @@
import TeamBillingView from "@calcom/features/ee/teams/pages/team-billing-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = TeamBillingView as CalPageWrapper;
import BillingPage from "../../billing";
const Page = BillingPage as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -1,3 +1,9 @@
// This route is reachable by
// 1. /team/[slug]
// 2. / (when on org domain e.g. http://calcom.cal.com/. This is through a rewrite from next.config.js)
// Also the getServerSideProps and default export are reused by
// 1. org/[orgSlug]/team/[slug]
// 2. org/[orgSlug]/[user]/[type]
import classNames from "classnames";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@ -5,7 +11,6 @@ import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
@ -13,12 +18,14 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
@ -31,9 +38,17 @@ import Team from "@components/team/screens/Team";
import { ssrInit } from "@server/lib/ssr";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: PageProps) {
export type PageProps = inferSSRProps<typeof getServerSideProps>;
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
function TeamPage({
team,
isUnpublished,
markdownStrippedBio,
isValidOrgDomain,
currentOrgDomain,
}: PageProps) {
useTheme(team.theme);
const routerQuery = useRouterQuery();
const pathname = usePathname();
@ -44,7 +59,6 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
const teamName = team.name || "Nameless Team";
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
const metadata = teamMetadataSchema.parse(team.metadata);
const orgBranding = useOrgBranding();
useEffect(() => {
telemetry.event(
@ -182,8 +196,8 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<Avatar
alt={teamName}
imageSrc={
!!team.parent && !!orgBranding
? `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`
isValidOrgDomain
? `/org/${currentOrgDomain}/avatar.png`
: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`
}
size="lg"
@ -268,26 +282,47 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
context.req.headers.host ?? "",
context.params?.orgSlug
);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
// Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1";
const flags = await getFeatureFlagMap(prisma);
const isOrganizationFeatureEnabled = flags["organizations"];
log.debug("getServerSideProps", {
isOrgProfile,
isOrganizationFeatureEnabled,
isValidOrgDomain,
currentOrgDomain,
});
const team = await getTeamWithMembers({
slug: slugify(slug ?? ""),
orgSlug: currentOrgDomain,
isTeamView: true,
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
isOrgView: isValidOrgDomain && isOrgProfile,
});
if (!isOrgContext && slug) {
const redirect = await getTemporaryOrgRedirect({
slug: slug,
redirectType: RedirectType.Team,
eventTypeSlug: null,
});
if (redirect) {
return redirect;
}
}
const ssr = await ssrInit(context);
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
console.info("gSSP, team/[slug] - ", {
isValidOrgDomain,
currentOrgDomain,
ALLOWED_HOSTNAMES: process.env.ALLOWED_HOSTNAMES,
flags: JSON.stringify(flags),
});
// Taking care of sub-teams and orgs
if (
(!isValidOrgDomain && team?.parent) ||
(!isValidOrgDomain && !!metadata?.isOrganization) ||
flags["organizations"] !== true
!isOrganizationFeatureEnabled
) {
return { notFound: true } as const;
}
@ -354,6 +389,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
trpcState: ssr.dehydrate(),
markdownStrippedBio,
isValidOrgDomain,
currentOrgDomain,
},
} as const;
};

View File

@ -11,12 +11,15 @@ import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/or
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { getTemporaryOrgRedirect } from "../../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type({
@ -75,6 +78,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
context.req.headers.host ?? "",
context.params?.orgSlug
);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
});
if (redirect) {
return redirect;
}
}
const team = await prisma.team.findFirst({
where: {

View File

@ -26,6 +26,7 @@ function Teams() {
CTA={
(!user.organizationId || user.organization.isOrgAdmin) && (
<Button
data-testid="new-team-btn"
variant="fab"
StartIcon={Plus}
type="button"

View File

@ -28,6 +28,7 @@ test.describe("Availablity tests", () => {
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
await page.locator('[data-testid="date-override-mark-unavailable"]').click();
await page.locator('[data-testid="add-override-submit-btn"]').click();
await page.locator('[data-testid="dialog-rejection"]').click();
await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1);
await page.locator('[form="availability-form"][type="submit"]').click();
});

View File

@ -0,0 +1,387 @@
import { loginUser } from "../fixtures/regularBookings";
import { test } from "../lib/fixtures";
test.describe("Booking With Phone Question and Each Other Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test.beforeEach(async ({ page, users, bookingPage }) => {
await loginUser(users);
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
});
test.describe("Booking With Phone Question and Address Question", () => {
test("Phone and Address required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Address question (both required)",
secondQuestion: "address",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Address not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Address question (only phone required)",
secondQuestion: "address",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test.describe("Booking With Phone Question and checkbox group Question", () => {
const bookingOptions = { hasPlaceholder: false, isRequired: true };
test("Phone and checkbox group required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and checkbox group question (both required)",
secondQuestion: "checkbox",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and checkbox group not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and checkbox group question (only phone required)",
secondQuestion: "checkbox",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and checkbox Question", () => {
test("Phone and checkbox required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and checkbox question (both required)",
secondQuestion: "boolean",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and checkbox not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and checkbox (only phone required)",
secondQuestion: "boolean",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Long text Question", () => {
test("Phone and Long text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Long Text question (both required)",
secondQuestion: "textarea",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Long text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Long Text question (only phone required)",
secondQuestion: "textarea",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Multi email Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test("Phone and Multi email required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion(
"multiemail",
"multiemail-test",
"multiemail test",
true,
"multiemail test"
);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Multi Email question (both required)",
secondQuestion: "multiemail",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Multi email not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion(
"multiemail",
"multiemail-test",
"multiemail test",
false,
"multiemail test"
);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Multi Email question (only phone required)",
secondQuestion: "multiemail",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and multiselect Question", () => {
test("Phone and multiselect text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Multi Select question (both required)",
secondQuestion: "multiselect",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and multiselect text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Multi Select question (only phone required)",
secondQuestion: "multiselect",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Number Question", () => {
test("Phone and Number required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Number question (both required)",
secondQuestion: "number",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Number not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Number question (only phone required)",
secondQuestion: "number",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Radio group Question", () => {
test("Phone and Radio group required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Radio question (both required)",
secondQuestion: "radio",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Radio group not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Radio question (only phone required)",
secondQuestion: "radio",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and select Question", () => {
test("Phone and select required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Select question (both required)",
secondQuestion: "select",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and select not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Select question (only phone required)",
secondQuestion: "select",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Short text question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test("Phone and Short text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Text question (both required)",
secondQuestion: "text",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Short text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Text question (only phone required)",
secondQuestion: "text",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
});
});

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