Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d4a3c7a93 | ||
|
|
1c1625d3fc | ||
|
|
75f9f045b8 | ||
|
|
86af383f6c | ||
|
|
84408025ed | ||
|
|
9fba8c548b | ||
|
|
56f3647ec2 | ||
|
|
a5cb3ce573 | ||
|
|
7581f4d641 | ||
|
|
a1d67b256e | ||
|
|
75f9dad16e | ||
|
|
0a0d212373 | ||
|
|
b8794ba807 | ||
|
|
9b8e4ed385 | ||
|
|
997b5d5290 | ||
|
|
d7080b41cb | ||
|
|
32754bd120 | ||
|
|
13fd02d174 | ||
|
|
d6cbac54e0 | ||
|
|
489ffdf49a | ||
|
|
37b22977ee | ||
|
|
848a040162 | ||
|
|
350d84a047 | ||
|
|
9c3cbee363 | ||
|
|
7e2ad3cea9 | ||
|
|
4ef52aa2c6 | ||
|
|
3132c64205 | ||
|
|
f25f8e0909 | ||
|
|
d8961f8eb9 | ||
|
|
9ff7cf650b | ||
|
|
ba4e717b59 | ||
|
|
f326a9a620 | ||
|
|
95be16c358 | ||
|
|
5621507f7e | ||
|
|
bc6d58fd47 | ||
|
|
db29cc1251 | ||
|
|
69888c57b0 | ||
|
|
5c0da23b97 | ||
|
|
5324ec8051 | ||
|
|
b946f89ffb | ||
|
|
3e3581fabc | ||
|
|
05ac339233 | ||
|
|
ef4675ff44 | ||
|
|
47dc85ee1b | ||
|
|
50342e33ed | ||
|
|
eebcb636f1 | ||
|
|
ed2ced899a | ||
|
|
b7ae8c9594 | ||
|
|
fce98ebb13 | ||
|
|
b81b221bf5 | ||
|
|
f9eb335d0b | ||
|
|
d7d7bcd651 | ||
|
|
4c861b9e67 | ||
|
|
2fa704c39b | ||
|
|
9ccdf21d75 | ||
|
|
9d6148da42 | ||
|
|
bf437b7d6b | ||
|
|
3701fcd68b | ||
|
|
2f90dc7cb6 | ||
|
|
829f90b82f | ||
|
|
3539693729 | ||
|
|
868ba4396d | ||
|
|
d06e36a5cd | ||
|
|
f61c9d9bbf | ||
|
|
a2d521e001 | ||
|
|
bf9c881d16 | ||
|
|
6dd0f9ce29 | ||
|
|
d34db7aef6 | ||
|
|
b4b90f2d27 | ||
|
|
3f0380617a | ||
|
|
7143923ea4 | ||
|
|
dfccf4604f | ||
|
|
c12abd75a8 | ||
|
|
2e12f09f05 | ||
|
|
4258da1df5 | ||
|
|
41ef354c7b | ||
|
|
8891641953 | ||
|
|
0150041496 | ||
|
|
13f9e97015 | ||
|
|
3c5901edb8 | ||
|
|
d26bab5880 | ||
|
|
1c451a8d51 | ||
|
|
576261bf88 | ||
|
|
7d944af7f6 | ||
|
|
dfe7056c93 | ||
|
|
b27f01449a | ||
|
|
6dd77d0494 | ||
|
|
f73d38b632 | ||
|
|
a7d8f0c84e | ||
|
|
b0164990c0 | ||
|
|
bad72ba7bf | ||
|
|
1b317a98a4 | ||
|
|
6a22d75caa | ||
|
|
a030861423 | ||
|
|
bd9cd00c04 | ||
|
|
6fd34af6f6 | ||
|
|
1a85f336c5 | ||
|
|
cc5096a54a | ||
|
|
eea7059226 | ||
|
|
63f3247b1b | ||
|
|
7a769c38fb | ||
|
|
34c2565c70 | ||
|
|
a130804f18 | ||
|
|
446e851065 | ||
|
|
f06b4bfb9c | ||
|
|
1896efa3a0 | ||
|
|
62c8603863 | ||
|
|
7d8559b0b0 | ||
|
|
7a48bbe553 | ||
|
|
817e95c246 | ||
|
|
4ac6225a35 | ||
|
|
38d9b93dee | ||
|
|
376b38d4ed | ||
|
|
0e181f6d9f | ||
|
|
4dbb692156 | ||
|
|
0068718d24 | ||
|
|
ac817b3542 | ||
|
|
97f2cffd1f | ||
|
|
b6dea9c161 | ||
|
|
57eb4746ad | ||
|
|
eff9c87964 | ||
|
|
e06c07bfec | ||
|
|
332c00dac2 | ||
|
|
e3523db6bb | ||
|
|
9c0f046b2a | ||
|
|
6e1a717ecf | ||
|
|
b2e098c781 | ||
|
|
3010900358 | ||
|
|
9b043f44a0 | ||
|
|
814674fe40 | ||
|
|
acaa79b0b8 | ||
|
|
781a0f4a7c | ||
|
|
b2028870d0 | ||
|
|
8f70be8861 | ||
|
|
53c8f8c04a | ||
|
|
c4e5d168a5 | ||
|
|
500bfb4d82 | ||
|
|
024b92b9ce | ||
|
|
f23b63cc98 | ||
|
|
edc90e3f27 | ||
|
|
05631d093f | ||
|
|
6000ff4b48 | ||
|
|
cd7de98e3f | ||
|
|
3f51858d27 | ||
|
|
2c498ace5c | ||
|
|
595d781d51 | ||
|
|
3c508568da | ||
|
|
3204d0af34 | ||
|
|
25684f9040 | ||
|
|
bc9aeef710 | ||
|
|
393411a47a | ||
|
|
356117feaf | ||
|
|
1bf68d50e0 | ||
|
|
4a35e85756 | ||
|
|
a4f56bf680 | ||
|
|
a308075bc3 | ||
|
|
efa6d464a3 | ||
|
|
811262d7ee | ||
|
|
99a5dcf96a | ||
|
|
79f092673a | ||
|
|
b285f27d00 | ||
|
|
1fa87ae179 | ||
|
|
f6f4b67bf9 | ||
|
|
8c8550e6f8 | ||
|
|
c6246c9408 | ||
|
|
ccc814eb54 | ||
|
|
2ee0ec11d6 | ||
|
|
3223beb9ac | ||
|
|
c1bcfcfa3d | ||
|
|
83aea7d28c | ||
|
|
a810a538e9 | ||
|
|
1cd0d7b5ca | ||
|
|
4540c79580 | ||
|
|
00d01882d4 | ||
|
|
6ce4122528 | ||
|
|
7ddbcfcdcf | ||
|
|
b1b01ed383 | ||
|
|
6f5d9dc41f | ||
|
|
ac190b89dd | ||
|
|
990441295c | ||
|
|
1e4c20073c | ||
|
|
80353d67a0 | ||
|
|
d55b4b051a | ||
|
|
25d90e3e12 | ||
|
|
1727eb5625 | ||
|
|
58f4224c66 | ||
|
|
5d491e0a12 | ||
|
|
1eef5a56c3 | ||
|
|
d6740503be | ||
|
|
9f647ead46 | ||
|
|
f480afcace | ||
|
|
8d73d15b8a | ||
|
|
e8370ef950 | ||
|
|
e859c8dc58 |
@@ -213,6 +213,10 @@ NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
# use organizations
|
||||
ORGANIZATIONS_ENABLED=
|
||||
|
||||
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
|
||||
# existing organizations based on email domain address
|
||||
ORGANIZATIONS_AUTOLINK=
|
||||
|
||||
# Vercel Config to create subdomains for organizations
|
||||
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
||||
PROJECT_ID_VERCEL=
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Add PRs to project Reviewing PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-PR-to-project:
|
||||
name: Add PRs to project Reviewing PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/11
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - webhookTriggers
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “every 5 minutes” (see https://crontab.guru)
|
||||
- cron: "*/5 * * * *"
|
||||
jobs:
|
||||
cron-webhookTriggers:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/webhookTriggers \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
# Contributing to Cal.com
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
@@ -37,7 +37,7 @@ Contributions are what make the open source community such an amazing place to l
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Core Features (Booking page, availabilty, timezone calculation)
|
||||
Core Features (Booking page, availability, timezone calculation)
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
||||
@@ -132,7 +132,6 @@ If you get errors, be sure to fix them before committing.
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
|
||||
- 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 [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||
- 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.
|
||||
|
||||
@@ -423,7 +423,7 @@ yarn seed-app-store
|
||||
```
|
||||
|
||||
You will need to complete a few more steps to activate Google Calendar App.
|
||||
Make sure to complete section "Obtaining the Google API Credentials". After the do the
|
||||
Make sure to complete section "Obtaining the Google API Credentials". After that do the
|
||||
following
|
||||
|
||||
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
|
||||
@@ -449,8 +449,8 @@ following
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
12. Click "Done".
|
||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||
|
||||
@@ -526,7 +526,7 @@ For example, `Cal.com (support@cal.com)`.
|
||||
3. Copy Account SID to your `.env` file into the `TWILIO_SID` field
|
||||
4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field
|
||||
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
|
||||
6. Add your own sender id to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
||||
6. Add your own sender ID to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
||||
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||
8. Choose any name for the messaging service
|
||||
9. Click 'Add Senders'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
BACKEND_URL=http://localhost:3002/api
|
||||
|
||||
APP_ID=cal-ai
|
||||
APP_URL=http://localhost:3000/apps/cal-ai
|
||||
|
||||
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||
PARSE_KEY=
|
||||
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optionally trace completions at https://smith.langchain.com
|
||||
# LANGCHAIN_TRACING_V2=true
|
||||
# LANGCHAIN_ENDPOINT=
|
||||
# LANGCHAIN_API_KEY=
|
||||
# LANGCHAIN_PROJECT=
|
||||
@@ -0,0 +1,48 @@
|
||||
# Cal.com Email Assistant
|
||||
|
||||
Welcome to the first stage of Cal AI!
|
||||
|
||||
This app lets you chat with your calendar via email:
|
||||
|
||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
||||
|
||||
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||
|
||||
_The AI agent can only choose from a set of tools, without ever seeing your API key._
|
||||
|
||||
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||
|
||||
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development
|
||||
|
||||
If you haven't yet, please run the [root setup](/README.md) steps.
|
||||
|
||||
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
|
||||
|
||||
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||
- A default sender email (for example, `ai@cal.dev`)
|
||||
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
|
||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||
|
||||
### Email Router
|
||||
|
||||
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||
|
||||
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||
|
||||
1. [Sign up for an account](https://signup.sendgrid.com/)
|
||||
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
|
||||
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
|
||||
4. Use the nGrok URL from above as the **Destination URL**.
|
||||
5. Activate "POST the raw, full MIME message".
|
||||
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
|
||||
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||
|
||||
Please feel free to improve any part of this architecture.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,24 @@
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||
|
||||
const plugins = [];
|
||||
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: "https://cal.com/ai",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@t3-oss/env-nextjs": "^0.6.1",
|
||||
"langchain": "^0.0.131",
|
||||
"mailparser": "^3.6.5",
|
||||
"next": "^13.4.6",
|
||||
"supports-color": "8.1.1",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mailparser": "^3.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev -p 3005",
|
||||
"format": "npx prettier . --write",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"start": "next start"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import agent from "../../../utils/agent";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
/**
|
||||
* Launches a LangChain agent to process an incoming email,
|
||||
* then sends the response to the user.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const json = await request.json();
|
||||
|
||||
const { apiKey, userId, message, subject, user, replyTo } = json;
|
||||
|
||||
if ((!message && !subject) || !user) {
|
||||
return new NextResponse("Missing fields", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
|
||||
|
||||
// Send response to user
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||
to: user.email,
|
||||
from: replyTo,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
} catch (error) {
|
||||
return new NextResponse(
|
||||
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ParsedMail, Source } from "mailparser";
|
||||
import { simpleParser } from "mailparser";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||
import getHostFromHeaders from "../../../utils/host";
|
||||
import now from "../../../utils/now";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
/**
|
||||
* Verifies email signature and app authorization,
|
||||
* then hands off to booking agent.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const body = Object.fromEntries(formData);
|
||||
|
||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||
const signature = (body.dkim as string).includes(" : pass");
|
||||
|
||||
const envelope = JSON.parse(body.envelope as string);
|
||||
|
||||
const aiEmail = envelope.to[0];
|
||||
|
||||
// Parse email from mixed MIME type
|
||||
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||
|
||||
if (!parsed.text && !parsed.subject) {
|
||||
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
timeZone: true,
|
||||
credentials: {
|
||||
select: {
|
||||
appId: true,
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { email: envelope.from },
|
||||
});
|
||||
|
||||
// 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}`,
|
||||
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,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
|
||||
|
||||
// User has not installed the app from the app store. Direct them to install it.
|
||||
if (!(credential as { apiKey: string })?.apiKey) {
|
||||
const url = env.APP_URL;
|
||||
|
||||
await sendEmail({
|
||||
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const { apiKey } = credential as { apiKey: string };
|
||||
|
||||
// Pre-fetch data relevant to most bookings.
|
||||
const [eventTypes, availability] = await Promise.all([
|
||||
fetchEventTypes({
|
||||
apiKey,
|
||||
}),
|
||||
fetchAvailability({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
dateFrom: now(user.timeZone),
|
||||
dateTo: now(user.timeZone),
|
||||
}),
|
||||
]);
|
||||
|
||||
if ("error" in availability) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(availability.error);
|
||||
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
if ("error" in eventTypes) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(eventTypes.error);
|
||||
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
const { workingHours } = availability;
|
||||
|
||||
const appHost = getHostFromHeaders(request.headers);
|
||||
|
||||
// Hand off to long-running agent endpoint to handle the email. (don't await)
|
||||
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
message: parsed.text,
|
||||
subject: parsed.subject,
|
||||
replyTo: aiEmail,
|
||||
user: {
|
||||
email: user.email,
|
||||
eventTypes,
|
||||
timeZone: user.timeZone,
|
||||
workingHours,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
return new NextResponse("ok");
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
BACKEND_URL: process.env.BACKEND_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
APP_URL: process.env.APP_URL,
|
||||
PARSE_KEY: process.env.PARSE_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
BACKEND_URL: z.string().url(),
|
||||
APP_ID: z.string().min(1),
|
||||
APP_URL: z.string().url(),
|
||||
PARSE_KEY: z.string().min(1),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
OPENAI_API_KEY: z.string().min(1),
|
||||
SENDGRID_API_KEY: z.string().min(1),
|
||||
DATABASE_URL: z.string().url(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Creates a booking for a user by event type, times, and timezone.
|
||||
*/
|
||||
const createBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
eventTypeId,
|
||||
start,
|
||||
end,
|
||||
timeZone,
|
||||
language,
|
||||
responses,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
eventTypeId: number;
|
||||
start: string;
|
||||
end: string;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
responses: { name?: string; email?: string; location?: string };
|
||||
title?: string;
|
||||
status?: string;
|
||||
}): Promise<string | Error | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
metadata: {},
|
||||
responses,
|
||||
start,
|
||||
timeZone,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong event type id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
error: data.message,
|
||||
};
|
||||
}
|
||||
|
||||
return "Booking created";
|
||||
};
|
||||
|
||||
const createBookingTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description:
|
||||
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
|
||||
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
|
||||
return JSON.stringify(
|
||||
await createBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
responses,
|
||||
start,
|
||||
status,
|
||||
timeZone,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "createBookingIfAvailable",
|
||||
schema: z.object({
|
||||
end: z
|
||||
.string()
|
||||
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||
eventTypeId: z.number(),
|
||||
language: z.string(),
|
||||
responses: z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.describe("External invited user. Not the user making the request."),
|
||||
start: z.string(),
|
||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||
timeZone: z.string(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default createBookingTool;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Cancels a booking for a user by ID with reason.
|
||||
*/
|
||||
const cancelBooking = async ({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
}: {
|
||||
apiKey: string;
|
||||
id: string;
|
||||
reason: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ reason }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking cancelled";
|
||||
};
|
||||
|
||||
const cancelBookingTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Cancel a booking",
|
||||
func: async ({ id, reason }) => {
|
||||
return JSON.stringify(
|
||||
await cancelBooking({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "cancelBooking",
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default cancelBookingTool;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Availability } from "../types/availability";
|
||||
|
||||
/**
|
||||
* Fetches availability for a user by date range and event type.
|
||||
*/
|
||||
export const fetchAvailability = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
}): Promise<Partial<Availability> | { error: string }> => {
|
||||
const params: { [k: string]: string } = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
};
|
||||
|
||||
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return {
|
||||
busy: data.busy,
|
||||
dateRanges: data.dateRanges,
|
||||
timeZone: data.timeZone,
|
||||
workingHours: data.workingHours,
|
||||
};
|
||||
};
|
||||
|
||||
const getAvailabilityTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get availability within range.",
|
||||
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
||||
return JSON.stringify(
|
||||
await fetchAvailability({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getAvailability",
|
||||
schema: z.object({
|
||||
dateFrom: z.string(),
|
||||
dateTo: z.string(),
|
||||
eventTypeId: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getAvailabilityTool;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Booking } from "../types/booking";
|
||||
import { BOOKING_STATUS } from "../types/booking";
|
||||
|
||||
/**
|
||||
* Fetches bookings for a user by date range.
|
||||
*/
|
||||
const fetchBookings = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<Booking[] | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
const bookings: Booking[] = data.bookings
|
||||
.filter((booking: Booking) => {
|
||||
const afterFrom = new Date(booking.startTime).getTime() > new Date(from).getTime();
|
||||
const beforeTo = new Date(booking.endTime).getTime() < new Date(to).getTime();
|
||||
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
|
||||
|
||||
return afterFrom && beforeTo && notCancelled;
|
||||
})
|
||||
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
|
||||
endTime,
|
||||
eventTypeId,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
}));
|
||||
|
||||
return bookings;
|
||||
};
|
||||
|
||||
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get bookings for a user between two dates.",
|
||||
func: async ({ from, to }) => {
|
||||
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||
},
|
||||
name: "getBookings",
|
||||
schema: z.object({
|
||||
from: z.string().describe("ISO 8601 datetime string"),
|
||||
to: z.string().describe("ISO 8601 datetime string"),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getBookingsTool;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { EventType } from "../types/eventType";
|
||||
|
||||
/**
|
||||
* Fetches event types by user ID.
|
||||
*/
|
||||
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return data.event_types.map((eventType: EventType) => ({
|
||||
id: eventType.id,
|
||||
length: eventType.length,
|
||||
title: eventType.title,
|
||||
}));
|
||||
};
|
||||
|
||||
const getEventTypesTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async () => {
|
||||
return JSON.stringify(
|
||||
await fetchEventTypes({
|
||||
apiKey,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getEventTypes",
|
||||
schema: z.object({}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getEventTypesTool;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Edits a booking for a user by booking ID with new times, title, description, or status.
|
||||
*/
|
||||
const editBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
id,
|
||||
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
|
||||
endTime, // Same here: it says end but it's endTime.
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
id: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ description, endTime, startTime, status, title }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking edited";
|
||||
};
|
||||
|
||||
const editBookingTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Edit a booking",
|
||||
func: async ({ description, endTime, id, startTime, status, title }) => {
|
||||
return JSON.stringify(
|
||||
await editBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
description,
|
||||
endTime,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "editBooking",
|
||||
schema: z.object({
|
||||
description: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
id: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default editBookingTool;
|
||||
@@ -0,0 +1,25 @@
|
||||
export type Availability = {
|
||||
busy: {
|
||||
start: string;
|
||||
end: string;
|
||||
title?: string;
|
||||
}[];
|
||||
timeZone: string;
|
||||
dateRanges: {
|
||||
start: string;
|
||||
end: string;
|
||||
}[];
|
||||
workingHours: {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
}[];
|
||||
dateOverrides: {
|
||||
date: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
};
|
||||
currentSeats: number;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export enum BOOKING_STATUS {
|
||||
ACCEPTED = "ACCEPTED",
|
||||
PENDING = "PENDING",
|
||||
CANCELLED = "CANCELLED",
|
||||
REJECTED = "REJECTED",
|
||||
}
|
||||
|
||||
export type Booking = {
|
||||
id: number;
|
||||
userId: number;
|
||||
description: string | null;
|
||||
eventTypeId: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
payment: { id: number; success: boolean; paymentOption: string }[];
|
||||
metadata: object | null;
|
||||
status: BOOKING_STATUS;
|
||||
responses: { email: string; name: string; location: string } | null;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export type EventType = {
|
||||
id: number;
|
||||
title: string;
|
||||
length: number;
|
||||
metadata: object;
|
||||
slug: string;
|
||||
hosts: {
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
}[];
|
||||
hidden: boolean;
|
||||
// ...
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { EventType } from "./eventType";
|
||||
import type { WorkingHours } from "./workingHours";
|
||||
|
||||
export type User = {
|
||||
email: string;
|
||||
timeZone: string;
|
||||
eventTypes: EventType[];
|
||||
workingHours: WorkingHours[];
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type WorkingHours = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
||||
import deleteBooking from "../tools/deleteBooking";
|
||||
import getAvailability from "../tools/getAvailability";
|
||||
import getBookings from "../tools/getBookings";
|
||||
import updateBooking from "../tools/updateBooking";
|
||||
import type { EventType } from "../types/eventType";
|
||||
import type { User } from "../types/user";
|
||||
import type { WorkingHours } from "../types/workingHours";
|
||||
import now from "./now";
|
||||
|
||||
const gptModel = "gpt-4";
|
||||
|
||||
/**
|
||||
* Core of the Cal AI booking agent: a LangChain Agent Executor.
|
||||
* Uses a toolchain to book meetings, list available slots, etc.
|
||||
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||
*/
|
||||
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
|
||||
const tools = [
|
||||
createBookingIfAvailable(apiKey, userId),
|
||||
getAvailability(apiKey, userId),
|
||||
getBookings(apiKey, userId),
|
||||
updateBooking(apiKey, userId),
|
||||
deleteBooking(apiKey),
|
||||
];
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
modelName: gptModel,
|
||||
openAIApiKey: env.OPENAI_API_KEY,
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize the agent executor with arguments.
|
||||
*/
|
||||
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||
agentArgs: {
|
||||
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
|
||||
Make sure your final answers are definitive, complete and well formatted.
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
|
||||
|
||||
The current time in the user's timezone is: ${now(user.timeZone)}
|
||||
The user's time zone is: ${user.timeZone}
|
||||
The user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
||||
.join("\n")}
|
||||
The user's working hours are: ${user.workingHours
|
||||
.map(
|
||||
(w: WorkingHours) =>
|
||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||
w.startTime
|
||||
}, End Time (minutes in UTC): ${w.endTime}`
|
||||
)
|
||||
.join("\n")}
|
||||
`,
|
||||
},
|
||||
agentType: "openai-functions",
|
||||
returnIntermediateSteps: env.NODE_ENV === "development",
|
||||
verbose: env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
const result = await executor.call({ input });
|
||||
const { output } = result;
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export default agent;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
|
||||
return `https://${headers.get("host")}`;
|
||||
};
|
||||
|
||||
export default getHostFromHeaders;
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function now(timeZone: string) {
|
||||
return new Date().toLocaleString("en-US", {
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import mail from "@sendgrid/mail";
|
||||
|
||||
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
||||
|
||||
/**
|
||||
* Simply send an email by address, subject, and body.
|
||||
*/
|
||||
const send = async ({
|
||||
subject,
|
||||
to,
|
||||
from,
|
||||
text,
|
||||
html,
|
||||
}: {
|
||||
subject: string;
|
||||
to: string;
|
||||
from: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}): Promise<boolean> => {
|
||||
mail.setApiKey(sendgridAPIKey);
|
||||
|
||||
const msg = {
|
||||
to,
|
||||
from: {
|
||||
email: from,
|
||||
name: "Cal AI",
|
||||
},
|
||||
text,
|
||||
html,
|
||||
subject,
|
||||
};
|
||||
|
||||
const res = await mail.send(msg);
|
||||
const success = !!res;
|
||||
|
||||
return success;
|
||||
};
|
||||
|
||||
export default send;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Verifies that the request contains the correct parse key.
|
||||
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
|
||||
*/
|
||||
export const verifyParseKey = (url: NextRequest["url"]) => {
|
||||
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
|
||||
|
||||
return verified;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -33,12 +33,14 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
position: true,
|
||||
eventName: true,
|
||||
timeZone: true,
|
||||
schedulingType: true,
|
||||
// START Limit future bookings
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
schedulingType: true,
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
// END Limit future bookings
|
||||
requiresConfirmation: true,
|
||||
disableGuests: true,
|
||||
hideCalendarNotes: true,
|
||||
@@ -51,6 +53,8 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
slotInterval: true,
|
||||
successRedirectUrl: true,
|
||||
locations: true,
|
||||
bookingLimits: true,
|
||||
durationLimits: true,
|
||||
})
|
||||
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
|
||||
.partial()
|
||||
@@ -66,6 +70,7 @@ const schemaEventTypeCreateParams = z
|
||||
recurringEvent: recurringEventInputSchema.optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
})
|
||||
@@ -85,6 +90,7 @@ const schemaEventTypeEditParams = z
|
||||
length: z.number().int().optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
})
|
||||
@@ -125,7 +131,10 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
bookingFields: true,
|
||||
bookingLimits: true,
|
||||
durationLimits: true,
|
||||
}).merge(
|
||||
z.object({
|
||||
locations: z
|
||||
|
||||
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export const membershipCreateBodySchema = Membership.partial({
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
}).transform((v) => ({
|
||||
accepted: false,
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
...v,
|
||||
}));
|
||||
export const membershipCreateBodySchema = Membership.omit({ id: true })
|
||||
.partial({
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
})
|
||||
.transform((v) => ({
|
||||
accepted: false,
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
...v,
|
||||
}));
|
||||
|
||||
export const membershipEditBodySchema = Membership.omit({
|
||||
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryUserEmail = baseApiParams.extend({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const schemaQuerySingleOrMultipleUserEmails = z.object({
|
||||
email: z.union([z.string().email(), z.array(z.string().email())]),
|
||||
});
|
||||
|
||||
export const withValidQueryUserEmail = withValidation({
|
||||
schema: schemaQueryUserEmail,
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
@@ -40,6 +40,6 @@
|
||||
"typescript": "^4.9.4",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ import { defaultResponder } from "@calcom/lib/server";
|
||||
* seatsShowAttendees:
|
||||
* type: boolean
|
||||
* description: 'Share Attendee information in seats'
|
||||
* seatsShowAvailabilityCount:
|
||||
* type: boolean
|
||||
* description: 'Show the number of available seats'
|
||||
* smsReminderNumber:
|
||||
* type: number
|
||||
* description: 'SMS reminder number'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { z } from "zod";
|
||||
|
||||
@@ -146,6 +146,9 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
||||
* seatsShowAttendees:
|
||||
* type: boolean
|
||||
* description: 'Share Attendee information in seats'
|
||||
* seatsShowAvailabilityCount:
|
||||
* type: boolean
|
||||
* description: 'Show the number of available seats'
|
||||
* locations:
|
||||
* type: array
|
||||
* description: A list of all available locations for the event type
|
||||
@@ -202,10 +205,17 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
||||
export async function patchHandler(req: NextApiRequest) {
|
||||
const { prisma, query, body } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
const { hosts = [], ...parsedBody } = schemaEventTypeEditBodyParams.parse(body);
|
||||
const {
|
||||
hosts = [],
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
...parsedBody
|
||||
} = schemaEventTypeEditBodyParams.parse(body);
|
||||
|
||||
const data: Prisma.EventTypeUpdateArgs["data"] = {
|
||||
...parsedBody,
|
||||
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||
};
|
||||
|
||||
if (hosts) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -259,12 +259,19 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
async function postHandler(req: NextApiRequest) {
|
||||
const { userId, isAdmin, prisma, body } = req;
|
||||
|
||||
const { hosts = [], ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||
const {
|
||||
hosts = [],
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
...parsedBody
|
||||
} = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||
|
||||
let data: Prisma.EventTypeCreateArgs["data"] = {
|
||||
...parsedBody,
|
||||
userId,
|
||||
users: { connect: { id: userId } },
|
||||
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||
};
|
||||
|
||||
await checkPermissions(req);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NextApiRequest } from "next";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
import { schemaQuerySingleOrMultipleUserEmails } from "~/lib/validations/shared/queryUserEmail";
|
||||
import { schemaUsersReadPublic } from "~/lib/validations/user";
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,17 @@ import { schemaUsersReadPublic } from "~/lib/validations/user";
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Your API key
|
||||
* - in: query
|
||||
* name: email
|
||||
* required: false
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: email
|
||||
* style: form
|
||||
* explode: true
|
||||
* description: The email address or an array of email addresses to filter by
|
||||
* tags:
|
||||
* - users
|
||||
* responses:
|
||||
@@ -39,6 +51,14 @@ export async function getHandler(req: NextApiRequest) {
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
// If user is not ADMIN, return only his data.
|
||||
if (!isAdmin) where.id = userId;
|
||||
|
||||
if (req.query.email) {
|
||||
const validationResult = schemaQuerySingleOrMultipleUserEmails.parse(req.query);
|
||||
where.email = {
|
||||
in: Array.isArray(validationResult.email) ? validationResult.email : [validationResult.email],
|
||||
};
|
||||
}
|
||||
|
||||
const [total, data] = await prisma.$transaction([
|
||||
prisma.user.count({ where }),
|
||||
prisma.user.findMany({ where, take, skip }),
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
const ns = ["common"];
|
||||
const supportedLngs = ["en", "fr"];
|
||||
const resources = ns.reduce((acc, n) => {
|
||||
supportedLngs.forEach((lng) => {
|
||||
if (!acc[lng]) acc[lng] = {};
|
||||
acc[lng] = {
|
||||
...acc[lng],
|
||||
[n]: require(`../../web/public/static/locales/${lng}/${n}.json`),
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: [],
|
||||
debug: true,
|
||||
fallbackLng: "en",
|
||||
defaultNS: "common",
|
||||
ns,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: { useSuspense: true },
|
||||
resources,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -21,6 +21,14 @@ export const parameters = {
|
||||
push() {},
|
||||
Provider: AppRouterContext.Provider,
|
||||
},
|
||||
globals: {
|
||||
locale: "en",
|
||||
locales: {
|
||||
en: "English",
|
||||
fr: "Français",
|
||||
},
|
||||
},
|
||||
i18n,
|
||||
};
|
||||
|
||||
addDecorator((storyFn) => (
|
||||
|
||||
@@ -18,6 +18,7 @@ export function VariantsTable({
|
||||
const columns = React.Children.toArray(children) as ReactElement<RowProps>[];
|
||||
return (
|
||||
<div
|
||||
id="light-variant"
|
||||
className={classNames(
|
||||
isDark &&
|
||||
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
|
||||
@@ -43,7 +44,7 @@ export function VariantsTable({
|
||||
</table>
|
||||
</div>
|
||||
{!isDark && (
|
||||
<div data-mode="dark" className="dark">
|
||||
<div id="dark-variant" data-mode="dark" className="dark">
|
||||
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
|
||||
{children}
|
||||
</VariantsTable>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Meta } from "@storybook/addon-docs";
|
||||
<a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">
|
||||
Figma
|
||||
</a>{" "}
|
||||
library is avalible for anyone to view and use. If you have any questions or concerns, please reach out to
|
||||
library is available for anyone to view and use. If you have any questions or concerns, please reach out to
|
||||
the design team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -248,10 +248,10 @@
|
||||
--cal-bg-inverted: #f3f4f6;
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #dee9fc;
|
||||
--cal-bg-success: #e2fbe8;
|
||||
--cal-bg-attention: #fceed8;
|
||||
--cal-bg-error: #f9e3e2;
|
||||
--cal-bg-info: #263fa9;
|
||||
--cal-bg-success: #306339;
|
||||
--cal-bg-attention: #8e3b1f;
|
||||
--cal-bg-error: #8c2822;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
/* Borders */
|
||||
@@ -269,10 +269,10 @@
|
||||
--cal-text-inverted: #101010;
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321b;
|
||||
--cal-text-error: #752522;
|
||||
--cal-text-info: #dee9fc;
|
||||
--cal-text-success: #e2fbe8;
|
||||
--cal-text-attention: #fceed8;
|
||||
--cal-text-error: #f9e3e2;
|
||||
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
@@ -80,7 +81,13 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
return (
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
{logo ? (
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||
src={logo}
|
||||
alt={`${title} logo`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
|
||||
|
||||
@@ -116,6 +116,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
type={item.integration.type}
|
||||
isChecked={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
credentialId={cal.credentialId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, TextField } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
|
||||
return (
|
||||
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||
<Label className="mt-4">{t("backup_code")}</Label>
|
||||
|
||||
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
|
||||
|
||||
<TextField
|
||||
id="backup-code"
|
||||
label=""
|
||||
defaultValue=""
|
||||
placeholder="XXXXX-XXXXX"
|
||||
minLength={10} // without dash
|
||||
maxLength={11} // with dash
|
||||
required
|
||||
{...methods.register("backupCode")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, Input } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
export default function TwoFactor({ center = true, autoFocus = true }) {
|
||||
const [value, onChange] = useState("");
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
|
||||
name={`2fa${index + 1}`}
|
||||
inputMode="decimal"
|
||||
{...digit}
|
||||
autoFocus={index === 0}
|
||||
autoFocus={autoFocus && index === 0}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -336,7 +336,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<tr className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||
<td
|
||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||
onClick={onClickTableData}>
|
||||
|
||||
@@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}}
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<DialogFooter>
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<Clock className="m-auto h-6 w-6" />
|
||||
|
||||
@@ -377,6 +377,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_available_seats_count")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
@@ -302,13 +301,7 @@ export const EventSetupTab = (
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={cx(
|
||||
"h-4 w-4",
|
||||
// invert all the icons except app icons
|
||||
eventLocationType.iconUrl &&
|
||||
!eventLocationType.iconUrl.startsWith("/app-store") &&
|
||||
"dark:invert"
|
||||
)}
|
||||
className="h-4 w-4 dark:invert-[.65]"
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { Options } from "react-select";
|
||||
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select } from "@calcom/ui";
|
||||
@@ -18,13 +17,14 @@ interface IUserToValue {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const mapUserToValue = ({ id, name, username, email }: IUserToValue, pendingString: string) => ({
|
||||
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
|
||||
value: `${id || ""}`,
|
||||
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
||||
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
||||
avatar,
|
||||
email,
|
||||
});
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ type Props = {
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
isUpdateMutationLoading?: boolean;
|
||||
availability?: AvailabilityOption;
|
||||
isUserOrganizationAdmin: boolean;
|
||||
};
|
||||
|
||||
function getNavigation(props: {
|
||||
@@ -133,6 +134,7 @@ function EventTypeSingleLayout({
|
||||
isUpdateMutationLoading,
|
||||
formMethods,
|
||||
availability,
|
||||
isUserOrganizationAdmin,
|
||||
}: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
@@ -142,7 +144,8 @@ function EventTypeSingleLayout({
|
||||
const hasPermsToDelete =
|
||||
currentUserMembership?.role !== "MEMBER" ||
|
||||
!currentUserMembership ||
|
||||
eventType.schedulingType === SchedulingType.MANAGED;
|
||||
eventType.schedulingType === SchedulingType.MANAGED ||
|
||||
isUserOrganizationAdmin;
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -53,6 +53,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||
<ul className="p-4">
|
||||
{calendars?.map((calendar, i) => (
|
||||
<CalendarSwitch
|
||||
credentialId={calendar.credentialId}
|
||||
key={calendar.externalId}
|
||||
externalId={calendar.externalId}
|
||||
title={calendar.name || "Nameless Calendar"}
|
||||
|
||||
@@ -22,7 +22,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
|
||||
const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId;
|
||||
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId },
|
||||
{ scheduleId: defaultScheduleId ?? undefined },
|
||||
{
|
||||
enabled: !!scheduleId,
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { FormEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
type FormData = {
|
||||
@@ -99,11 +100,11 @@ const UserProfile = () => {
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && (
|
||||
<Avatar
|
||||
<OrganizationAvatar
|
||||
alt={user.username || "user avatar"}
|
||||
gravatarFallbackMd5={user.emailMd5}
|
||||
size="lg"
|
||||
imageSrc={imageSrc}
|
||||
organizationSlug={user.organization?.slug}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
|
||||
}
|
||||
|
||||
interface DisableTwoFactorValues {
|
||||
backupCode: string;
|
||||
totpCode: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
|
||||
}: DisableTwoFactorAuthModalProps) => {
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useForm<DisableTwoFactorValues>();
|
||||
|
||||
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
|
||||
const resetForm = (clearPassword = true) => {
|
||||
if (clearPassword) form.setValue("password", "");
|
||||
form.setValue("backupCode", "");
|
||||
form.setValue("totpCode", "");
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
@@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||
if (response.status === 200) {
|
||||
setTwoFactorLostAccess(false);
|
||||
resetForm();
|
||||
onDisable();
|
||||
return;
|
||||
}
|
||||
@@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
|
||||
const body = await response.json();
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
}
|
||||
if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
setErrorMessage(t("2fa_required"));
|
||||
}
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(t("incorrect_2fa"));
|
||||
} else if (body.error === ErrorCode.IncorrectBackupCode) {
|
||||
setErrorMessage(t("incorrect_backup_code"));
|
||||
} else if (body.error === ErrorCode.MissingBackupCodes) {
|
||||
setErrorMessage(t("missing_backup_codes"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
@@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
<div className="mb-8">
|
||||
{!disablePassword && (
|
||||
<PasswordField
|
||||
required
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
@@ -85,12 +100,25 @@ const DisableTwoFactorAuthModal = ({
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
)}
|
||||
<TwoFactor center={false} />
|
||||
{twoFactorLostAccess ? (
|
||||
<BackupCode center={false} />
|
||||
) : (
|
||||
<TwoFactor center={false} autoFocus={false} />
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter showDivider className="relative mt-5">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(!twoFactorLostAccess);
|
||||
resetForm(false);
|
||||
}}>
|
||||
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
|
||||
|
||||
enum SetupStep {
|
||||
ConfirmPassword,
|
||||
DisplayBackupCodes,
|
||||
DisplayQrCode,
|
||||
EnterTotpCode,
|
||||
}
|
||||
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||
};
|
||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||
const [password, setPassword] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||
const [dataUri, setDataUri] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
setPassword("");
|
||||
setErrorMessage(null);
|
||||
setStep(SetupStep.ConfirmPassword);
|
||||
};
|
||||
|
||||
async function handleSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setBackupCodes(body.backupCodes);
|
||||
|
||||
// create backup codes download url
|
||||
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
|
||||
type: "text/plain",
|
||||
});
|
||||
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
|
||||
setBackupCodesUrl(URL.createObjectURL(textBlob));
|
||||
|
||||
setDataUri(body.dataUri);
|
||||
setSecret(body.secret);
|
||||
setStep(SetupStep.DisplayQrCode);
|
||||
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
onEnable();
|
||||
setStep(SetupStep.DisplayBackupCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||
<DialogContent
|
||||
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
|
||||
description={setupDescriptions[step]}
|
||||
type="creation">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
<TextField
|
||||
<PasswordField
|
||||
label={t("password")}
|
||||
type="password"
|
||||
name="password"
|
||||
@@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
|
||||
{backupCodes.map((code) => (
|
||||
<div key={code}>{formatBackupCode(code)}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<div className="-mt-4 pb-2">
|
||||
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</div>
|
||||
</WithStep>
|
||||
<DialogFooter className="mt-8" showDivider>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
{step !== SetupStep.DisplayBackupCodes ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
resetState();
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetState();
|
||||
onEnable();
|
||||
}}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-copy"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
|
||||
showToast(t("backup_codes_copied"), "success");
|
||||
}}>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
|
||||
<Button color="primary" data-testid="backup-codes-download">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
</WithStep>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
async disable(password: string, code: string) {
|
||||
async disable(password: string, code: string, backupCode: string) {
|
||||
return fetch("/api/auth/two-factor/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code }),
|
||||
body: JSON.stringify({ password, code, backupCode }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
}),
|
||||
});
|
||||
|
||||
const formMethods = useForm<{
|
||||
username: string;
|
||||
email_address: string;
|
||||
full_name: string;
|
||||
password: string;
|
||||
}>({
|
||||
type formSchemaType = z.infer<typeof formSchema>;
|
||||
|
||||
const formMethods = useForm<formSchemaType>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
@@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
props.onError();
|
||||
};
|
||||
|
||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = formMethods.handleSubmit(async (data) => {
|
||||
props.onSubmit();
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
@@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
||||
onBlur={onBlur}
|
||||
name="username"
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("username", e.target.value);
|
||||
await formMethods.trigger("username");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<TextField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("full_name", e.target.value);
|
||||
await formMethods.trigger("full_name");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
||||
type="text"
|
||||
name="full_name"
|
||||
@@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<EmailField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("email_address", e.target.value);
|
||||
await formMethods.trigger("email_address");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="my-0"
|
||||
name="email_address"
|
||||
/>
|
||||
@@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<PasswordField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("password", e.target.value);
|
||||
await formMethods.trigger("password");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
hintErrors={["caplow", "admin_min", "num"]}
|
||||
name="password"
|
||||
className="my-0"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { components } from "react-select";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
export type LocationOption = {
|
||||
@@ -23,14 +22,7 @@ export type GroupOptionType = GroupBase<LocationOption>;
|
||||
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && (
|
||||
<img
|
||||
src={icon}
|
||||
alt="cover"
|
||||
// invert all the icons except app icons
|
||||
className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/app-store") && "dark:invert")}
|
||||
/>
|
||||
)}
|
||||
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5 dark:invert-[.65]" />}
|
||||
<span className={classNames("text-sm font-medium")}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -57,7 +49,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||
{e.icon && (
|
||||
<img
|
||||
src={e.icon}
|
||||
alt="app-icon"
|
||||
className={classNames(e.icon.includes("-dark") && "dark:invert", "h-5 w-5")}
|
||||
/>
|
||||
)}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,6 @@ import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
@@ -222,19 +221,7 @@ function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function useOrgBrandingValues() {
|
||||
const session = useSession();
|
||||
|
||||
const res = trpc.viewer.organizations.getBrand.useQuery(undefined, {
|
||||
// Only fetch if we have a session to avoid flooding logs with errors
|
||||
enabled: session.status === "authenticated",
|
||||
});
|
||||
|
||||
if (res.status === "loading") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (res.status === "error") return null;
|
||||
|
||||
return res.data;
|
||||
return session?.data?.user.org;
|
||||
}
|
||||
|
||||
function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ function getCspPolicy(nonce: string) {
|
||||
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
|
||||
} app.cal.com;
|
||||
font-src 'self';
|
||||
img-src 'self' ${WEBAPP_URL} https://www.gravatar.com https://img.youtube.com https://eu.ui-avatars.com/api/ data:;
|
||||
img-src 'self' ${WEBAPP_URL} https://img.youtube.com https://eu.ui-avatars.com/api/ data:;
|
||||
connect-src 'self'
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export const defaultAvatarSrc = function ({ email, md5 }: { md5?: string; email?: string }) {
|
||||
if (!email && !md5) return "";
|
||||
|
||||
if (email && !md5) {
|
||||
md5 = crypto.createHash("md5").update(email).digest("hex");
|
||||
}
|
||||
|
||||
return `https://www.gravatar.com/avatar/${md5}?s=160&d=mp&r=PG`;
|
||||
};
|
||||
+47
-33
@@ -14,7 +14,8 @@ const {
|
||||
|
||||
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
||||
|
||||
const isOrganizationsEnabled =
|
||||
process.env.ORGANIZATIONS_ENABLED === "1" || process.env.ORGANIZATIONS_ENABLED === "true";
|
||||
// To be able to use the version in the app without having to import package.json
|
||||
process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
|
||||
|
||||
@@ -226,7 +227,7 @@ const nextConfig = {
|
||||
async rewrites() {
|
||||
const beforeFiles = [
|
||||
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
||||
...(process.env.ORGANIZATIONS_ENABLED
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
@@ -253,6 +254,10 @@ const nextConfig = {
|
||||
source: "/org/:slug",
|
||||
destination: "/team/:slug",
|
||||
},
|
||||
{
|
||||
source: "/org/:orgSlug/avatar.png",
|
||||
destination: "/api/user/avatar?orgSlug=:orgSlug",
|
||||
},
|
||||
{
|
||||
source: "/team/:teamname/avatar.png",
|
||||
destination: "/api/user/avatar?teamname=:teamname",
|
||||
@@ -333,44 +338,46 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
...[
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
headers: [
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/team/:orgSlug",
|
||||
...matcherConfigRootPath,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/team/:orgSlug",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user",
|
||||
...matcherConfigUserRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type",
|
||||
...matcherConfigUserTypeRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type/embed",
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type/embed",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
@@ -447,6 +454,13 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
source: "/support",
|
||||
missing: [
|
||||
{
|
||||
type: "header",
|
||||
key: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
destination: "/event-types?openIntercom=true",
|
||||
permanent: true,
|
||||
},
|
||||
@@ -463,7 +477,7 @@ const nextConfig = {
|
||||
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
|
||||
...(process.env.NODE_ENV === "development" &&
|
||||
// Safer to enable the redirect only when the user is opting to test out organizations
|
||||
process.env.ORGANIZATIONS_ENABLED &&
|
||||
isOrganizationsEnabled &&
|
||||
// Prevent infinite redirect by checking that we aren't already on localhost
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
|
||||
? [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -129,7 +129,7 @@
|
||||
"turndown": "^7.1.1",
|
||||
"uuid": "^8.3.2",
|
||||
"web3": "^1.7.5",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.6",
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function Custom404() {
|
||||
)}`
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isSuccessPage = pathname?.startsWith("/booking");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
@@ -25,7 +26,7 @@ import prisma from "@calcom/prisma";
|
||||
import type { EventType, User } from "@calcom/prisma/client";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
@@ -53,7 +54,6 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
orgSlug: _orgSlug,
|
||||
...query
|
||||
} = useRouterQuery();
|
||||
const nameOrUsername = user.name || user.username || "";
|
||||
|
||||
/*
|
||||
const telemetry = useTelemetry();
|
||||
@@ -97,7 +97,12 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar imageSrc={profile.image} size="xl" alt={profile.name} />
|
||||
<OrganizationAvatar
|
||||
imageSrc={profile.image}
|
||||
size="xl"
|
||||
alt={profile.name}
|
||||
organizationSlug={profile.organizationSlug}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
|
||||
{profile.name}
|
||||
{user.verified && (
|
||||
@@ -219,6 +224,7 @@ export type UserPageProps = {
|
||||
theme: string | null;
|
||||
brandColor: string;
|
||||
darkBrandColor: string;
|
||||
organizationSlug: string | null;
|
||||
allowSEOIndexing: boolean;
|
||||
};
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
||||
@@ -322,6 +328,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
organizationSlug: user.organization?.slug ?? null,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
||||
@@ -11,18 +10,9 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().refine((value) => !value.includes("+"), {
|
||||
message: "String should not contain a plus symbol (+).",
|
||||
}),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(7),
|
||||
language: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).end();
|
||||
@@ -39,19 +29,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const username = slugify(data.username);
|
||||
const userEmail = email.toLowerCase();
|
||||
|
||||
const validationResponse = (
|
||||
incomingEmail: string,
|
||||
validation: { isValid: boolean; email: string | undefined }
|
||||
) => {
|
||||
const { isValid, email } = validation;
|
||||
if (!isValid) {
|
||||
const message: string =
|
||||
email !== incomingEmail ? "Username already taken" : "Email address is already registered";
|
||||
|
||||
return res.status(409).json({ message });
|
||||
}
|
||||
};
|
||||
|
||||
if (!username) {
|
||||
res.status(422).json({ message: "Invalid username" });
|
||||
return;
|
||||
@@ -79,11 +56,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
if (foundToken?.teamId) {
|
||||
const teamUserValidation = await validateUsernameInTeam(username, userEmail, foundToken?.teamId);
|
||||
return validationResponse(userEmail, teamUserValidation);
|
||||
if (!teamUserValidation.isValid) {
|
||||
return res.status(409).json({ message: "Username or email is already taken" });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const userValidation = await validateUsername(username, userEmail);
|
||||
return validationResponse(userEmail, userValidation);
|
||||
if (!userValidation.isValid) {
|
||||
return res.status(409).json({ message: "Username or email is already taken" });
|
||||
}
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
@@ -94,7 +75,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
id: foundToken.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (team) {
|
||||
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
||||
|
||||
|
||||
@@ -43,8 +43,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
|
||||
}
|
||||
}
|
||||
// if user has 2fa
|
||||
if (user.twoFactorEnabled) {
|
||||
|
||||
// if user has 2fa and using backup code
|
||||
if (user.twoFactorEnabled && req.body.backupCode) {
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
if (!user.backupCodes) {
|
||||
return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
|
||||
}
|
||||
|
||||
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) {
|
||||
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
|
||||
}
|
||||
|
||||
// we delete all stored backup codes at the end, no need to do this here
|
||||
|
||||
// if user has 2fa and NOT using backup code, try totp
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!req.body.code) {
|
||||
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
|
||||
// throw new Error(ErrorCode.SecondFactorRequired);
|
||||
@@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: null,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authenticator } from "otplib";
|
||||
import qrcode from "qrcode";
|
||||
@@ -56,11 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
// bytes without updating the sanity checks in the enable and login endpoints.
|
||||
const secret = authenticator.generateSecret(20);
|
||||
|
||||
// generate backup codes with 10 character length
|
||||
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
},
|
||||
@@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const keyUri = authenticator.keyuri(name, "Cal", secret);
|
||||
const dataUri = await qrcode.toDataURL(keyUri);
|
||||
|
||||
return res.json({ secret, keyUri, dataUri });
|
||||
return res.json({ secret, keyUri, dataUri, backupCodes });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
|
||||
const selectedCalendarSelectSchema = z.object({
|
||||
integration: z.string(),
|
||||
externalId: z.string(),
|
||||
credentialId: z.number().optional(),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -37,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const { credentials, ...user } = userWithCredentials;
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { integration, externalId } = selectedCalendarSelectSchema.parse(req.body);
|
||||
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
|
||||
await prisma.selectedCalendar.upsert({
|
||||
where: {
|
||||
userId_integration_externalId: {
|
||||
@@ -50,6 +51,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
userId: user.id,
|
||||
integration,
|
||||
externalId,
|
||||
credentialId,
|
||||
},
|
||||
// already exists
|
||||
update: {},
|
||||
|
||||
@@ -104,7 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
@@ -127,7 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
||||
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
|
||||
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
|
||||
};
|
||||
|
||||
await sendOrganizerRequestReminderEmail(evt);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/webhooks/lib/cron";
|
||||
@@ -7,54 +7,6 @@ import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (IS_PRODUCTION) return res.write("Only for development purposes"), res.end();
|
||||
const t = await getTranslation("en", "common");
|
||||
const language = { translate: t, locale: "en" };
|
||||
|
||||
const evt = {
|
||||
type: "30min",
|
||||
title: "30min between Pro Example and pro@example.com",
|
||||
description: null,
|
||||
additionalNotes: "asdasdas",
|
||||
startTime: "2022-06-03T09:00:00-06:00",
|
||||
endTime: "2022-06-03T09:30:00-06:00",
|
||||
organizer: {
|
||||
name: "Pro Example",
|
||||
email: "pro@example.com",
|
||||
timeZone: "Europe/London",
|
||||
language,
|
||||
},
|
||||
attendees: [
|
||||
{
|
||||
email: "pro@example.com",
|
||||
name: "pro@example.com",
|
||||
timeZone: "America/Chihuahua",
|
||||
language,
|
||||
},
|
||||
],
|
||||
location: "Zoom video",
|
||||
destinationCalendar: null,
|
||||
hideCalendarNotes: false,
|
||||
uid: "xxyPr4cg2xx4XoS2KeMEQy",
|
||||
metadata: {},
|
||||
recurringEvent: null,
|
||||
appsStatus: [
|
||||
{
|
||||
appName: "Outlook Calendar",
|
||||
type: "office365_calendar",
|
||||
success: 1,
|
||||
failures: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
},
|
||||
{
|
||||
appName: "Google Meet",
|
||||
type: "conferencing",
|
||||
success: 0,
|
||||
failures: 1,
|
||||
errors: [],
|
||||
warnings: ["In order to use Google Meet you must set your destination calendar to a Google Calendar"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
res.statusCode = 200;
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", response.headers.get("content-type") as string);
|
||||
res.setHeader("Cache-Control", "s-maxage=86400");
|
||||
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
res.statusCode = 404;
|
||||
|
||||
@@ -34,13 +34,17 @@ const triggerWebhook = async ({
|
||||
booking: {
|
||||
userId: number | undefined;
|
||||
eventTypeId: number | null;
|
||||
eventTypeParentId: number | null | undefined;
|
||||
teamId?: number | null;
|
||||
};
|
||||
}) => {
|
||||
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
|
||||
// Send Webhook call if hooked to BOOKING.RECORDING_READY
|
||||
|
||||
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
|
||||
|
||||
const subscriberOptions = {
|
||||
userId: booking.userId,
|
||||
userId: triggerForUser ? booking.userId : null,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
triggerEvent: eventTrigger,
|
||||
teamId: booking.teamId,
|
||||
@@ -183,6 +187,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
booking: {
|
||||
userId: booking?.user?.id,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
eventTypeParentId: booking.eventType?.parentId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
username: z.string(),
|
||||
teamname: z.string(),
|
||||
orgSlug: z.string(),
|
||||
/**
|
||||
* Allow fetching avatar of a particular organization
|
||||
* Avatars being public, we need not worry about others accessing it.
|
||||
@@ -21,7 +20,7 @@ const querySchema = z
|
||||
.partial();
|
||||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname, orgId } = querySchema.parse(req.query);
|
||||
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
@@ -61,7 +60,23 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
org,
|
||||
name: teamname,
|
||||
email: null,
|
||||
avatar: team?.logo || getPlaceholderAvatar(null, teamname),
|
||||
avatar: getPlaceholderAvatar(team?.logo, teamname),
|
||||
};
|
||||
}
|
||||
if (orgSlug) {
|
||||
const org = await prisma.team.findFirst({
|
||||
where: getSlugOrRequestedSlug(orgSlug),
|
||||
select: {
|
||||
slug: true,
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
org: org?.slug,
|
||||
name: org?.name,
|
||||
email: null,
|
||||
avatar: getPlaceholderAvatar(org?.logo, org?.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -69,18 +84,15 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const identity = await getIdentityData(req);
|
||||
const img = identity?.avatar;
|
||||
// We cache for one day
|
||||
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
|
||||
// If image isn't set or links to this route itself, use default avatar
|
||||
if (!img) {
|
||||
if (identity?.org) {
|
||||
res.setHeader("x-cal-org", identity.org);
|
||||
}
|
||||
res.writeHead(302, {
|
||||
Location: defaultAvatarSrc({
|
||||
md5: crypto
|
||||
.createHash("md5")
|
||||
.update(identity?.email || "guest@example.com")
|
||||
.digest("hex"),
|
||||
}),
|
||||
Location: AVATAR_FALLBACK,
|
||||
});
|
||||
|
||||
return res.end();
|
||||
|
||||
@@ -12,7 +12,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import type { HorizontalTabItemProps } from "@calcom/ui";
|
||||
import { AllApps, AppStoreCategories, HorizontalTabs, TextField, PopularAppsSlider } from "@calcom/ui";
|
||||
import {
|
||||
AllApps,
|
||||
AppStoreCategories,
|
||||
HorizontalTabs,
|
||||
TextField,
|
||||
PopularAppsSlider,
|
||||
RecentAppsSlider,
|
||||
} from "@calcom/ui";
|
||||
import { Search } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -81,6 +88,7 @@ export default function Apps({
|
||||
<>
|
||||
<AppStoreCategories categories={categories} />
|
||||
<PopularAppsSlider items={appStore} />
|
||||
<RecentAppsSlider items={appStore} />
|
||||
</>
|
||||
)}
|
||||
<AllApps
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||
import { ArrowLeft } from "@calcom/ui/components/icon";
|
||||
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
@@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
|
||||
@@ -39,6 +40,7 @@ interface LoginValues {
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode: string;
|
||||
backupCode: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
export default function Login({
|
||||
@@ -50,6 +52,8 @@ export default function Login({
|
||||
totpEmail,
|
||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const isTeamInvite = searchParams.get("teamInvite");
|
||||
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const formSchema = z
|
||||
@@ -65,6 +69,7 @@ export default function Login({
|
||||
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
|
||||
const { register, formState } = methods;
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
@@ -92,21 +97,43 @@ export default function Login({
|
||||
callbackUrl = safeCallbackUrl || "";
|
||||
|
||||
const LoginFooter = (
|
||||
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
||||
<a
|
||||
href={callbackUrl !== "" ? `${WEBSITE_URL}/signup?callbackUrl=${callbackUrl}` : `${WEBSITE_URL}/signup`}
|
||||
className="text-brand-500 font-medium">
|
||||
{t("dont_have_an_account")}
|
||||
</a>
|
||||
);
|
||||
|
||||
const TwoFactorFooter = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTwoFactorRequired(false);
|
||||
methods.setValue("totpCode", "");
|
||||
}}
|
||||
StartIcon={ArrowLeft}
|
||||
color="minimal">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (twoFactorLostAccess) {
|
||||
setTwoFactorLostAccess(false);
|
||||
methods.setValue("backupCode", "");
|
||||
} else {
|
||||
setTwoFactorRequired(false);
|
||||
methods.setValue("totpCode", "");
|
||||
}
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
StartIcon={ArrowLeft}
|
||||
color="minimal">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
{!twoFactorLostAccess ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(true);
|
||||
setErrorMessage(null);
|
||||
methods.setValue("totpCode", "");
|
||||
}}
|
||||
StartIcon={Lock}
|
||||
color="minimal">
|
||||
{t("lost_access")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const ExternalTotpFooter = (
|
||||
@@ -130,8 +157,9 @@ export default function Login({
|
||||
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
|
||||
// we're logged in! let's do a hard refresh to the desired url
|
||||
else if (!res.error) router.push(callbackUrl);
|
||||
// reveal two factor input if required
|
||||
else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
|
||||
else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
|
||||
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
|
||||
// fallback if error not found
|
||||
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
||||
};
|
||||
@@ -160,6 +188,9 @@ export default function Login({
|
||||
? LoginFooter
|
||||
: null
|
||||
}>
|
||||
{isTeamInvite && (
|
||||
<Alert severity="info" message={t("signin_or_signup_to_accept_invite")} className="mb-4 mt-4" />
|
||||
)}
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
|
||||
<div>
|
||||
@@ -194,7 +225,7 @@ export default function Login({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{twoFactorRequired && <TwoFactor center />}
|
||||
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<Button
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -17,11 +18,6 @@ import {
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
Label,
|
||||
showToast,
|
||||
@@ -32,7 +28,7 @@ import {
|
||||
Tooltip,
|
||||
VerticalDivider,
|
||||
} from "@calcom/ui";
|
||||
import { Info, MoreHorizontal, Plus, Trash } from "@calcom/ui/components/icon";
|
||||
import { Info, MoreVertical, ArrowLeft, Plus, Trash } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
@@ -95,7 +91,7 @@ export default function Availability() {
|
||||
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
|
||||
const fromEventType = searchParams?.get("fromEventType");
|
||||
const { timeFormat } = me.data || { timeFormat: null };
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [openSidebar, setOpenSidebar] = useState(false);
|
||||
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId },
|
||||
{
|
||||
@@ -225,33 +221,60 @@ export default function Availability() {
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<VerticalDivider className="hidden sm:inline" />
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="sm:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent style={{ minWidth: "200px" }}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon={Trash}
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_schedule")}
|
||||
confirmBtnText={t("delete")}
|
||||
loadingText={t("delete")}
|
||||
onConfirm={() => {
|
||||
schedule !== undefined && deleteMutation.mutate({ scheduleId: schedule.id });
|
||||
}}>
|
||||
{t("delete_schedule_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex h-9 flex-row items-center justify-between px-4 py-2 hover:bg-gray-100">
|
||||
<div
|
||||
className={classNames(
|
||||
openSidebar
|
||||
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
|
||||
: ""
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
|
||||
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
|
||||
)}>
|
||||
<div className="flex flex-row items-center pt-5">
|
||||
<Button StartIcon={ArrowLeft} color="minimal" onClick={() => setOpenSidebar(false)} />
|
||||
<p className="-ml-2">{t("availability_settings")}</p>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
StartIcon={Trash}
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
aria-label={t("delete")}
|
||||
className="ml-16 inline"
|
||||
disabled={schedule?.isLastSchedule}
|
||||
tooltip={schedule?.isLastSchedule ? t("requires_at_least_one_schedule") : t("delete")}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_schedule")}
|
||||
confirmBtnText={t("delete")}
|
||||
loadingText={t("delete")}
|
||||
onConfirm={() => {
|
||||
scheduleId && deleteMutation.mutate({ scheduleId });
|
||||
setOpenSidebar(false);
|
||||
}}>
|
||||
{t("delete_schedule_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
<Skeleton as={Label}>{t("name")}</Skeleton>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<input
|
||||
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:border-neutral-300 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-9 flex-row-reverse items-center justify-end gap-3 px-2">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
@@ -267,9 +290,44 @@ export default function Availability() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
|
||||
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
|
||||
{t("timezone")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="timeZone"
|
||||
render={({ field: { onChange, value } }) =>
|
||||
value ? (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
) : (
|
||||
<SelectSkeletonLoader className="mt-1 w-72" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle my-7" />
|
||||
<div className="rounded-md md:block">
|
||||
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
|
||||
{t("something_doesnt_look_right")}
|
||||
</Skeleton>
|
||||
<div className="mt-3 flex">
|
||||
<Skeleton as={Button} href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-default border-l-2" />
|
||||
<Button
|
||||
className="ml-4 lg:ml-0"
|
||||
@@ -278,6 +336,13 @@ export default function Availability() {
|
||||
loading={updateMutation.isLoading}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-3 sm:hidden"
|
||||
StartIcon={MoreVertical}
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
onClick={() => setOpenSidebar(true)}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="mt-4 w-full md:mt-0">
|
||||
@@ -313,7 +378,7 @@ export default function Availability() {
|
||||
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
|
||||
@@ -335,7 +400,7 @@ export default function Availability() {
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle my-6 mr-8" />
|
||||
<div className="hidden rounded-md md:block">
|
||||
<div className="rounded-md">
|
||||
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
|
||||
{t("something_doesnt_look_right")}
|
||||
</Skeleton>
|
||||
|
||||
@@ -917,6 +917,7 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
},
|
||||
@@ -940,6 +941,7 @@ const handleSeatsEventTypeOnBooking = async (
|
||||
eventType: {
|
||||
seatsPerTimeSlot?: number | null;
|
||||
seatsShowAttendees: boolean | null;
|
||||
seatsShowAvailabilityCount: boolean | null;
|
||||
[x: string | number | symbol]: unknown;
|
||||
},
|
||||
bookingInfo: Partial<
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function Bookings() {
|
||||
)}
|
||||
<div className="pt-2 xl:pt-0">
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<table data-testid={`${status}-bookings`} className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -113,6 +113,7 @@ export type FormValues = {
|
||||
periodDates: { startDate: Date; endDate: Date };
|
||||
seatsPerTimeSlot: number | null;
|
||||
seatsShowAttendees: boolean | null;
|
||||
seatsShowAvailabilityCount: boolean | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
minimumBookingNotice: number;
|
||||
minimumBookingNoticeInDurationType: number;
|
||||
@@ -183,12 +184,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
created: true,
|
||||
}))
|
||||
);
|
||||
showToast(
|
||||
t("event_type_updated_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
showToast(t("event_type_updated_successfully"), "success");
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
@@ -365,6 +361,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
afterBufferTime,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
seatsShowAvailabilityCount,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
recurringEvent,
|
||||
@@ -381,9 +378,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
bookerLayouts,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
multipleDurationEnabled,
|
||||
length,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
if (!Number(length)) throw new Error(t("event_setup_length_error"));
|
||||
|
||||
if (bookingLimits) {
|
||||
const isValid = validateIntervalLimitOrder(bookingLimits);
|
||||
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
|
||||
@@ -401,7 +401,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
if (metadata?.multipleDuration.length < 1) {
|
||||
throw new Error(t("event_setup_multiple_duration_error"));
|
||||
} else {
|
||||
if (!input.length && !metadata?.multipleDuration?.includes(input.length)) {
|
||||
if (!length && !metadata?.multipleDuration?.includes(length)) {
|
||||
throw new Error(t("event_setup_multiple_duration_default_error"));
|
||||
}
|
||||
}
|
||||
@@ -411,9 +411,11 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
throw new Error(t("seats_and_no_show_fee_error"));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { availability, ...rest } = input;
|
||||
updateMutation.mutate({
|
||||
...rest,
|
||||
length,
|
||||
locations,
|
||||
recurringEvent,
|
||||
periodStartDate: periodDates.startDate,
|
||||
@@ -426,6 +428,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
durationLimits,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
seatsShowAvailabilityCount,
|
||||
metadata,
|
||||
customInputs,
|
||||
children,
|
||||
@@ -447,7 +450,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
isUpdateMutationLoading={updateMutation.isLoading}
|
||||
formMethods={formMethods}
|
||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
currentUserMembership={currentUserMembership}>
|
||||
currentUserMembership={currentUserMembership}
|
||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||
<Form
|
||||
form={formMethods}
|
||||
id="event-type-form"
|
||||
@@ -459,6 +463,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
afterBufferTime,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
seatsShowAvailabilityCount,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
recurringEvent,
|
||||
@@ -470,9 +475,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
seatsPerTimeSlotEnabled,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
multipleDurationEnabled,
|
||||
length,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
if (!Number(length)) throw new Error(t("event_setup_length_error"));
|
||||
|
||||
if (bookingLimits) {
|
||||
const isValid = validateIntervalLimitOrder(bookingLimits);
|
||||
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
|
||||
@@ -490,14 +498,16 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
if (metadata?.multipleDuration.length < 1) {
|
||||
throw new Error(t("event_setup_multiple_duration_error"));
|
||||
} else {
|
||||
if (!input.length && !metadata?.multipleDuration?.includes(input.length)) {
|
||||
if (!length && !metadata?.multipleDuration?.includes(length)) {
|
||||
throw new Error(t("event_setup_multiple_duration_default_error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { availability, ...rest } = input;
|
||||
updateMutation.mutate({
|
||||
...rest,
|
||||
length,
|
||||
locations,
|
||||
recurringEvent,
|
||||
periodStartDate: periodDates.startDate,
|
||||
@@ -510,6 +520,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
durationLimits,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
seatsShowAvailabilityCount,
|
||||
metadata,
|
||||
customInputs,
|
||||
});
|
||||
|
||||
@@ -52,10 +52,9 @@ import {
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
ArrowButton,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Clipboard,
|
||||
Code,
|
||||
Copy,
|
||||
@@ -109,8 +108,10 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
const orgBranding = useOrgBranding();
|
||||
const tabs = eventTypeGroups.map((item) => ({
|
||||
name: item.profile.name ?? "",
|
||||
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types",
|
||||
avatar: item.profile.image ?? `${orgBranding?.fullDomain ?? WEBAPP_URL}/${item.profile.slug}/avatar.png`,
|
||||
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types?noTeam",
|
||||
avatar: orgBranding
|
||||
? `${orgBranding.fullDomain}${item.teamId ? "/team" : ""}/${item.profile.slug}/avatar.png`
|
||||
: item.profile.image ?? `${WEBAPP_URL + (item.teamId && "/team")}/${item.profile.slug}/avatar.png`,
|
||||
}));
|
||||
const { data } = useTypedQuery(querySchema);
|
||||
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
|
||||
@@ -118,13 +119,15 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
return (
|
||||
<div>
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
{events.length && (
|
||||
{events.length > 0 ? (
|
||||
<EventTypeList
|
||||
types={events[0].eventTypes}
|
||||
group={events[0]}
|
||||
groupIndex={0}
|
||||
readOnly={events[0].metadata.readOnly}
|
||||
/>
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -359,6 +362,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!types.length) {
|
||||
return group.teamId ? <EmptyEventTypeList group={group} /> : <CreateFirstEventTypeView />;
|
||||
}
|
||||
|
||||
const firstItem = types[0];
|
||||
const lastItem = types[types.length - 1];
|
||||
const isManagedEventPrefix = () => {
|
||||
@@ -378,19 +385,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<div className="hover:bg-muted flex w-full items-center justify-between">
|
||||
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
|
||||
{!(firstItem && firstItem.id === type.id) && (
|
||||
<button
|
||||
className="bg-default text-muted hover:text-emphasis border-default hover:border-emphasis invisible absolute left-[5px] -ml-4 -mt-4 mb-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={() => moveEventType(index, -1)}>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === type.id) && (
|
||||
<button
|
||||
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-[5px] -ml-4 mt-8 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={() => moveEventType(index, 1)}>
|
||||
<ArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
@@ -400,23 +399,27 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
className="relative right-3 top-1"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={type.users.map(
|
||||
(organizer: { name: string | null; username: string | null }) => ({
|
||||
alt: organizer.name || "",
|
||||
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
organizer.username
|
||||
}/avatar.png`,
|
||||
title: organizer.name || "",
|
||||
})
|
||||
)}
|
||||
items={
|
||||
type?.users
|
||||
? type.users.map(
|
||||
(organizer: { name: string | null; username: string | null }) => ({
|
||||
alt: organizer.name || "",
|
||||
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
organizer.username
|
||||
}/avatar.png`,
|
||||
title: organizer.name || "",
|
||||
})
|
||||
)
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isManagedEventType && (
|
||||
{isManagedEventType && type?.children && type.children?.length > 0 && (
|
||||
<AvatarGroup
|
||||
className="relative right-3 top-1"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={type.children
|
||||
items={type?.children
|
||||
.flatMap((ch) => ch.users)
|
||||
.map((user: Pick<User, "name" | "username">) => ({
|
||||
alt: user.name || "",
|
||||
@@ -671,6 +674,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
title={t(`delete${isManagedEventPrefix()}_event_type`)}
|
||||
confirmBtnText={t(`confirm_delete_event_type`)}
|
||||
loadingText={t(`confirm_delete_event_type`)}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
deleteEventTypeHandler(deleteDialogTypeId);
|
||||
@@ -711,12 +715,17 @@ const EventTypeListHeading = ({
|
||||
},
|
||||
});
|
||||
const bookerUrl = useBookerUrl();
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center space-x-2">
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
|
||||
imageSrc={`${orgBranding?.fullDomain ?? WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
|
||||
imageSrc={
|
||||
orgBranding?.fullDomain
|
||||
? `${orgBranding.fullDomain}${teamId ? "/team" : ""}/${profile.slug}/avatar.png`
|
||||
: profile.image
|
||||
}
|
||||
size="md"
|
||||
className="mt-1 inline-flex justify-center"
|
||||
/>
|
||||
@@ -763,6 +772,12 @@ const CreateFirstEventTypeView = () => {
|
||||
Icon={LinkIcon}
|
||||
headline={t("new_event_type_heading")}
|
||||
description={t("new_event_type_description")}
|
||||
className="mb-16"
|
||||
buttonRaw={
|
||||
<Button href="?dialog=new" variant="button">
|
||||
{t("create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -830,6 +845,25 @@ const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<EmptyScreen
|
||||
headline={t("team_no_event_types")}
|
||||
buttonRaw={
|
||||
<Button
|
||||
href={`?dialog=new&eventPage=${group.profile.slug}&teamId=${group.teamId}`}
|
||||
variant="button"
|
||||
className="mt-5">
|
||||
{t("create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Main = ({
|
||||
status,
|
||||
errorMessage,
|
||||
@@ -863,7 +897,7 @@ const Main = ({
|
||||
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
|
||||
) : (
|
||||
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
|
||||
<div className="flex flex-col" key={group.profile.slug}>
|
||||
<div className="mt-4 flex flex-col" key={group.profile.slug}>
|
||||
<EventTypeListHeading
|
||||
profile={group.profile}
|
||||
membershipCount={group.metadata.membershipCount}
|
||||
@@ -871,12 +905,18 @@ const Main = ({
|
||||
orgSlug={orgBranding?.slug}
|
||||
/>
|
||||
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
/>
|
||||
{group.eventTypes.length ? (
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
/>
|
||||
) : group.teamId ? (
|
||||
<EmptyEventTypeList group={group} />
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -182,6 +182,7 @@ const CalendarsView = () => {
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
credentialId={cal.credentialId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -14,10 +15,10 @@ import turndown from "@calcom/lib/turndownService";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -77,26 +78,25 @@ type FormValues = {
|
||||
const ProfileView = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: _session, update } = useSession();
|
||||
const { update } = useSession();
|
||||
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
await update(res);
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
if (res.signOutUser && tempFormValues) {
|
||||
if (res.passwordReset) {
|
||||
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
|
||||
// sign out the user to avoid unauthorized access error
|
||||
await signOut({ callbackUrl: "/auth/logout?passReset=true" });
|
||||
} else {
|
||||
// sign out the user to avoid unauthorized access error
|
||||
await signOut({ callbackUrl: "/auth/logout?emailChange=true" });
|
||||
}
|
||||
|
||||
// signout user only in case of password reset
|
||||
if (res.signOutUser && tempFormValues && res.passwordReset) {
|
||||
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
|
||||
await signOut({ callbackUrl: "/auth/logout?passReset=true" });
|
||||
} else {
|
||||
utils.viewer.me.invalidate();
|
||||
utils.viewer.avatar.invalidate();
|
||||
utils.viewer.shouldVerifyEmail.invalidate();
|
||||
}
|
||||
utils.viewer.me.invalidate();
|
||||
utils.viewer.avatar.invalidate();
|
||||
|
||||
setConfirmAuthEmailChangeWarningDialogOpen(false);
|
||||
update(res);
|
||||
setTempFormValues(null);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -224,6 +224,7 @@ const ProfileView = () => {
|
||||
key={JSON.stringify(defaultValues)}
|
||||
defaultValues={defaultValues}
|
||||
isLoading={updateProfileMutation.isLoading}
|
||||
userOrganization={user.organization}
|
||||
onSubmit={(values) => {
|
||||
if (values.email !== user.email && isCALIdentityProvider) {
|
||||
setTempFormValues(values);
|
||||
@@ -325,7 +326,10 @@ const ProfileView = () => {
|
||||
{confirmPasswordErrorMessage && <Alert severity="error" title={confirmPasswordErrorMessage} />}
|
||||
</div>
|
||||
<DialogFooter showDivider>
|
||||
<Button color="primary" onClick={(e) => onConfirmPassword(e)}>
|
||||
<Button
|
||||
color="primary"
|
||||
loading={confirmPasswordMutation.isLoading}
|
||||
onClick={(e) => onConfirmPassword(e)}>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
<DialogClose />
|
||||
@@ -345,7 +349,7 @@ const ProfileView = () => {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={updateProfileMutation.isLoading}
|
||||
loading={updateProfileMutation.isLoading}
|
||||
onClick={(e) => onConfirmAuthEmailChange(e)}>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
@@ -362,11 +366,13 @@ const ProfileForm = ({
|
||||
onSubmit,
|
||||
extraField,
|
||||
isLoading = false,
|
||||
userOrganization,
|
||||
}: {
|
||||
defaultValues: FormValues;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
extraField?: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
@@ -404,7 +410,12 @@ const ProfileForm = ({
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" />
|
||||
<OrganizationAvatar
|
||||
alt={formMethods.getValues("username")}
|
||||
imageSrc={value}
|
||||
size="lg"
|
||||
organizationSlug={userOrganization.slug}
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
|
||||
+26
-17
@@ -13,10 +13,12 @@ import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
|
||||
|
||||
@@ -25,14 +27,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
|
||||
import { ssrInit } from "../server/lib/ssr";
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().refine((value) => !value.includes("+"), {
|
||||
message: "String should not contain a plus symbol (+).",
|
||||
}),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(7),
|
||||
language: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
const signupSchema = apiSignupSchema.extend({
|
||||
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
|
||||
});
|
||||
|
||||
@@ -40,12 +35,30 @@ type FormValues = z.infer<typeof signupSchema>;
|
||||
|
||||
type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
const getSafeCallbackUrl = (url: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
let callbackUrl = url;
|
||||
|
||||
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
|
||||
|
||||
// If not absolute URL, make it absolute
|
||||
if (!/^https?:\/\//.test(callbackUrl)) {
|
||||
callbackUrl = `${WEBAPP_URL}/${callbackUrl}`;
|
||||
}
|
||||
|
||||
const safeCallbackUrl = getSafeRedirectUrl(callbackUrl);
|
||||
|
||||
return safeCallbackUrl;
|
||||
};
|
||||
|
||||
export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const telemetry = useTelemetry();
|
||||
const { t, i18n } = useLocale();
|
||||
const flags = useFlagMap();
|
||||
const methods = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(signupSchema),
|
||||
defaultValues: prepopulateFormValues,
|
||||
});
|
||||
@@ -60,6 +73,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
throw new Error(err.message);
|
||||
}
|
||||
};
|
||||
const callbackUrl = getSafeCallbackUrl(searchParams.get("callbackUrl"));
|
||||
|
||||
const signUp: SubmitHandler<FormValues> = async (data) => {
|
||||
await fetch("/api/auth/signup", {
|
||||
@@ -77,13 +91,10 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
.then(async () => {
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
|
||||
|
||||
await signIn<"credentials">("credentials", {
|
||||
...data,
|
||||
callbackUrl: `${
|
||||
searchParams?.get("callbackUrl")
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: `${WEBAPP_URL}/${verifyOrGettingStarted}`
|
||||
}?from=signup`,
|
||||
callbackUrl: `${callbackUrl ? callbackUrl : `${WEBAPP_URL}/${verifyOrGettingStarted}`}?from=signup`,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -131,7 +142,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
<TextField
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? getOrgFullDomain(orgSlug, { protocol: true })
|
||||
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
}
|
||||
{...register("username")}
|
||||
@@ -162,9 +173,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
className="w-full justify-center"
|
||||
onClick={() =>
|
||||
signIn("Cal.com", {
|
||||
callbackUrl: searchParams?.get("callbackUrl")
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: `${WEBAPP_URL}/getting-started`,
|
||||
callbackUrl: callbackUrl ? callbackUrl : `${WEBAPP_URL}/getting-started`,
|
||||
})
|
||||
}>
|
||||
{t("login_instead")}
|
||||
|
||||
@@ -153,11 +153,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
) : (
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
||||
{" " + t("org_no_teams_yet")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("org_no_teams_yet_description")}</p>
|
||||
<div className="text-muted p-8 text-center">
|
||||
<h2 className="font-cal text-emphasis mb-2 text-3xl">{" " + t("org_no_teams_yet")}</h2>
|
||||
<p className="text-emphasis mx-auto max-w-md">{t("org_no_teams_yet_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,19 +197,27 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
<SubTeams />
|
||||
) : (
|
||||
<>
|
||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||
{(showMembers.isOn || !team.eventTypes.length) &&
|
||||
(team.isPrivate ? (
|
||||
<div className="w-full text-center">
|
||||
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_team_members")}</h2>
|
||||
</div>
|
||||
) : (
|
||||
<Team team={team} />
|
||||
))}
|
||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||
<div className="mx-auto max-w-3xl ">
|
||||
<EventTypes />
|
||||
|
||||
{!team.hideBookATeamMember && (
|
||||
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
|
||||
{!team.hideBookATeamMember && !team.isPrivate && (
|
||||
<div>
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="border-subtle w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
|
||||
<span className="dark:bg-darkgray-50 bg-subtle text-subtle px-2 text-sm">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
@@ -41,6 +43,21 @@ function Teams() {
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
await ssr.viewer.me.prefetch();
|
||||
const session = await getServerSession({ req: context.req, res: context.res });
|
||||
const token = context.query?.token;
|
||||
const resolvedUrl = context.resolvedUrl;
|
||||
|
||||
const callbackUrl = token ? getSafeRedirectUrl(`${WEBAPP_URL}${resolvedUrl}`) : null;
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}&teamInvite=true` : "/auth/login",
|
||||
permanent: false,
|
||||
},
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
return { props: { trpcState: ssr.dehydrate() } };
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
|
||||
));
|
||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\..*`;
|
||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
|
||||
|
||||
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
|
||||
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
|
||||
|
||||
@@ -158,9 +158,10 @@ test.describe("pro user", () => {
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
additionalGuests.forEach(async (email) => {
|
||||
const promises = additionalGuests.map(async (email) => {
|
||||
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
});
|
||||
|
||||
test("Time slots should be reserved when selected", async ({ context, page }) => {
|
||||
@@ -267,3 +268,60 @@ test.describe("prefill", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking on different layouts", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await page.goto(`/${user.username}`);
|
||||
});
|
||||
|
||||
test("Book on week layout", async ({ page }) => {
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await page.click('[data-testid="toggle-group-item-week_view"]');
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
await page.locator('[data-testid="calendar-empty-cell"]').nth(0).click();
|
||||
|
||||
// Fill what is this meeting about? name email and notes
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
await page.locator('[name="notes"]').fill("Test notes");
|
||||
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
|
||||
// expect page to be booking page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Book on column layout", async ({ page }) => {
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await page.click('[data-testid="toggle-group-item-column_view"]');
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
|
||||
// Fill what is this meeting about? name email and notes
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
await page.locator('[name="notes"]').fill("Test notes");
|
||||
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
|
||||
// expect page to be booking page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,21 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { uuid } from "short-uuid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewSeatedEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
createUserWithSeatedEventAndAttendees,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
|
||||
const slug = "seats";
|
||||
const user = await users.create({
|
||||
eventTypes: [
|
||||
{
|
||||
title: "Seated event",
|
||||
slug,
|
||||
seatsPerTimeSlot: 10,
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
disableGuests: true, // should always be true for seated events
|
||||
},
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
|
||||
return { user, eventType };
|
||||
}
|
||||
|
||||
async function createUserWithSeatedEventAndAttendees(
|
||||
fixtures: Pick<Fixtures, "users" | "bookings">,
|
||||
attendees: Prisma.AttendeeCreateManyBookingInput[]
|
||||
) {
|
||||
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
|
||||
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
// startTime with 1 day from now and endTime half hour after
|
||||
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: attendees,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { user, eventType, booking };
|
||||
}
|
||||
|
||||
test.describe("Booking with Seats", () => {
|
||||
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
|
||||
const user = await users.create({ name: "Seated event" });
|
||||
@@ -64,7 +25,7 @@ test.describe("Booking with Seats", () => {
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
const eventTitle = "My 2-seated event";
|
||||
await createNewSeatedEventType(page, { eventTitle });
|
||||
await expect(page.locator(`text=${eventTitle} event type updated successfully`)).toBeVisible();
|
||||
await expect(page.locator(`text=Event type updated successfully`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { BookingStatus } from "@calcom/prisma/client";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Bookings", () => {
|
||||
test.describe("Upcoming bookings", () => {
|
||||
test("show attendee bookings and organizer bookings in asc order by startDate", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const firstUser = await users.create();
|
||||
const secondUser = await users.create();
|
||||
|
||||
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
|
||||
title: "Booking as organizer",
|
||||
bookingsFixture: bookings,
|
||||
// Create a booking 3 days from today
|
||||
relativeDate: 3,
|
||||
organizer: firstUser,
|
||||
organizerEventType: firstUser.eventTypes[0],
|
||||
attendees: [
|
||||
{ name: "First", email: "first@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Second", email: "second@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Third", email: "third@cal.com", timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
|
||||
|
||||
const bookingWhereFirstUserIsAttendeeFixture = await createBooking({
|
||||
title: "Booking as attendee",
|
||||
bookingsFixture: bookings,
|
||||
organizer: secondUser,
|
||||
// Booking created 2 days from today
|
||||
relativeDate: 2,
|
||||
organizerEventType: secondUser.eventTypes[0],
|
||||
attendees: [
|
||||
{ name: "OrganizerAsBooker", email: firstUser.email, timeZone: "Europe/Berlin" },
|
||||
{ name: "Second", email: "second@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Third", email: "third@cal.com", timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
const bookingWhereFirstUserIsAttendee = await bookingWhereFirstUserIsAttendeeFixture.self();
|
||||
|
||||
await firstUser.apiLogin();
|
||||
await page.goto(`/bookings/upcoming`);
|
||||
const upcomingBookings = page.locator('[data-testid="upcoming-bookings"]');
|
||||
const firstUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const secondUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(1);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
firstUpcomingBooking.locator(`text=${bookingWhereFirstUserIsAttendee!.title}`)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createBooking({
|
||||
bookingsFixture,
|
||||
organizer,
|
||||
organizerEventType,
|
||||
attendees,
|
||||
/**
|
||||
* Relative date from today
|
||||
* 0 means today
|
||||
* 1 means tomorrow
|
||||
*/
|
||||
relativeDate = 0,
|
||||
durationMins = 30,
|
||||
title,
|
||||
}: {
|
||||
bookingsFixture: Fixtures["bookings"];
|
||||
organizer: {
|
||||
id: number;
|
||||
username: string | null;
|
||||
};
|
||||
organizerEventType: {
|
||||
id: number;
|
||||
};
|
||||
attendees: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
}[];
|
||||
relativeDate?: number;
|
||||
durationMins?: number;
|
||||
title: string;
|
||||
}) {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const bookingDurationMs = durationMins * 60 * 1000;
|
||||
const startTime = new Date(Date.now() + relativeDate * DAY_MS);
|
||||
const endTime = new Date(Date.now() + relativeDate * DAY_MS + bookingDurationMs);
|
||||
return await bookingsFixture.create(organizer.id, organizer.username, organizerEventType.id, {
|
||||
title,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime,
|
||||
endTime,
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: [...attendees],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
@@ -91,6 +92,7 @@ test.describe("Event Types tests", () => {
|
||||
expect(formTitle).toBe(firstTitle);
|
||||
expect(formSlug).toContain(firstSlug);
|
||||
});
|
||||
|
||||
test("edit first event", async ({ page }) => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
@@ -152,5 +154,46 @@ test.describe("Event Types tests", () => {
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Different Locations Tests", () => {
|
||||
test("can add Attendee Phone Number location and book with it", async ({ page }) => {
|
||||
await gotoFirstEventType(page);
|
||||
await selectAttendeePhoneNumber(page);
|
||||
await saveEventType(page);
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator(`[data-fob-field-name="location"] input`).fill("9199999999");
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator("text=+19199999999")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const selectAttendeePhoneNumber = async (page: Page) => {
|
||||
const locationOptionText = "Attendee Phone Number";
|
||||
await page.locator("#location-select").click();
|
||||
await page.locator(`text=${locationOptionText}`).click();
|
||||
};
|
||||
|
||||
async function gotoFirstEventType(page: Page) {
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
await firstEventTypeElement.click();
|
||||
await page.waitForURL((url) => {
|
||||
return !!url.pathname.match(/\/event-types\/.+/);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveEventType(page: Page) {
|
||||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
}
|
||||
|
||||
async function gotoBookingPage(page: Page) {
|
||||
const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href");
|
||||
|
||||
await page.goto(previewLink ?? "");
|
||||
}
|
||||
|
||||
@@ -19,9 +19,12 @@ export const createBookingsFixture = (page: Page) => {
|
||||
username: string | null,
|
||||
eventTypeId = -1,
|
||||
{
|
||||
title = "",
|
||||
rescheduled = false,
|
||||
paid = false,
|
||||
status = "ACCEPTED",
|
||||
startTime,
|
||||
endTime,
|
||||
attendees = {
|
||||
create: {
|
||||
email: "attendee@example.com",
|
||||
@@ -39,9 +42,9 @@ export const createBookingsFixture = (page: Page) => {
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: uid,
|
||||
title: "30min",
|
||||
startTime: startDate,
|
||||
endTime: endDateParam || dayjs().add(1, "day").add(30, "minutes").toDate(),
|
||||
title: title || "30min",
|
||||
startTime: startTime || startDate,
|
||||
endTime: endTime || endDateParam || dayjs().add(1, "day").add(30, "minutes").toDate(),
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
type Route = {
|
||||
id: string;
|
||||
action: {
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
isFallback: boolean;
|
||||
queryValue: {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const createRoutingFormsFixture = () => {
|
||||
return {
|
||||
async create({
|
||||
userId,
|
||||
teamId,
|
||||
name,
|
||||
fields,
|
||||
routes = [],
|
||||
}: {
|
||||
name: string;
|
||||
userId: number;
|
||||
teamId: number | null;
|
||||
routes?: Route[];
|
||||
fields: {
|
||||
type: string;
|
||||
label: string;
|
||||
identifier?: string;
|
||||
required: boolean;
|
||||
}[];
|
||||
}) {
|
||||
return await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name,
|
||||
userId,
|
||||
teamId,
|
||||
routes: [
|
||||
...routes,
|
||||
// Add a fallback route always, this is taken care of tRPC route normally but do it manually while running the query directly.
|
||||
{
|
||||
id: "898899aa-4567-489a-bcde-f1823f708646",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: fields.map((f) => ({
|
||||
id: uuidv4(),
|
||||
...f,
|
||||
})),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -542,7 +542,7 @@ export async function apiLogin(
|
||||
.then((json) => json.csrfToken);
|
||||
const data = {
|
||||
email: user.email ?? `${user.username}@example.com`,
|
||||
password: user.password ?? user.username!,
|
||||
password: user.password ?? user.username,
|
||||
callbackURL: "http://localhost:3000/",
|
||||
redirect: "false",
|
||||
json: "true",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||
import { createServersFixture } from "../fixtures/servers";
|
||||
import { createUsersFixture } from "../fixtures/users";
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface Fixtures {
|
||||
servers: ReturnType<typeof createServersFixture>;
|
||||
prisma: typeof prisma;
|
||||
emails?: API;
|
||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -71,6 +73,9 @@ export const test = base.extend<Fixtures>({
|
||||
prisma: async ({}, use) => {
|
||||
await use(prisma);
|
||||
},
|
||||
routingForms: async ({}, use) => {
|
||||
await use(createRoutingFormsFixture());
|
||||
},
|
||||
emails: async ({}, use) => {
|
||||
if (IS_MAILHOG_ENABLED) {
|
||||
const mailhogAPI = mailhog();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user