Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b88a3c81 | ||
|
|
c97f1928bb | ||
|
|
dea6ec5003 | ||
|
|
8cace7f7a0 | ||
|
|
b80c0692dc | ||
|
|
44291be7b7 | ||
|
|
54440cf954 | ||
|
|
43315108a2 | ||
|
|
311b6e071d | ||
|
|
308c7fa12b | ||
|
|
51cf73218b | ||
|
|
dc44825ce5 | ||
|
|
908128ca5d | ||
|
|
b12f14c538 | ||
|
|
f03e6075a2 | ||
|
|
a8aac6dc3a | ||
|
|
1594567fd5 | ||
|
|
dde6e7d140 | ||
|
|
d8f36d0665 | ||
|
|
9362253bfb | ||
|
|
c91da96956 | ||
|
|
dbb33b9632 | ||
|
|
ead0691ea3 | ||
|
|
e4a6b97017 | ||
|
|
98b862c048 | ||
|
|
78c411621e | ||
|
|
a1e1bf8b76 | ||
|
|
d00cf329e8 | ||
|
|
843329ccce | ||
|
|
741144bff2 | ||
|
|
a11f7e6510 | ||
|
|
3a8bf7014c | ||
|
|
6f02fbe13b | ||
|
|
af3f44a7f5 | ||
|
|
0605572969 | ||
|
|
5ddfbcf225 | ||
|
|
2fc1078158 | ||
|
|
ad416ac9df | ||
|
|
2a7439b58d | ||
|
|
8aaa84142b | ||
|
|
059ca2503d | ||
|
|
35d6c41fff | ||
|
|
1c3ced5b70 | ||
|
|
88ce7868e8 | ||
|
|
ba3838ec0f | ||
|
|
f87d4de546 | ||
|
|
fd6c6e6419 | ||
|
|
63d1361a7a | ||
|
|
cbcd769f88 | ||
|
|
a2185d9ee0 | ||
|
|
7a66add217 | ||
|
|
68b93385eb | ||
|
|
e2aa6361d5 | ||
|
|
d15281160f | ||
|
|
8b6c6273ee | ||
|
|
957d19740e | ||
|
|
150e93dc6e | ||
|
|
94c7bfea4d | ||
|
|
dbef7644d4 | ||
|
|
a843a38e67 | ||
|
|
73ffd9c7b5 | ||
|
|
2c8ff89fac | ||
|
|
3bf2a8fa6c | ||
|
|
16a4c75e1e | ||
|
|
f9db1d6111 | ||
|
|
1d9aee7154 | ||
|
|
56e3a314a8 | ||
|
|
8167fe0d05 | ||
|
|
c23c2d128a | ||
|
|
65893ae943 | ||
|
|
f33edb2b99 | ||
|
|
5d8c4ad13d | ||
|
|
f359d37799 | ||
|
|
b98bb8b852 | ||
|
|
7ca2082c62 | ||
|
|
c11865fba9 | ||
|
|
a3c0d06c13 | ||
|
|
cd74f292ee | ||
|
|
2c52e31c76 | ||
|
|
6479adf14e | ||
|
|
3c46c35b69 | ||
|
|
41ad0f718d | ||
|
|
0103321b71 | ||
|
|
38890dad30 | ||
|
|
0776657faf | ||
|
|
3cf7266048 | ||
|
|
86b9aa5f23 | ||
|
|
b94f107493 | ||
|
|
4a451b411c | ||
|
|
b64cfc9e66 | ||
|
|
ce279da7c4 | ||
|
|
7dbc888848 | ||
|
|
8fcbf2c167 | ||
|
|
a050ccb4ee | ||
|
|
2522638414 | ||
|
|
0ddb456a91 | ||
|
|
9048d053ac | ||
|
|
cbc8d3d7a8 | ||
|
|
e6ba89961c | ||
|
|
724204843a | ||
|
|
f136b7145f | ||
|
|
179ace23e1 | ||
|
|
3af6fee05d | ||
|
|
0f84cedad2 | ||
|
|
a23400777a | ||
|
|
33c3bf3558 | ||
|
|
2701b4de79 | ||
|
|
a9f83ac5e2 | ||
|
|
ff1533d344 | ||
|
|
d9cddd85ff | ||
|
|
1ab4b9dfab | ||
|
|
f3d0475840 | ||
|
|
061283bab9 | ||
|
|
4e0798577a | ||
|
|
c95b0833c1 | ||
|
|
2a7c6590a3 | ||
|
|
7d2a06ddb6 | ||
|
|
d34ad2b0f6 | ||
|
|
e06249d2df | ||
|
|
a09dfe37a1 | ||
|
|
71e7f16b29 | ||
|
|
d6546c3107 | ||
|
|
625c260e40 | ||
|
|
81224f324a | ||
|
|
700d8e7f30 | ||
|
|
301376e33b | ||
|
|
57476ea392 | ||
|
|
9c2d7f99c2 | ||
|
|
ef3281f06f | ||
|
|
d03f45dd0f | ||
|
|
2b2bf36703 | ||
|
|
272d97c0a3 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
This PR introduces two variants for the calendar view atom, a generic calendar view and an event type specific view containing bookings data and busy times from connected calendars.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
This PR fixes the issue of connect atoms not working inside iframes. It also updates the atoms exports to include the `useAvailableSlots` hook.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": major
|
||||
---
|
||||
|
||||
feat: use tailwind v4 (breaking change)
|
||||
@@ -496,3 +496,9 @@ TZ=UTC
|
||||
# test oauth client for `packages/platform/examples/base` (optional)
|
||||
SEED_PLATFORM_OAUTH_CLIENT_ID=
|
||||
SEED_PLATFORM_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# Trigger.dev
|
||||
ENABLE_ASYNC_TASKER="false" # set to "true" to enable
|
||||
TRIGGER_SECRET_KEY=
|
||||
TRIGGER_API_URL=https://api.trigger.dev
|
||||
TRIGGER_DEV_PROJECT_REF=
|
||||
@@ -1,2 +1,4 @@
|
||||
GET /v2/event-types/{eventTypeId} added to the 'data' response property 'oneOf' list for the response status '200'
|
||||
GET /v2/event-types/{eventTypeId} added '#/components/schemas/EventTypeOutput_2024_06_14, #/components/schemas/TeamEventTypeOutput_2024_06_14' to the 'data' response property 'oneOf' list for the response status '200'
|
||||
GET /v2/bookings/{bookingUid}/recordings added the new required 'header' request parameter 'Authorization'
|
||||
GET /v2/bookings/{bookingUid}/transcripts added the new required 'header' request parameter 'Authorization'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Companion Builds
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
EXPO_PUBLIC_CAL_API_KEY: ${{ secrets.CI_EXPO_PUBLIC_CAL_API_KEY || vars.CI_EXPO_PUBLIC_CAL_API_KEY || vars.EXPO_PUBLIC_CAL_API_KEY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Companion App & Extension
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: companion/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: npm ci
|
||||
- name: Build Expo web app
|
||||
working-directory: companion
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: 1
|
||||
CI: true
|
||||
run: npx expo export --platform web
|
||||
- name: Build browser extension
|
||||
working-directory: companion
|
||||
run: npm run ext:build
|
||||
@@ -146,6 +146,13 @@ jobs:
|
||||
uses: ./.github/workflows/docs-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-companion:
|
||||
name: Companion builds
|
||||
needs: [changes, check-label, deps]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/companion-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: [changes, check-label, deps]
|
||||
@@ -244,6 +251,7 @@ jobs:
|
||||
build-api-v2,
|
||||
build-atoms,
|
||||
build-docs,
|
||||
build-companion,
|
||||
e2e,
|
||||
e2e-api-v2,
|
||||
e2e-embed,
|
||||
|
||||
@@ -105,3 +105,6 @@ packages/**/.yarn/ci-cache/
|
||||
|
||||
# AI
|
||||
.claude
|
||||
|
||||
# trigger.dev
|
||||
.trigger
|
||||
Vendored
-1
@@ -4,7 +4,6 @@
|
||||
"yzhang.markdown-all-in-one", // nicer markdown support
|
||||
"esbenp.prettier-vscode", // prettier plugin
|
||||
"dbaeumer.vscode-eslint", // eslint plugin
|
||||
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
|
||||
"ban.spellright", // Spell check for docs
|
||||
"stripe.vscode-stripe", // stripe VSCode extension
|
||||
"Prisma.prisma", // syntax|format|completion for prisma
|
||||
|
||||
Vendored
+1
-1
@@ -8,5 +8,5 @@
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"spellright.language": ["en"],
|
||||
"spellright.documentTypes": ["markdown", "typescript", "typescriptreact"],
|
||||
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]]
|
||||
"tailwindCSS.classFunctions": ["cva"]
|
||||
}
|
||||
|
||||
Vendored
Vendored
@@ -113,6 +113,12 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
)
|
||||
.optional(),
|
||||
responses: z.record(z.any()).nullable(),
|
||||
// Override metadata to handle reassignment objects from Round Robin/Managed Events
|
||||
// Safe to use z.any() here because:
|
||||
// 1. API v1 POST only accepts z.record(z.string()) for metadata (user input restricted)
|
||||
// 2. API v1 PATCH does not accept metadata changes at all
|
||||
// 3. Complex metadata (objects) are only set by trusted internal features
|
||||
metadata: z.record(z.any()).nullable(),
|
||||
}).pick({
|
||||
id: true,
|
||||
userId: true,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(_request: NextRequest) {
|
||||
// Matcher guarantees the last segment is one of the blocked segments, so we can
|
||||
// immediately return a 403 without further path checks.
|
||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// The blocked segment is always the last part of the path, so we only need this matcher.
|
||||
matcher: [
|
||||
"/:path*/_get",
|
||||
"/:path*/_post",
|
||||
"/:path*/_patch",
|
||||
"/:path*/_delete",
|
||||
"/:path*/_auth-middleware",
|
||||
],
|
||||
};
|
||||
@@ -35,7 +35,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^15.4.5",
|
||||
"next": "^15.4.8",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -11,69 +11,114 @@ type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("PATCH /api/bookings", () => {
|
||||
let member1Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
|
||||
let member0Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
|
||||
const createdBookingIds: number[] = [];
|
||||
let testAdminUserId: number | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
const member1 = await prisma.user.findFirstOrThrow({
|
||||
where: { email: "member1-acme@example.com" },
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
const member0 = await prisma.user.findFirstOrThrow({
|
||||
where: { email: "member0-acme@example.com" },
|
||||
});
|
||||
|
||||
// Create bookings for testing
|
||||
member1Booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-member1-booking-${Date.now()}`,
|
||||
title: "Member 1 Test Booking",
|
||||
startTime: new Date(Date.now() + 86400000), // Tomorrow
|
||||
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
|
||||
userId: member1.id,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
createdBookingIds.push(member1Booking.id);
|
||||
|
||||
member0Booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-member0-booking-${Date.now()}`,
|
||||
title: "Member 0 Test Booking",
|
||||
startTime: new Date(Date.now() + 172800000), // Day after tomorrow
|
||||
endTime: new Date(Date.now() + 176400000), // Day after tomorrow + 1 hour
|
||||
userId: member0.id,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
createdBookingIds.push(member0Booking.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdBookingIds.length > 0) {
|
||||
await prisma.booking.deleteMany({
|
||||
where: { id: { in: createdBookingIds } },
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up test admin user if created
|
||||
if (testAdminUserId) {
|
||||
await prisma.user.delete({
|
||||
where: { id: testAdminUserId },
|
||||
});
|
||||
}
|
||||
});
|
||||
it("Returns 403 when user has no permission to the booking", async () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
// Member2 tries to access Member0's booking - should fail
|
||||
const member2 = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "PATCH",
|
||||
body: {
|
||||
title: booking.title,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
userId: memberUser.id,
|
||||
title: member0Booking.title,
|
||||
startTime: member0Booking.startTime.toISOString(),
|
||||
endTime: member0Booking.endTime.toISOString(),
|
||||
userId: member2.id,
|
||||
},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: member0Booking.id,
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = memberUser.id;
|
||||
req.userId = member2.id;
|
||||
|
||||
await handler(req, res);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("Allows PATCH when user is system-wide admin", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
// Check if admin user already exists before upserting
|
||||
const existingAdmin = await prisma.user.findUnique({ where: { email: "test-admin@example.com" } });
|
||||
|
||||
// Create a system-wide admin user for this test
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { email: "test-admin@example.com" },
|
||||
update: { role: "ADMIN" },
|
||||
create: {
|
||||
email: "test-admin@example.com",
|
||||
username: "test-admin",
|
||||
name: "Test Admin",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
|
||||
// Only track for cleanup if we created it (not if it already existed)
|
||||
if (!existingAdmin) {
|
||||
testAdminUserId = adminUser.id;
|
||||
}
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "PATCH",
|
||||
body: {
|
||||
title: booking.title,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
userId: proUser.id,
|
||||
title: member0Booking.title,
|
||||
startTime: member0Booking.startTime.toISOString(),
|
||||
endTime: member0Booking.endTime.toISOString(),
|
||||
userId: member0Booking.userId,
|
||||
},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: member0Booking.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,19 +131,17 @@ describe("PATCH /api/bookings", () => {
|
||||
|
||||
it("Allows PATCH when user is org-wide admin", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "PATCH",
|
||||
body: {
|
||||
title: booking.title,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
userId: memberUser.id,
|
||||
title: member1Booking.title,
|
||||
startTime: member1Booking.startTime.toISOString(),
|
||||
endTime: member1Booking.endTime.toISOString(),
|
||||
userId: member1Booking.userId,
|
||||
},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: member1Booking.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it, beforeAll } from "vitest";
|
||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
@@ -16,51 +16,67 @@ const DefaultPagination = {
|
||||
skip: 0,
|
||||
};
|
||||
|
||||
describe("GET /api/bookings", async () => {
|
||||
describe("GET /api/bookings", () => {
|
||||
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
|
||||
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
|
||||
let memberUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
|
||||
let memberUserBooking: Awaited<ReturnType<typeof prisma.booking.create>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
|
||||
// Find an event type for memberUser or use a simple booking
|
||||
const memberEventType = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
OR: [
|
||||
{ userId: memberUser.id },
|
||||
{ team: { members: { some: { userId: memberUser.id } } } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
memberUserBooking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-member-booking-${Date.now()}`,
|
||||
title: "Member Test Booking",
|
||||
startTime: new Date(Date.now() + 86400000), // Tomorrow
|
||||
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
|
||||
userId: memberUser.id,
|
||||
eventTypeId: memberEventType?.id,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up the test booking created in beforeAll
|
||||
if (memberUserBooking?.id) {
|
||||
await prisma.booking.delete({
|
||||
where: { id: memberUserBooking.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
it("Does not return bookings of other users when user has no permission", async () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
userId: proUser.id,
|
||||
userId: proUser.id, // Try to access proUser's bookings
|
||||
},
|
||||
pagination: DefaultPagination,
|
||||
});
|
||||
|
||||
req.userId = memberUser.id;
|
||||
req.userId = memberUser.id; // But request is from memberUser
|
||||
|
||||
const responseData = await handler(req);
|
||||
const groupedUsers = new Set(responseData.bookings.map((b) => b.userId));
|
||||
|
||||
// Should only return memberUser's own bookings, not proUser's
|
||||
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
|
||||
expect(responseData.bookings.find((b) => b.id === memberUserBooking.id)).toBeDefined();
|
||||
expect(groupedUsers.size).toBe(1);
|
||||
const firstEntry = groupedUsers.entries().next().value;
|
||||
expect(firstEntry?.[0]).toBe(memberUser.id);
|
||||
|
||||
@@ -41,4 +41,11 @@ LOGGER_BRIDGE_LOG_LEVEL="1"
|
||||
|
||||
# 1: Rewrite /api/v2 to /v2
|
||||
# 0: Don't rewrite
|
||||
REWRITE_API_V2_PREFIX="1"
|
||||
REWRITE_API_V2_PREFIX="1"
|
||||
|
||||
|
||||
# Trigger.dev
|
||||
ENABLE_ASYNC_TASKER="false" # set to "true" to enable
|
||||
TRIGGER_SECRET_KEY=
|
||||
TRIGGER_API_URL=https://api.trigger.dev
|
||||
TRIGGER_DEV_PROJECT_REF=
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
@@ -96,6 +98,8 @@ import { Module } from "@nestjs/common";
|
||||
BookingReferencesService_2024_08_13,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CalVideoService,
|
||||
CalVideoOutputService,
|
||||
BookingPbacGuard,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
GetOptionalUser,
|
||||
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||
@@ -66,6 +68,7 @@ import {
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
GetBookingRecordingsOutput,
|
||||
GetBookingTranscriptsOutput,
|
||||
GetBookingVideoSessionsOutput,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInputPipe,
|
||||
@@ -209,12 +212,16 @@ export class BookingsController_2024_08_13 {
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/recordings")
|
||||
// @Pbac(["booking.readRecordings"])
|
||||
@Permissions([BOOKING_READ])
|
||||
@UseGuards(BookingUidGuard)
|
||||
// @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get all the recordings for the booking",
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\`
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\`. Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>
|
||||
`,
|
||||
})
|
||||
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
|
||||
@@ -223,16 +230,26 @@ export class BookingsController_2024_08_13 {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: recordings,
|
||||
message:
|
||||
"This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/transcripts")
|
||||
// @Pbac(["booking.readRecordings"])
|
||||
@Permissions([BOOKING_READ])
|
||||
@UseGuards(BookingUidGuard)
|
||||
// @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get Cal Video real time transcript download links for the booking",
|
||||
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
|
||||
description: `Fetches all the transcript download links for the booking \`:bookingUid\`
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
<Note>
|
||||
Transcripts are generated when clicking "Transcribe" during a Cal Video meeting. Download links are valid for 1 hour only - make a new request to generate fresh links after expiration.
|
||||
|
||||
Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.
|
||||
</Note>
|
||||
`,
|
||||
})
|
||||
async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise<GetBookingTranscriptsOutput> {
|
||||
@@ -241,6 +258,8 @@ export class BookingsController_2024_08_13 {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: transcripts ?? [],
|
||||
message:
|
||||
"This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -542,4 +561,25 @@ export class BookingsController_2024_08_13 {
|
||||
data: bookingReferences,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/conferencing-sessions")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Pbac(["booking.readRecordings"])
|
||||
@Permissions([BOOKING_READ])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get Video Meeting Sessions. Only supported for Cal Video",
|
||||
description: `Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
|
||||
|
||||
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>`,
|
||||
})
|
||||
async getVideoSessions(@Param("bookingUid") bookingUid: string): Promise<GetBookingVideoSessionsOutput> {
|
||||
const sessions = await this.calVideoService.getVideoSessions(bookingUid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: sessions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Booking access authorization", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
|
||||
const ownerEmail = `booking-access-auth-owner-${randomString()}@api.com`;
|
||||
const unauthorizedEmail = `booking-access-auth-unauthorized-${randomString()}@api.com`;
|
||||
let ownerUser: User;
|
||||
let unauthorizedUser: User;
|
||||
let ownerApiKey: string;
|
||||
let unauthorizedApiKey: string;
|
||||
|
||||
let eventTypeId: number;
|
||||
let testBooking: Booking;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `booking-access-auth-organization-${randomString()}`,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
ownerUser = await userRepositoryFixture.create({
|
||||
email: ownerEmail,
|
||||
locale: "en",
|
||||
name: `booking-access-auth-owner-${randomString()}`,
|
||||
});
|
||||
|
||||
unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedEmail,
|
||||
locale: "en",
|
||||
name: `booking-access-auth-unauthorized-${randomString()}`,
|
||||
});
|
||||
|
||||
const { keyString: ownerKeyString } = await apiKeysRepositoryFixture.createApiKey(ownerUser.id, null);
|
||||
ownerApiKey = `cal_test_${ownerKeyString}`;
|
||||
|
||||
const { keyString: unauthorizedKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
unauthorizedUser.id,
|
||||
null
|
||||
);
|
||||
unauthorizedApiKey = `cal_test_${unauthorizedKeyString}`;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `booking-access-auth-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(ownerUser.id, userSchedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `booking-access-auth-event-type-${randomString()}`,
|
||||
slug: `booking-access-auth-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
ownerUser.id
|
||||
);
|
||||
eventTypeId = eventType.id;
|
||||
|
||||
testBooking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-access-auth-booking-${randomString()}`,
|
||||
title: "Test Booking for Access Auth",
|
||||
description: "",
|
||||
startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: ownerUser.id,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Test Attendee",
|
||||
email: "attendee@example.com",
|
||||
},
|
||||
references: {
|
||||
create: [
|
||||
{
|
||||
type: "daily_video",
|
||||
uid: `daily-room-${randomString()}`,
|
||||
meetingId: `daily-room-${randomString()}`,
|
||||
meetingPassword: "test-password",
|
||||
meetingUrl: `https://daily.co/test-room-${randomString()}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("GET /v2/bookings/:bookingUid/conferencing-sessions - Authorization", () => {
|
||||
it("should allow booking organizer to access conferencing sessions", async () => {
|
||||
const calVideoService = app.get(CalVideoService);
|
||||
jest.spyOn(calVideoService, "getVideoSessions").mockResolvedValue([]);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
|
||||
.set("Authorization", `Bearer ${ownerApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
|
||||
it("should return 403 when unauthorized user tries to access conferencing sessions", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
|
||||
.set("Authorization", `Bearer ${unauthorizedApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
// describe("GET /v2/bookings/:bookingUid/recordings - Authorization", () => {
|
||||
// it("should allow booking organizer to access recordings", async () => {
|
||||
// const calVideoService = app.get(CalVideoService);
|
||||
// jest.spyOn(calVideoService, "getRecordings").mockResolvedValue([]);
|
||||
|
||||
// const response = await request(app.getHttpServer())
|
||||
// .get(`/v2/bookings/${testBooking.uid}/recordings`)
|
||||
// .set("Authorization", `Bearer ${ownerApiKey}`)
|
||||
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
// .expect(200);
|
||||
|
||||
// expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
// });
|
||||
|
||||
// it("should return 403 when unauthorized user tries to access recordings", async () => {
|
||||
// await request(app.getHttpServer())
|
||||
// .get(`/v2/bookings/${testBooking.uid}/recordings`)
|
||||
// .set("Authorization", `Bearer ${unauthorizedApiKey}`)
|
||||
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
// .expect(403);
|
||||
// });
|
||||
// });
|
||||
|
||||
// describe("GET /v2/bookings/:bookingUid/transcripts - Authorization", () => {
|
||||
// it("should allow booking organizer to access transcripts", async () => {
|
||||
// const calVideoService = app.get(CalVideoService);
|
||||
// jest.spyOn(calVideoService, "getTranscripts").mockResolvedValue([]);
|
||||
|
||||
// const response = await request(app.getHttpServer())
|
||||
// .get(`/v2/bookings/${testBooking.uid}/transcripts`)
|
||||
// .set("Authorization", `Bearer ${ownerApiKey}`)
|
||||
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
// .expect(200);
|
||||
|
||||
// expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
// });
|
||||
|
||||
// it("should return 403 when unauthorized user tries to access transcripts", async () => {
|
||||
// await request(app.getHttpServer())
|
||||
// .get(`/v2/bookings/${testBooking.uid}/transcripts`)
|
||||
// .set("Authorization", `Bearer ${unauthorizedApiKey}`)
|
||||
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
// .expect(403);
|
||||
// });
|
||||
// });
|
||||
|
||||
afterAll(async () => {
|
||||
await bookingsRepositoryFixture.deleteById(testBooking.id);
|
||||
await eventTypesRepositoryFixture.delete(eventTypeId);
|
||||
await userRepositoryFixture.deleteByEmail(ownerEmail);
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedEmail);
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import type {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
GetBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import type { EventType, User } from "@calcom/prisma/client";
|
||||
|
||||
describe("Reschedule bookings 2024-08-13", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
|
||||
let normalBookingUser: User;
|
||||
let normalBookingEventType: EventType;
|
||||
let normalBookingEventTypeId: number;
|
||||
const normalBookingUserEmail = `user-bookings-normal-user-${randomString(5)}@api.com`;
|
||||
const normalBookingEventTypeSlug = `user-bookings-normal-event-type-${randomString(5)}`;
|
||||
let oldNormalBooking: BookingOutput_2024_08_13;
|
||||
let newNormalBooking: BookingOutput_2024_08_13;
|
||||
|
||||
const RESCHEDULE_REASON = "Flying to venus that day";
|
||||
const RESCHEDULED_BY = `user-venus-bookings-rescheduler-${randomString(5)}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
normalBookingUserEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
// initialize normal booking user, schedule and event type
|
||||
normalBookingUser = await userRepositoryFixture.create({
|
||||
email: normalBookingUserEmail,
|
||||
username: normalBookingUserEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `user-bookings-normal-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(normalBookingUser.id, userSchedule);
|
||||
|
||||
normalBookingEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-bookings-normal-event-type-${randomString()}`,
|
||||
slug: normalBookingEventTypeSlug,
|
||||
length: 60,
|
||||
},
|
||||
normalBookingUser.id
|
||||
);
|
||||
normalBookingEventTypeId = normalBookingEventType.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("reschedule normal booking", () => {
|
||||
it("should create a booking to be rescheduled", async () => {
|
||||
const bookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 1, 9, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: normalBookingEventTypeId,
|
||||
attendee: {
|
||||
name: `user-venus-bookings-attendee-${randomString(10)}`,
|
||||
email: `user-venus-bookings-attendee-${randomString(10)}@gmail.com`,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/oj12u83128u9312ou3",
|
||||
};
|
||||
|
||||
const bookingResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(bookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const bookingResponseBody: CreateBookingOutput_2024_08_13 = bookingResponse.body;
|
||||
expect(bookingResponseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(bookingResponseBody.data)).toBe(true);
|
||||
if (responseDataIsBooking(bookingResponseBody.data)) {
|
||||
const created: BookingOutput_2024_08_13 = bookingResponseBody.data;
|
||||
oldNormalBooking = created;
|
||||
expect(oldNormalBooking).toBeDefined();
|
||||
expect(oldNormalBooking.uid).toBeDefined();
|
||||
} else {
|
||||
throw new Error("should create a booking to be rescheduled - Invalid response data");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reschedule", async () => {
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 1, 9, 12, 0, 0)).toISOString(),
|
||||
reschedulingReason: RESCHEDULE_REASON,
|
||||
rescheduledBy: RESCHEDULED_BY,
|
||||
};
|
||||
|
||||
const beforeCreate = new Date();
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${oldNormalBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const afterCreate = new Date();
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data as BookingOutput_2024_08_13;
|
||||
expect(data.reschedulingReason).toEqual(RESCHEDULE_REASON);
|
||||
expect(data.rescheduledFromUid).toEqual(oldNormalBooking.uid);
|
||||
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
|
||||
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2040, 1, 9, 13, 0, 0)).toISOString());
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(normalBookingUser.id);
|
||||
expect(data.hosts[0].username).toEqual(normalBookingUser.username);
|
||||
expect(data.hosts[0].email).toEqual(normalBookingUser.email);
|
||||
expect(data.status).toEqual(oldNormalBooking.status);
|
||||
expect(data.duration).toEqual(oldNormalBooking.duration);
|
||||
expect(data.eventTypeId).toEqual(oldNormalBooking.eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(oldNormalBooking.attendees[0]);
|
||||
expect(data.location).toEqual(oldNormalBooking.location);
|
||||
expect(data.absentHost).toEqual(oldNormalBooking.absentHost);
|
||||
expect(data.metadata).toEqual(oldNormalBooking.metadata);
|
||||
|
||||
newNormalBooking = data;
|
||||
expect(newNormalBooking).toBeDefined();
|
||||
|
||||
const createdAtDate = new Date(data.createdAt);
|
||||
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch old booking and verify rescheduledToUid and rescheduledByEmail and status cancelled", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${oldNormalBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.rescheduledToUid).toEqual(newNormalBooking.uid);
|
||||
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch new booking and verify rescheduledFromUid, rescheduledByEmail and reschedulingReason", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${newNormalBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.rescheduledFromUid).toEqual(oldNormalBooking.uid);
|
||||
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
|
||||
expect(data.reschedulingReason).toEqual(RESCHEDULE_REASON);
|
||||
expect(data.status).toEqual("accepted");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(normalBookingUser.email);
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
|
||||
}
|
||||
});
|
||||
+351
-356
@@ -4,6 +4,7 @@ import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { CreateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
@@ -79,7 +80,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const eventTypeRequiresConfirmationSlug = `user-bookings-event-type-requires-confirmation-${randomString()}`;
|
||||
|
||||
let createdBooking: BookingOutput_2024_08_13;
|
||||
let rescheduledBooking: BookingOutput_2024_08_13;
|
||||
let createdRecurringBooking: RecurringBookingOutput_2024_08_13[];
|
||||
|
||||
let bookingInThePast: Booking;
|
||||
@@ -1360,139 +1360,217 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reschedule bookings", () => {
|
||||
it("should reschedule normal booking", async () => {
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars that day",
|
||||
};
|
||||
|
||||
const beforeCreate = new Date();
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const afterCreate = new Date();
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString());
|
||||
expect(data.rescheduledFromUid).toEqual(createdBooking.uid);
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.hosts[0].username).toEqual(user.username);
|
||||
expect(data.hosts[0].email).toEqual(user.email);
|
||||
expect(data.status).toEqual(createdBooking.status);
|
||||
expect(data.duration).toEqual(createdBooking.duration);
|
||||
expect(data.eventTypeId).toEqual(createdBooking.eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
|
||||
expect(data.location).toEqual(createdBooking.location);
|
||||
expect(data.absentHost).toEqual(createdBooking.absentHost);
|
||||
expect(data.metadata).toEqual(createdBooking.metadata);
|
||||
|
||||
// When a booking is rescheduled, a new booking is created and the old booking is cancelled.
|
||||
// We want to make sure the createdAt date of the new booking is between the beforeCreate and afterCreate dates.
|
||||
const createdAtDate = new Date(data.createdAt);
|
||||
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
rescheduledBooking = data;
|
||||
describe("rescheduling bookings", () => {
|
||||
describe("cant't reschedule cancelled or rescheduled booking booking", () => {
|
||||
it("should not be able to reschedule cancelled booking", async () => {
|
||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars that day",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||
});
|
||||
|
||||
it("should not be able to reschedule rescheduled booking", async () => {
|
||||
const rescheduledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
rescheduled: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newBooking = await bookingsRepositoryFixture.create({
|
||||
status: "ACCEPTED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
fromReschedule: rescheduledBooking.uid,
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `new-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars that day",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
|
||||
await bookingsRepositoryFixture.deleteById(newBooking.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set rescheduled booking status to cancelled", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.rescheduledToUid).toEqual(rescheduledBooking.uid);
|
||||
});
|
||||
});
|
||||
describe("recurring booking", () => {
|
||||
it("should reschedule recurrence of a recurring booking", async () => {
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars again",
|
||||
};
|
||||
|
||||
it("should reschedule recurrence of a recurring booking", async () => {
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars again",
|
||||
};
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const data: RecurringBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.hosts[0].username).toEqual(user.username);
|
||||
expect(data.hosts[0].email).toEqual(user.email);
|
||||
expect(data.status).toEqual(createdRecurringBooking[0].status);
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(createdRecurringBooking[0].duration);
|
||||
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
|
||||
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
|
||||
expect(data.location).toEqual(createdRecurringBooking[0].location);
|
||||
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
|
||||
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const data: RecurringBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.hosts[0].username).toEqual(user.username);
|
||||
expect(data.hosts[0].email).toEqual(user.email);
|
||||
expect(data.status).toEqual(createdRecurringBooking[0].status);
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(createdRecurringBooking[0].duration);
|
||||
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
|
||||
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
|
||||
expect(data.location).toEqual(createdRecurringBooking[0].location);
|
||||
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
|
||||
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
|
||||
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
|
||||
expect(oldBooking).toBeDefined();
|
||||
expect(oldBooking?.status).toEqual("CANCELLED");
|
||||
});
|
||||
});
|
||||
|
||||
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
|
||||
expect(oldBooking).toBeDefined();
|
||||
expect(oldBooking?.status).toEqual("CANCELLED");
|
||||
});
|
||||
});
|
||||
it("should get recurring booking recurrences after rescheduling one", async () => {
|
||||
const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid;
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${recurringBookingUid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
it("should get recurring booking recurrences after rescheduling one", async () => {
|
||||
const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid;
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${recurringBookingUid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(4);
|
||||
const cancelled = data.find((booking) => booking.status === "cancelled");
|
||||
expect(cancelled).toBeDefined();
|
||||
const rescheduledNew = data.find(
|
||||
(booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString()
|
||||
);
|
||||
expect(rescheduledNew).toBeDefined();
|
||||
createdRecurringBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(4);
|
||||
const cancelled = data.find((booking) => booking.status === "cancelled");
|
||||
expect(cancelled).toBeDefined();
|
||||
const rescheduledNew = data.find(
|
||||
(booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString()
|
||||
);
|
||||
expect(rescheduledNew).toBeDefined();
|
||||
createdRecurringBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2385,95 +2463,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("rescheduling booking with invalid booking responses", () => {
|
||||
it("should reschedule", async () => {
|
||||
const email = `user-bookings-attendee-${randomString(10)}@gmail.com`;
|
||||
const username = `user-bookings-attendee-${randomString(10)}`;
|
||||
const locale = "it";
|
||||
const timeZone = "Europe/Rome";
|
||||
const attendee = await userRepositoryFixture.create({
|
||||
email,
|
||||
username,
|
||||
locale,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${randomString(10)}`,
|
||||
title: "booking title",
|
||||
startTime: "2060-09-05T11:00:00.000Z",
|
||||
endTime: "2060-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
status: "ACCEPTED",
|
||||
metadata: {},
|
||||
responses: "null",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: email,
|
||||
name: username,
|
||||
timeZone,
|
||||
locale,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2060, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars that day",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${booking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2060, 0, 9, 15, 0, 0)).toISOString());
|
||||
expect(data.rescheduledFromUid).toEqual(booking.uid);
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.hosts[0].username).toEqual(user.username);
|
||||
expect(data.hosts[0].email).toEqual(user.email);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.eventTypeId).toEqual(booking.eventTypeId);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: attendee.username,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.bookingFieldsResponses).toEqual({
|
||||
name: attendee.username,
|
||||
email: attendee.email,
|
||||
rescheduledReason: body.reschedulingReason,
|
||||
guests: [],
|
||||
});
|
||||
await bookingsRepositoryFixture.deleteById(data.id);
|
||||
await userRepositoryFixture.delete(attendee.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("create booking with attendee sms reminder", () => {
|
||||
it("should not be able create a booking if attendee sms reminder is missing", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
@@ -2559,146 +2548,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cant't reschedule cancelled booking", () => {
|
||||
it("should not be able to reschedule cancelled booking", async () => {
|
||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars that day",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||
});
|
||||
|
||||
it("should not be able to reschedule rescheduled booking", async () => {
|
||||
const rescheduledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
rescheduled: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newBooking = await bookingsRepositoryFixture.create({
|
||||
status: "ACCEPTED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
fromReschedule: rescheduledBooking.uid,
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `new-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars that day",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
|
||||
await bookingsRepositoryFixture.deleteById(newBooking.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cant't cancel already cancelled booking", () => {
|
||||
it("should not be able to cancel alraedy cancelled booking", async () => {
|
||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||
@@ -2752,6 +2601,56 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancellation reason and cancelled by returned if null in database", () => {
|
||||
it("should return cancellation reason and cancelled by if null in database", async () => {
|
||||
const dbBooking = await bookingsRepositoryFixture.create({
|
||||
uid: randomString(10),
|
||||
title: "test",
|
||||
startTime: new Date(Date.UTC(2040, 0, 13, 16, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2040, 0, 13, 17, 0, 0)),
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
cancellationReason: null,
|
||||
cancelledBy: null,
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${dbBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.cancellationReason).toBe("");
|
||||
expect(data.cancelledByEmail).toBe("");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar events", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
@@ -3180,6 +3079,102 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
);
|
||||
}
|
||||
|
||||
describe("Meeting sessions", () => {
|
||||
it("should get cal video sessions for a booking", async () => {
|
||||
const mockSessions = [
|
||||
{
|
||||
id: "session-123",
|
||||
room: "daily-room-123",
|
||||
start_time: 1678901234,
|
||||
duration: 3600,
|
||||
ongoing: false,
|
||||
max_participants: 10,
|
||||
participants: [
|
||||
{
|
||||
user_id: "user-1",
|
||||
participant_id: "participant-1",
|
||||
user_name: "John Doe",
|
||||
join_time: 1678901234,
|
||||
duration: 3600,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `test-video-session-${randomString()}`,
|
||||
title: "Test Video Session Booking",
|
||||
description: "",
|
||||
startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
references: {
|
||||
create: [
|
||||
{
|
||||
type: "daily_video",
|
||||
uid: `daily-room-123-${randomString()}`,
|
||||
meetingId: "daily-room-123",
|
||||
meetingPassword: "test-password",
|
||||
meetingUrl: "https://daily.co/daily-room-123",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const calVideoService = app.get(CalVideoService);
|
||||
jest
|
||||
.spyOn(calVideoService, "getVideoSessions")
|
||||
.mockResolvedValue([
|
||||
{
|
||||
id: mockSessions[0].id,
|
||||
room: mockSessions[0].room,
|
||||
startTime: mockSessions[0].start_time,
|
||||
duration: mockSessions[0].duration,
|
||||
ongoing: mockSessions[0].ongoing,
|
||||
maxParticipants: mockSessions[0].max_participants,
|
||||
participants: mockSessions[0].participants.map((p) => ({
|
||||
userId: p.user_id,
|
||||
userName: p.user_name,
|
||||
joinTime: p.join_time,
|
||||
duration: p.duration,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${booking.uid}/conferencing-sessions`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response.body.data[0].id).toEqual(mockSessions[0].id);
|
||||
expect(response.body.data[0].room).toEqual(mockSessions[0].room);
|
||||
expect(response.body.data[0].startTime).toEqual(mockSessions[0].start_time);
|
||||
expect(response.body.data[0].duration).toEqual(mockSessions[0].duration);
|
||||
expect(response.body.data[0].ongoing).toEqual(mockSessions[0].ongoing);
|
||||
expect(response.body.data[0].maxParticipants).toEqual(mockSessions[0].max_participants);
|
||||
expect(response.body.data[0].participants.length).toBe(1);
|
||||
expect(response.body.data[0].participants[0].userId).toEqual(mockSessions[0].participants[0].user_id);
|
||||
expect(response.body.data[0].participants[0].userName).toEqual(
|
||||
mockSessions[0].participants[0].user_name
|
||||
);
|
||||
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { BookingAccessService } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class BookingPbacGuard implements CanActivate {
|
||||
private bookingAccessService: BookingAccessService;
|
||||
|
||||
constructor(private readonly prismaReadService: PrismaReadService) {
|
||||
this.bookingAccessService = new BookingAccessService(this.prismaReadService.prisma);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user?: ApiAuthGuardUser; pbacAuthorizedRequest?: boolean }>();
|
||||
const user = request.user;
|
||||
const bookingUid = request.params.bookingUid;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (!bookingUid) {
|
||||
throw new BadRequestException("BookingPbacGuard - bookingUid is required");
|
||||
}
|
||||
|
||||
const hasAccess = await this.bookingAccessService.doesUserIdHaveAccessToBooking({
|
||||
userId: user.id,
|
||||
bookingUid,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
throw new ForbiddenException(
|
||||
`BookingPbacGuard - user with id=${user.id} does not have access to booking with uid=${bookingUid}`
|
||||
);
|
||||
}
|
||||
|
||||
request.pbacAuthorizedRequest = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { CalMeetingSession } from "@calcom/platform-libraries/conferencing";
|
||||
|
||||
@Injectable()
|
||||
export class CalVideoOutputService {
|
||||
getOutputVideoSessions(sessions: CalMeetingSession[]) {
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
room: session.room,
|
||||
startTime: session.start_time,
|
||||
duration: session.duration,
|
||||
ongoing: session.ongoing,
|
||||
maxParticipants: session.max_participants,
|
||||
participants: session.participants.map((participant) => ({
|
||||
userId: participant.user_id,
|
||||
userName: participant.user_name,
|
||||
joinTime: participant.join_time,
|
||||
duration: participant.duration,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { CAL_VIDEO_TYPE } from "@calcom/platform-constants";
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getAllTranscriptsAccessLinkFromRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
getCalVideoMeetingSessionsByRoomName,
|
||||
} from "@calcom/platform-libraries/conferencing";
|
||||
|
||||
@Injectable()
|
||||
@@ -13,18 +15,23 @@ export class CalVideoService {
|
||||
private readonly logger = new Logger("CalVideoService");
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly outputService: OutputBookingsService_2024_08_13
|
||||
private readonly calVideoOutputService: CalVideoOutputService
|
||||
) {}
|
||||
|
||||
private getVideoSessionsRoomName(references?: Array<{ type: string; meetingId?: string | null }>) {
|
||||
return (
|
||||
references?.filter((reference) => reference.type === CAL_VIDEO_TYPE)?.pop()?.meetingId ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async getRecordings(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName =
|
||||
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
|
||||
undefined;
|
||||
const roomName = this.getVideoSessionsRoomName(booking.references);
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
@@ -68,10 +75,7 @@ export class CalVideoService {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName =
|
||||
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
|
||||
undefined;
|
||||
|
||||
const roomName = this.getVideoSessionsRoomName(booking.references);
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
@@ -80,4 +84,19 @@ export class CalVideoService {
|
||||
|
||||
return transcripts;
|
||||
}
|
||||
|
||||
async getVideoSessions(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName = this.getVideoSessionsRoomName(booking.references);
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
|
||||
const sessions = await getCalVideoMeetingSessionsByRoomName(roomName);
|
||||
return this.calVideoOutputService.getOutputVideoSessions(sessions.data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,12 +544,18 @@ export class InputBookingsService_2024_08_13 {
|
||||
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
|
||||
newRequest.body = {
|
||||
...bodyTransformed,
|
||||
rescheduledBy: request.body.rescheduledBy,
|
||||
noEmail: !oAuthClientParams.arePlatformEmailsEnabled,
|
||||
creationSource: CreationSource.API_V2,
|
||||
};
|
||||
} else {
|
||||
Object.assign(newRequest, { userId, platformBookingLocation: location });
|
||||
newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2 };
|
||||
newRequest.body = {
|
||||
...bodyTransformed,
|
||||
noEmail: false,
|
||||
creationSource: CreationSource.API_V2,
|
||||
rescheduledBy: request.body.rescheduledBy,
|
||||
};
|
||||
}
|
||||
|
||||
return newRequest as unknown as BookingRequest;
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
ReassignBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import type { Booking, BookingSeat } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export const bookingResponsesSchema = z
|
||||
.object({
|
||||
@@ -105,14 +104,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
);
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
const rescheduledToInfo = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled
|
||||
? rescheduledToInfo?.rescheduledBy
|
||||
: databaseBooking.rescheduledBy;
|
||||
const rescheduledToUid = await this.getRescheduledToUid(databaseBooking);
|
||||
const rescheduledByEmail = await this.getRescheduledByEmail(databaseBooking);
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -121,9 +114,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason:
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||
cancellationReason: databaseBooking.cancellationReason || "",
|
||||
cancelledByEmail: databaseBooking.cancelledBy || "",
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
@@ -168,6 +160,23 @@ export class OutputBookingsService_2024_08_13 {
|
||||
};
|
||||
}
|
||||
|
||||
private async getRescheduledToUid(databaseBooking: DatabaseBooking) {
|
||||
if (!databaseBooking.rescheduled) return undefined;
|
||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(databaseBooking.uid);
|
||||
return rescheduledTo?.uid;
|
||||
}
|
||||
|
||||
private async getRescheduledByEmail(databaseBooking: DatabaseBooking) {
|
||||
if (databaseBooking.rescheduled) {
|
||||
return databaseBooking.rescheduledBy;
|
||||
}
|
||||
if (databaseBooking.fromReschedule) {
|
||||
const previousBooking = await this.bookingsRepository.getByUid(databaseBooking.fromReschedule);
|
||||
return previousBooking?.rescheduledBy ?? databaseBooking.rescheduledBy;
|
||||
}
|
||||
return databaseBooking.rescheduledBy;
|
||||
}
|
||||
|
||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||
if (databaseMetadata === null) return {};
|
||||
|
||||
@@ -227,10 +236,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason:
|
||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail:
|
||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancelledBy : undefined,
|
||||
cancellationReason: databaseBooking.cancellationReason || "",
|
||||
cancelledByEmail: databaseBooking.cancelledBy || "",
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
@@ -284,14 +291,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
const rescheduledToInfo = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled
|
||||
? rescheduledToInfo?.rescheduledBy
|
||||
: databaseBooking.rescheduledBy;
|
||||
const rescheduledToUid = await this.getRescheduledToUid(databaseBooking);
|
||||
const rescheduledByEmail = await this.getRescheduledByEmail(databaseBooking);
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -300,8 +301,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason:
|
||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
|
||||
cancellationReason: databaseBooking.cancellationReason || "",
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
@@ -428,8 +428,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason:
|
||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
|
||||
cancellationReason: databaseBooking.cancellationReason || "",
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
requiresConfirmationWillBlockSlot:
|
||||
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
|
||||
eventTypeColor: this.transformInputEventTypeColor(color),
|
||||
...this.transformInputSeatOptions(seats),
|
||||
...(seats ? this.transformInputSeatOptions(seats) : { seatsPerTimeSlot: undefined }),
|
||||
eventName: customName,
|
||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||
...maxActiveBookingsPerBooker,
|
||||
|
||||
+1
-1
@@ -318,7 +318,7 @@ const CustomFieldsSchema = z.object({
|
||||
id: z.literal("user"),
|
||||
type: z.literal("user"),
|
||||
label: z.literal("User"),
|
||||
fieldRequired: z.literal(true),
|
||||
fieldRequired: z.boolean().optional(),
|
||||
})
|
||||
),
|
||||
editable: z.enum(["user", "user-readonly"]),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Injectable, Logger as NestLogger, Scope } from "@nestjs/common";
|
||||
import type { Logger as TsLogger } from "tslog";
|
||||
|
||||
// 1. Define an interface for the settings
|
||||
interface IMyLoggerSettings {
|
||||
minLevel: number; // Example: 0=debug, 1=info, 2=warn, 3=error
|
||||
displayTimestamp: boolean;
|
||||
logFormat: "pretty" | "json" | "simple";
|
||||
// Add any other settings you need, mimicking tslog or your own requirements
|
||||
// Add unknown other settings you need, mimicking tslog or your own requirements
|
||||
// e.g., name?: string; displayFunctionName?: boolean; etc.
|
||||
}
|
||||
|
||||
@@ -23,7 +24,10 @@ const LogLevel = {
|
||||
* (e.g., sending to Axiom) and adds an optional prefix for context.
|
||||
*/
|
||||
@Injectable({ scope: Scope.TRANSIENT }) // TRANSIENT ensures getSubLogger provides truly independent instances if needed elsewhere
|
||||
export class Logger {
|
||||
export class Logger
|
||||
implements
|
||||
Pick<TsLogger<unknown>, "log" | "silly" | "trace" | "debug" | "info" | "warn" | "error" | "getSubLogger">
|
||||
{
|
||||
// Use NestLogger for the actual logging output
|
||||
private readonly nestLogger = new NestLogger("LoggerBridge");
|
||||
// Prefix to add to messages for this instance
|
||||
@@ -57,50 +61,68 @@ export class Logger {
|
||||
* @param options.prefix - An array of strings to join as the log prefix.
|
||||
* @returns A new LoggerBridge instance with the specified prefix.
|
||||
*/
|
||||
getSubLogger(options: { prefix?: string[] }): Logger {
|
||||
getSubLogger(options: { prefix?: string[] }): TsLogger<unknown> {
|
||||
const newLogger = new Logger();
|
||||
// Set the prefix for the *new* instance
|
||||
newLogger.prefix = options?.prefix?.join(" ") ?? "";
|
||||
return newLogger;
|
||||
return newLogger as unknown as TsLogger<unknown>;
|
||||
}
|
||||
|
||||
// --- Public logging methods ---
|
||||
|
||||
info(...args: any[]) {
|
||||
this.settings.minLevel <= 1 && this.logInternal("log", ...args);
|
||||
log(...args: unknown[]): undefined {
|
||||
if (this.settings.minLevel <= LogLevel.INFO) this.logInternal("log", ...args);
|
||||
}
|
||||
|
||||
warn(...args: any[]) {
|
||||
this.settings.minLevel <= 2 && this.logInternal("warn", ...args);
|
||||
info(...args: unknown[]): undefined {
|
||||
if (this.settings.minLevel <= 1) {
|
||||
this.logInternal("log", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
this.settings.minLevel <= 3 && this.logInternal("error", ...args);
|
||||
warn(...args: unknown[]): undefined {
|
||||
if (this.settings.minLevel <= 2) {
|
||||
this.logInternal("warn", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
debug(...args: any[]) {
|
||||
this.settings.minLevel === 0 && this.logInternal("debug", ...args);
|
||||
error(...args: unknown[]): undefined {
|
||||
if (this.settings.minLevel <= 3) {
|
||||
this.logInternal("error", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
trace(...args: any[]) {
|
||||
this.settings.minLevel === 0 && this.logInternal("verbose", ...args);
|
||||
debug(...args: unknown[]): undefined {
|
||||
if (this.settings.minLevel === 0) {
|
||||
this.logInternal("debug", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
fatal(...args: any[]) {
|
||||
trace(...args: unknown[]): undefined {
|
||||
if (this.settings.minLevel === 0) {
|
||||
this.logInternal("verbose", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
fatal(...args: unknown[]): undefined {
|
||||
// Prepend FATAL: to the message and log as error
|
||||
const fatalMessage = `fatal: ${this.formatArgsAsString(args)}`;
|
||||
this.settings.minLevel <= 3 && this.logInternal("error", fatalMessage);
|
||||
if (this.settings.minLevel <= 3) {
|
||||
this.logInternal("error", fatalMessage);
|
||||
}
|
||||
}
|
||||
|
||||
silly(...args: any[]) {
|
||||
silly(...args: unknown[]): undefined {
|
||||
const sillyMessage = `silly: ${this.formatArgsAsString(args)}`;
|
||||
this.settings.minLevel === 0 && this.logInternal("verbose", sillyMessage);
|
||||
if (this.settings.minLevel === 0) {
|
||||
this.logInternal("verbose", sillyMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal logging implementation ---
|
||||
|
||||
/** Formats arguments into a single string */
|
||||
private formatArgsAsString(args: any[]): string {
|
||||
private formatArgsAsString(args: unknown[]): string {
|
||||
return args
|
||||
.map((arg) => {
|
||||
if (typeof arg === "string") {
|
||||
@@ -109,7 +131,7 @@ export class Logger {
|
||||
// Attempt to stringify non-string arguments
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return "[Unserializable Object]";
|
||||
}
|
||||
})
|
||||
@@ -117,7 +139,7 @@ export class Logger {
|
||||
}
|
||||
|
||||
/** Central method to forward logs to NestLogger */
|
||||
private logInternal(level: "log" | "warn" | "error" | "debug" | "verbose", ...args: any[]) {
|
||||
private logInternal(level: "log" | "warn" | "error" | "debug" | "verbose", ...args: unknown[]) {
|
||||
try {
|
||||
// Format message from potentially multiple arguments
|
||||
const formattedMessage = this.formatArgsAsString(args);
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booking-emails-sms-sync-tasker.service";
|
||||
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
|
||||
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
||||
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
@@ -21,12 +28,25 @@ import { Module } from "@nestjs/common";
|
||||
PrismaHostRepository,
|
||||
PrismaOOORepository,
|
||||
PrismaUserRepository,
|
||||
{
|
||||
provide: Logger,
|
||||
useFactory: () => {
|
||||
return new Logger();
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
BookingEventHandlerService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
CheckBookingLimitsService,
|
||||
HashedLinkService,
|
||||
LuckyUserService,
|
||||
BookingEmailSmsService,
|
||||
BookingEmailAndSmsTaskService,
|
||||
BookingEmailAndSmsSyncTaskerService,
|
||||
BookingEmailAndSmsTriggerTaskerService,
|
||||
BookingEmailAndSmsTasker,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
})
|
||||
export class RegularBookingModule { }
|
||||
export class RegularBookingModule {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingCancelService as BaseBookingCancelService } from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { BookingCancelService as BaseBookingCancelService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingCancelService extends BaseBookingCancelService {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingEmailSmsHandler } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEmailSmsService extends BookingEmailSmsHandler {
|
||||
constructor(logger: Logger) {
|
||||
super({
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingEventHandlerService as BaseBookingEventHandlerService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
|
||||
import { HashedLinkService } from "./hashed-link.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEventHandlerService extends BaseBookingEventHandlerService {
|
||||
constructor(hashedLinkService: HashedLinkService, bridgeLogger: Logger) {
|
||||
super({
|
||||
log: bridgeLogger,
|
||||
hashedLinkService,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@@ -17,7 +20,10 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
hashedLinkService: HashedLinkService,
|
||||
luckyUserService: LuckyUserService,
|
||||
userRepository: PrismaUserRepository
|
||||
userRepository: PrismaUserRepository,
|
||||
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker,
|
||||
featuresRepository: PrismaFeaturesRepository,
|
||||
bookingEventHandler: BookingEventHandlerService
|
||||
) {
|
||||
super({
|
||||
checkBookingAndDurationLimitsService,
|
||||
@@ -26,6 +32,9 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
hashedLinkService,
|
||||
luckyUserService,
|
||||
userRepository,
|
||||
bookingEmailAndSmsTasker,
|
||||
featuresRepository,
|
||||
bookingEventHandler,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingEmailAndSmsSyncTasker as BaseBookingEmailAndSmsSyncTaskerService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEmailAndSmsSyncTaskerService extends BaseBookingEmailAndSmsSyncTaskerService {
|
||||
constructor(bookingTaskService: BookingEmailAndSmsTaskService, logger: Logger) {
|
||||
super({
|
||||
logger,
|
||||
bookingTaskService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingEmailAndSmsTaskService as BaseBookingEmailAndSmsTaskService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEmailAndSmsTaskService extends BaseBookingEmailAndSmsTaskService {
|
||||
constructor(
|
||||
bookingEmailSmsService: BookingEmailSmsService,
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
logger: Logger
|
||||
) {
|
||||
super({
|
||||
logger,
|
||||
emailsAndSmsHandler: bookingEmailSmsService,
|
||||
bookingRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booking-emails-sms-sync-tasker.service";
|
||||
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingEmailAndSmsTasker as BaseBookingEmailAndSmsTasker } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEmailAndSmsTasker extends BaseBookingEmailAndSmsTasker {
|
||||
constructor(
|
||||
syncTasker: BookingEmailAndSmsSyncTaskerService,
|
||||
asyncTasker: BookingEmailAndSmsTriggerTaskerService,
|
||||
logger: Logger
|
||||
) {
|
||||
super({
|
||||
logger,
|
||||
asyncTasker: asyncTasker,
|
||||
syncTasker: syncTasker,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingEmailAndSmsTriggerDevTasker as BaseBookingEmailAndSmsTriggerDevTasker } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEmailAndSmsTriggerTaskerService extends BaseBookingEmailAndSmsTriggerDevTasker {
|
||||
constructor(logger: Logger) {
|
||||
super({
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/reposito
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
|
||||
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
@@ -44,6 +45,7 @@ import { Module } from "@nestjs/common";
|
||||
ManagedOrganizationsBillingService,
|
||||
OAuthClientRepository,
|
||||
BookingsRepository_2024_08_13,
|
||||
IsUserInBillingOrg,
|
||||
],
|
||||
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
|
||||
controllers: [BillingController],
|
||||
|
||||
@@ -17,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import type { Team, PlatformBilling } from "@calcom/prisma/client";
|
||||
|
||||
@@ -32,8 +32,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||
let organization: Team;
|
||||
let organization2: Team;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withNextAuth(
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
@@ -49,6 +51,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
name: `billing-organization-${randomString()}`,
|
||||
isPlatform: true,
|
||||
});
|
||||
organization2 = await organizationsRepositoryFixture.create({
|
||||
name: `billing-organization-2-${randomString()}`,
|
||||
isPlatform: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -133,7 +139,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (GET) should get billing plan for org", () => {
|
||||
it("/billing/:orgId/check (GET) should check billing plan for org", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/billing/${organization.id}/check`)
|
||||
.expect(200)
|
||||
@@ -143,6 +149,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/:organizationId/check (GET) should not be able to check other org plan", () => {
|
||||
return request(app.getHttpServer()).get(`/v2/billing/${organization2.id}/check`).expect(403);
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
||||
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import {
|
||||
@@ -49,8 +51,9 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Get("/:teamId/check")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
|
||||
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
|
||||
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
|
||||
async checkTeamBilling(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
|
||||
@@ -66,8 +69,9 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Post("/:teamId/subscribe")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
|
||||
async subscribeTeamToStripe(
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
@@ -84,8 +88,9 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Post("/:teamId/upgrade")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
|
||||
async upgradeTeamBillingInStripe(
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
@@ -100,13 +105,12 @@ export class BillingController {
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:organizationId/unsubscribe")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@Delete("/:teamId/unsubscribe")
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
async cancelTeamSubscriptionInStripe(
|
||||
@Param("organizationId") organizationId: number
|
||||
): Promise<ApiResponse> {
|
||||
await this.billingService.cancelTeamSubscription(organizationId);
|
||||
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
|
||||
async cancelTeamSubscriptionInStripe(@Param("teamId") teamId: number): Promise<ApiResponse> {
|
||||
await this.billingService.cancelTeamSubscription(teamId);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import type { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class IsUserInBillingOrg implements CanActivate {
|
||||
constructor(private organizationsRepository: OrganizationsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const orgId: string = request.params.teamId;
|
||||
const userId = (request.user as ApiAuthGuardUser).id;
|
||||
|
||||
if (!userId) {
|
||||
throw new ForbiddenException("IsUserInBillingOrg - No user id found.");
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("IsUserInBillingOrg - No org id found in request params.");
|
||||
}
|
||||
|
||||
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException(
|
||||
`IsUserInBillingOrg - user with id=${userId} is not part of the organization with id=${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+64
@@ -1230,6 +1230,70 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve seatsPerTimeSlot when doing partial update", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation with seats",
|
||||
slug: `organizations-event-types-seats-${randomString()}`,
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
},
|
||||
],
|
||||
seats: {
|
||||
seatsPerTimeSlot: 5,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: TeamEventTypeOutput_2024_06_14 = createResponse.body.data;
|
||||
const createdSeats = createdEventType.seats;
|
||||
expect(createdSeats).toBeDefined();
|
||||
expect(createdSeats && "seatsPerTimeSlot" in createdSeats).toBe(true);
|
||||
if (createdSeats && "seatsPerTimeSlot" in createdSeats) {
|
||||
expect(createdSeats.seatsPerTimeSlot).toEqual(5);
|
||||
expect(createdSeats.showAttendeeInfo).toEqual(true);
|
||||
expect(createdSeats.showAvailabilityCount).toEqual(true);
|
||||
}
|
||||
|
||||
// Now do a partial update that only changes a different field (not seats)
|
||||
const updateBody: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
bookingRequiresAuthentication: false,
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${createdEventType.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const updatedEventType: TeamEventTypeOutput_2024_06_14 = updateResponse.body.data;
|
||||
|
||||
// Verify that seatsPerTimeSlot is preserved and not reset to null
|
||||
const updatedSeats = updatedEventType.seats;
|
||||
expect(updatedSeats).toBeDefined();
|
||||
expect(updatedSeats && "seatsPerTimeSlot" in updatedSeats).toBe(true);
|
||||
if (updatedSeats && "seatsPerTimeSlot" in updatedSeats) {
|
||||
expect(updatedSeats.seatsPerTimeSlot).toEqual(5);
|
||||
expect(updatedSeats.showAttendeeInfo).toEqual(true);
|
||||
expect(updatedSeats.showAvailabilityCount).toEqual(true);
|
||||
}
|
||||
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
|
||||
});
|
||||
|
||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
||||
expect(expected.userId).toEqual(received?.userId);
|
||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
||||
|
||||
@@ -188,7 +188,8 @@ export class SlotsController_2024_09_04 {
|
||||
@DocsResponse({
|
||||
status: 200,
|
||||
description: `A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.
|
||||
For seated slots each object will have attendeesCount and bookingUid properties.`,
|
||||
For seated slots each object will have attendeesCount and bookingUid properties.
|
||||
If no slots are available, the data field will be an empty object {}.`,
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
|
||||
@@ -45,8 +45,8 @@ export function ShellMainAppDir(props: LayoutProps) {
|
||||
className={classNames(
|
||||
props.backPath
|
||||
? "relative"
|
||||
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-32 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
|
||||
"flex-shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
|
||||
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-20 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
|
||||
"shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
|
||||
)}>
|
||||
{props.CTA}
|
||||
</div>
|
||||
|
||||
@@ -64,8 +64,8 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
const organizationRepository = getOrganizationRepository();
|
||||
const isOrgPrivate = organizationId
|
||||
? await organizationRepository.checkIfPrivate({
|
||||
orgId: organizationId,
|
||||
})
|
||||
orgId: organizationId,
|
||||
})
|
||||
: false;
|
||||
|
||||
const permissionService = new PermissionCheckService();
|
||||
@@ -81,12 +81,7 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={
|
||||
<AvailabilityCTA
|
||||
toggleGroupOptions={[
|
||||
{ value: "mine", label: t("my_availability") },
|
||||
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
|
||||
]}
|
||||
/>
|
||||
<AvailabilityCTA canViewTeamAvailability={canViewTeamAvailability} />
|
||||
}>
|
||||
{searchParams?.type === "team" && canViewTeamAvailability ? (
|
||||
<AvailabilitySliderTable isOrg={!!organizationId} />
|
||||
|
||||
@@ -9,16 +9,12 @@ import { AvailabilityCTA } from "~/availability/availability-view";
|
||||
|
||||
export default function AvailabilityLoader() {
|
||||
const { t } = useLocale();
|
||||
const toggleGroupOptions = [
|
||||
{ value: "mine", label: t("my_availability") },
|
||||
{ value: "team", label: t("team_availability") },
|
||||
];
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<AvailabilityCTA toggleGroupOptions={toggleGroupOptions} />}>
|
||||
CTA={<AvailabilityCTA canViewTeamAvailability />}>
|
||||
<SkeletonLoader />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -13,6 +14,9 @@ export const TeamsCTA = () => {
|
||||
StartIcon="plus"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
posthog.capture("add_team_button_clicked")
|
||||
}}
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
|
||||
@@ -6,11 +6,10 @@ import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing"
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { TeamsCTA } from "./CTA";
|
||||
|
||||
const getCachedTeams = unstable_cache(
|
||||
@@ -52,7 +51,7 @@ export const ServerTeamsListing = async ({
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
if (e instanceof ErrorWithCode) errorMsgFromInvite = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ function Field({
|
||||
data-testid={`${hookFieldNamespace}.label`}
|
||||
disabled={!!router}
|
||||
label="Label"
|
||||
className="flex-grow"
|
||||
className="grow"
|
||||
placeholder={t("this_is_what_your_users_would_see")}
|
||||
defaultValue={label || routerField?.label || "Field"}
|
||||
required
|
||||
@@ -189,7 +189,7 @@ function Field({
|
||||
/>
|
||||
</div>
|
||||
{["select", "multiselect"].includes(fieldType) ? (
|
||||
<div className="bg-muted w-full rounded-[10px] p-2">
|
||||
<div className="bg-cal-muted w-full rounded-[10px] p-2">
|
||||
<Label className="text-subtle">{t("options")}</Label>
|
||||
<MultiOptionInput
|
||||
fieldArrayName={`${hookFieldNamespace}.options`}
|
||||
@@ -319,7 +319,7 @@ const FormEdit = ({
|
||||
) : (
|
||||
<div className="w-full py-4 lg:py-8">
|
||||
{/* TODO: remake empty screen for V3 */}
|
||||
<div className="border-sublte bg-muted flex flex-col items-center gap-6 rounded-xl border p-11">
|
||||
<div className="border-sublte bg-cal-muted flex flex-col items-center gap-6 rounded-xl border p-11">
|
||||
<div className="mb-3 grid">
|
||||
{/* Icon card - Top */}
|
||||
<div className="bg-default border-subtle z-30 col-start-1 col-end-1 row-start-1 row-end-1 h-10 w-10 transform rounded-md border shadow-sm">
|
||||
|
||||
@@ -122,7 +122,7 @@ function Page({ form }: { form: RoutingFormWithResponseCount }) {
|
||||
/>
|
||||
</div>
|
||||
{salesforceActionEnabled ? (
|
||||
<div className="bg-muted mt-1 rounded-xl p-2">
|
||||
<div className="bg-cal-muted mt-1 rounded-xl p-2">
|
||||
{form.team && (
|
||||
<>
|
||||
<div className="bg-default mt-2 rounded-xl px-2 py-2">
|
||||
|
||||
@@ -307,7 +307,7 @@ const WeightedAttributesSelector = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-muted mt-1 rounded-xl p-2">
|
||||
<div className="bg-cal-muted mt-1 rounded-xl p-2">
|
||||
{attributeIdForWeights ? (
|
||||
<SelectField
|
||||
size="sm"
|
||||
@@ -652,21 +652,21 @@ const Route = ({
|
||||
<div
|
||||
className={classNames(
|
||||
"cal-query-builder-card w-full gap-2 p-2",
|
||||
route.isFallback && "bg-muted border-subtle rounded-xl border"
|
||||
route.isFallback && "bg-cal-muted border-subtle rounded-xl border"
|
||||
)}>
|
||||
<div className="cal-query-builder w-full ">
|
||||
{formFieldsQueryBuilder}
|
||||
<div>
|
||||
{route.isFallback ? (
|
||||
<div className="flex w-full flex-col gap-2 text-sm lg:flex-row">
|
||||
<div className="flex flex-grow items-center gap-2">
|
||||
{/* <div className="flex flex-grow-0 whitespace-nowrap">
|
||||
<div className="flex grow items-center gap-2">
|
||||
{/* <div className="flex grow-0 whitespace-nowrap">
|
||||
<span>{t("send_booker_to")}</span>
|
||||
</div> */}
|
||||
<Select
|
||||
size="sm"
|
||||
isDisabled={disabled}
|
||||
className="data-testid-select-routing-action block w-full flex-grow"
|
||||
className="data-testid-select-routing-action block w-full grow"
|
||||
required
|
||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
||||
onChange={(item) => {
|
||||
@@ -695,7 +695,7 @@ const Route = ({
|
||||
required
|
||||
disabled={disabled}
|
||||
name="customPageMessage"
|
||||
className="border-default flex flex-grow lg:w-fit"
|
||||
className="border-default flex grow lg:w-fit"
|
||||
style={{
|
||||
minHeight: "38px",
|
||||
}}
|
||||
@@ -708,8 +708,8 @@ const Route = ({
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
name="externalRedirectUrl"
|
||||
className="border-default flex flex-grow text-sm"
|
||||
containerClassName="flex-grow"
|
||||
className="border-default flex grow text-sm"
|
||||
containerClassName="grow"
|
||||
type="url"
|
||||
required
|
||||
labelSrOnly
|
||||
@@ -720,7 +720,7 @@ const Route = ({
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-grow">
|
||||
<div className="grow">
|
||||
<Select
|
||||
size="sm"
|
||||
required
|
||||
@@ -758,8 +758,8 @@ const Route = ({
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
className="border-default flex w-full flex-grow text-sm"
|
||||
containerClassName="flex-grow mt-2"
|
||||
className="border-default flex w-full grow text-sm"
|
||||
containerClassName="grow mt-2"
|
||||
addOnLeading={eventTypePrefix}
|
||||
required
|
||||
value={customEventTypeSlug}
|
||||
@@ -796,12 +796,12 @@ const Route = ({
|
||||
</div>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">Send booker to</span>
|
||||
</div>
|
||||
<div className="bg-muted flex w-full flex-col gap-2 rounded-xl p-2 text-sm lg:flex-row">
|
||||
<div className="flex flex-grow items-center gap-2">
|
||||
<div className="bg-cal-muted flex w-full flex-col gap-2 rounded-xl p-2 text-sm lg:flex-row">
|
||||
<div className="flex grow items-center gap-2">
|
||||
<Select
|
||||
size="sm"
|
||||
isDisabled={disabled}
|
||||
className="data-testid-select-routing-action block w-full flex-grow"
|
||||
className="data-testid-select-routing-action block w-full grow"
|
||||
required
|
||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
||||
onChange={(item) => {
|
||||
@@ -830,7 +830,7 @@ const Route = ({
|
||||
required
|
||||
disabled={disabled}
|
||||
name="customPageMessage"
|
||||
className="border-default flex flex-grow lg:w-fit"
|
||||
className="border-default flex grow lg:w-fit"
|
||||
style={{
|
||||
minHeight: "38px",
|
||||
}}
|
||||
@@ -844,8 +844,8 @@ const Route = ({
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
name="externalRedirectUrl"
|
||||
className="border-default flex flex-grow text-sm"
|
||||
containerClassName="flex-grow"
|
||||
className="border-default flex grow text-sm"
|
||||
containerClassName="grow"
|
||||
type="url"
|
||||
required
|
||||
labelSrOnly
|
||||
@@ -856,7 +856,7 @@ const Route = ({
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-grow">
|
||||
<div className="grow">
|
||||
<Select
|
||||
size="sm"
|
||||
required
|
||||
@@ -894,8 +894,8 @@ const Route = ({
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
className="border-default flex w-full flex-grow text-sm"
|
||||
containerClassName="flex-grow mt-2"
|
||||
className="border-default flex w-full grow text-sm"
|
||||
containerClassName="grow mt-2"
|
||||
addOnLeading={eventTypePrefix}
|
||||
required
|
||||
value={customEventTypeSlug}
|
||||
|
||||
@@ -177,7 +177,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
<>
|
||||
<div className={classNames("mx-auto my-0 max-w-3xl", isEmbed ? "" : "md:my-24")}>
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
<div className="main border-booker md:border-booker-width dark:bg-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 ">
|
||||
<div className="main border-booker md:border-booker-width dark:bg-cal-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 ">
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
<form onSubmit={handleOnSubmit}>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { isFallbackRoute } from "@calcom/app-store/routing-forms/lib/isFallbackRoute";
|
||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
||||
@@ -46,6 +47,7 @@ function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNe
|
||||
data-testid="new-routing-form"
|
||||
createFunction={(teamId) => {
|
||||
setNewFormDialogState({ action: "new", target: teamId ? String(teamId) : "" });
|
||||
posthog.capture("new_routing_form_button_clicked", { teamId });
|
||||
}}
|
||||
withPermission={{
|
||||
permission: "routingForm.create",
|
||||
@@ -76,6 +78,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
hookForm.reset({});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const filters = getTeamsFiltersFromQuery(routerQuery);
|
||||
|
||||
@@ -160,7 +163,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
|
||||
background="/tips/routing-forms"
|
||||
isParentLoading={<SkeletonLoaderTeamList />}
|
||||
buttons={
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
{t("upgrade")}
|
||||
@@ -278,7 +281,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
|
||||
action="edit"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="!flex"
|
||||
className="flex!"
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</FormAction>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AvailabilityScheduleSkeleton() {
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="relative flex-shrink-0 md:relative md:bottom-auto md:right-auto">
|
||||
<div className="relative shrink-0 md:relative md:bottom-auto md:right-auto">
|
||||
{/* Desktop buttons - Set to Default, Delete, and Save */}
|
||||
<div className="flex items-center justify-end">
|
||||
{/* Set to Default - desktop only */}
|
||||
@@ -99,14 +99,14 @@ export function AvailabilityScheduleSkeleton() {
|
||||
<SkeletonText className="ml-2 h-4 w-4 rounded-full" /> {/* Info icon */}
|
||||
</div>
|
||||
<SkeletonText className="mb-4 mt-1 h-4 w-full max-w-md text-sm" /> {/* Description */}
|
||||
<div className="space-y-2">
|
||||
<div className="stack-y-2">
|
||||
<SkeletonButton className="h-9 w-36 rounded-md" /> {/* Add an override button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - only visible on desktop */}
|
||||
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1 xl:w-[25%]">
|
||||
<div className="min-w-40 col-span-3 hidden stack-y-2 md:block lg:col-span-1 xl:w-[25%]">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<SkeletonText className="mb-1 h-4 w-24 text-sm" /> {/* Timezone */}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Icon } from "@calcom/ui/components/icon";
|
||||
export default function TroubleshooterLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<div className="flex flex-1 *:flex-1">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -32,8 +32,8 @@ export const EventTypeEditPageSkeleton = () => {
|
||||
<Shell subtitle={<SkeletonText className="hidden h-4 w-24" />} afterHeading={backButtonSkeleton}>
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-6">
|
||||
<div className="hidden xl:block">
|
||||
<div className="primary-navigation w-64 flex-shrink-0">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="primary-navigation w-64 shrink-0">
|
||||
<div className="flex flex-col stack-y-1">
|
||||
{/* Tab navigation items */}
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
@@ -58,14 +58,14 @@ export const EventTypeEditPageSkeleton = () => {
|
||||
<SkeletonText className="h-8 w-full max-w-md" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 space-y-6">
|
||||
<div className="mb-6 stack-y-6">
|
||||
{/* Form fields */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col stack-y-2">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col stack-y-2">
|
||||
<SkeletonText className="h-4 w-40" />
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
@@ -73,12 +73,12 @@ export const EventTypeEditPageSkeleton = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col stack-y-2">
|
||||
<SkeletonText className="h-4 w-36" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col stack-y-2">
|
||||
<SkeletonText className="h-4 w-28" />
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
@@ -86,7 +86,7 @@ export const EventTypeEditPageSkeleton = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col stack-y-2">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function UpgradeTipWrapper({ children }: { children: React.ReactN
|
||||
features={features}
|
||||
background="/tips/insights"
|
||||
buttons={
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
{t("create_team")}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view";
|
||||
import { TeamInviteView } from "~/onboarding/teams/invite/team-invite-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
@@ -26,13 +27,14 @@ const ServerPage = async () => {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
if (session.user.role !== "ADMIN") {
|
||||
return redirect("/onboarding/teams/invite/email");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <TeamInviteView userEmail={userEmail} />;
|
||||
// If user is not ADMIN, show the email view directly instead of redirecting
|
||||
if (session.user.role !== "ADMIN") {
|
||||
return <TeamInviteEmailView userEmail={userEmail} />;
|
||||
}
|
||||
|
||||
return <TeamInviteEmailView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -109,7 +109,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<div className="bg-cal-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<PayIcon currency={paymentAppData.currency} className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Loading() {
|
||||
<SkeletonText className="h-5 w-32" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 space-y-2">
|
||||
<div className="mb-6 stack-y-2">
|
||||
<SkeletonText className="h-7 w-3/4" />
|
||||
<SkeletonText className="h-7 w-1/2" />
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function AdminLayoutAppDirClient({ children }: AdminLayoutProps)
|
||||
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
|
||||
return (
|
||||
<div className="divide-subtle bg-default mx-auto flex max-w-4xl flex-row divide-y">
|
||||
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
|
||||
<div className={isAppsPage ? "min-w-0" : "flex flex-1 *:flex-1"}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ const sampleBookingsByHourStats = [
|
||||
export default function BookingsByHourPlayground() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="stack-y-6 p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Bookings by Hour Playground</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import Link from "next/link";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
title: "Routing Funnel",
|
||||
@@ -15,20 +18,32 @@ const LINKS = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Playground</h1>
|
||||
|
||||
<ul className="mt-8">
|
||||
{LINKS.map((link) => (
|
||||
<li key={link.title}>
|
||||
<Link href={link.href} className="list-item list-disc font-medium underline">
|
||||
{link.title} →
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("playground"),
|
||||
(t) => t("admin_playground_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/admin/playground"
|
||||
);
|
||||
}
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader title={t("playground")} description={t("admin_playground_description")}>
|
||||
<div>
|
||||
<ul className="mt-8">
|
||||
{LINKS.map((link) => (
|
||||
<li key={link.title}>
|
||||
<Link href={link.href} className="list-item list-disc font-medium underline">
|
||||
{link.title} →
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ const sampleRoutingFunnelData = [
|
||||
export default function RoutingFunnelPlayground() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="stack-y-6 p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Routing Funnel Playground</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
|
||||
+3
-3
@@ -512,7 +512,7 @@ function ScenarioCard({ scenario }: { scenario: Scenario }) {
|
||||
|
||||
export default function WeeklyCalendarPlayground() {
|
||||
return (
|
||||
<div className="space-y-8 p-6">
|
||||
<div className="stack-y-8 p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Weekly Calendar Playground</h1>
|
||||
<p className="text-subtle mt-2">
|
||||
@@ -522,7 +522,7 @@ export default function WeeklyCalendarPlayground() {
|
||||
</div>
|
||||
|
||||
{/* Grid View of All Scenarios */}
|
||||
<div className="space-y-8">
|
||||
<div className="stack-y-8">
|
||||
<h2 className="mb-4 text-2xl font-bold">All Scenarios (Side-by-Side)</h2>
|
||||
{scenarios.map((scenario, index) => (
|
||||
<ScenarioCard key={index} scenario={scenario} />
|
||||
@@ -532,7 +532,7 @@ export default function WeeklyCalendarPlayground() {
|
||||
{/* Testing Checklist */}
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-bold">Testing Checklist</h2>
|
||||
<ul className="text-subtle space-y-2 text-sm">
|
||||
<ul className="text-subtle stack-y-2 text-sm">
|
||||
<li>✓ Visual appearance matches expectations (80% width with 8% cascading offsets)</li>
|
||||
<li>✓ Hover behavior works smoothly - hovered event appears topmost</li>
|
||||
<li>✓ No visual glitches with 3+ overlapping events</li>
|
||||
|
||||
+104
-71
@@ -37,13 +37,13 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
href: "/settings/my-account",
|
||||
icon: "user",
|
||||
children: [
|
||||
{ name: "profile", href: "/settings/my-account/profile" },
|
||||
{ name: "general", href: "/settings/my-account/general" },
|
||||
{ name: "calendars", href: "/settings/my-account/calendars" },
|
||||
{ name: "conferencing", href: "/settings/my-account/conferencing" },
|
||||
{ name: "appearance", href: "/settings/my-account/appearance" },
|
||||
{ name: "out_of_office", href: "/settings/my-account/out-of-office" },
|
||||
{ name: "push_notifications", href: "/settings/my-account/push-notifications" },
|
||||
{ name: "profile", href: "/settings/my-account/profile", trackingMetadata: { section: "my_account", page: "profile" } },
|
||||
{ name: "general", href: "/settings/my-account/general", trackingMetadata: { section: "my_account", page: "general" } },
|
||||
{ name: "calendars", href: "/settings/my-account/calendars", trackingMetadata: { section: "my_account", page: "calendars" } },
|
||||
{ name: "conferencing", href: "/settings/my-account/conferencing", trackingMetadata: { section: "my_account", page: "conferencing" } },
|
||||
{ name: "appearance", href: "/settings/my-account/appearance", trackingMetadata: { section: "my_account", page: "appearance" } },
|
||||
{ name: "out_of_office", href: "/settings/my-account/out-of-office", trackingMetadata: { section: "my_account", page: "out_of_office" } },
|
||||
{ name: "push_notifications", href: "/settings/my-account/push-notifications", trackingMetadata: { section: "my_account", page: "push_notifications" } },
|
||||
// TODO
|
||||
// { name: "referrals", href: "/settings/my-account/referrals" },
|
||||
],
|
||||
@@ -53,16 +53,16 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
href: "/settings/security",
|
||||
icon: "key",
|
||||
children: [
|
||||
{ name: "password", href: "/settings/security/password" },
|
||||
{ name: "impersonation", href: "/settings/security/impersonation" },
|
||||
{ name: "2fa_auth", href: "/settings/security/two-factor-auth" },
|
||||
{ name: "password", href: "/settings/security/password", trackingMetadata: { section: "security", page: "password" } },
|
||||
{ name: "impersonation", href: "/settings/security/impersonation", trackingMetadata: { section: "security", page: "impersonation" } },
|
||||
{ name: "2fa_auth", href: "/settings/security/two-factor-auth", trackingMetadata: { section: "security", page: "2fa_auth" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/billing",
|
||||
icon: "credit-card",
|
||||
children: [{ name: "manage_billing", href: "/settings/billing" }],
|
||||
children: [{ name: "manage_billing", href: "/settings/billing", trackingMetadata: { section: "billing", page: "manage_billing" } }],
|
||||
},
|
||||
{
|
||||
name: "developer",
|
||||
@@ -70,9 +70,9 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
icon: "terminal",
|
||||
children: [
|
||||
//
|
||||
{ name: "webhooks", href: "/settings/developer/webhooks" },
|
||||
{ name: "api_keys", href: "/settings/developer/api-keys" },
|
||||
{ name: "admin_api", href: "/settings/organizations/admin-api" },
|
||||
{ name: "webhooks", href: "/settings/developer/webhooks", trackingMetadata: { section: "developer", page: "webhooks" } },
|
||||
{ name: "api_keys", href: "/settings/developer/api-keys", trackingMetadata: { section: "developer", page: "api_keys" } },
|
||||
{ name: "admin_api", href: "/settings/organizations/admin-api", trackingMetadata: { section: "developer", page: "admin_api" } },
|
||||
// TODO: Add profile level for embeds
|
||||
// { name: "embeds", href: "/v2/settings/developer/embeds" },
|
||||
],
|
||||
@@ -84,36 +84,47 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
{
|
||||
name: "profile",
|
||||
href: "/settings/organizations/profile",
|
||||
trackingMetadata: { section: "organization", page: "profile" },
|
||||
},
|
||||
{
|
||||
name: "general",
|
||||
href: "/settings/organizations/general",
|
||||
trackingMetadata: { section: "organization", page: "general" },
|
||||
},
|
||||
{
|
||||
name: "guest_notifications",
|
||||
href: "/settings/organizations/guest-notifications",
|
||||
},
|
||||
...(orgBranding
|
||||
? [
|
||||
{
|
||||
name: "members",
|
||||
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
|
||||
isExternalLink: true,
|
||||
},
|
||||
]
|
||||
{
|
||||
name: "members",
|
||||
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
|
||||
isExternalLink: true,
|
||||
trackingMetadata: { section: "organization", page: "members" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "privacy_and_security",
|
||||
href: "/settings/organizations/privacy",
|
||||
trackingMetadata: { section: "organization", page: "privacy_and_security" },
|
||||
},
|
||||
{
|
||||
name: "SSO",
|
||||
href: "/settings/organizations/sso",
|
||||
trackingMetadata: { section: "organization", page: "sso" },
|
||||
},
|
||||
{
|
||||
name: "directory_sync",
|
||||
href: "/settings/organizations/dsync",
|
||||
trackingMetadata: { section: "organization", page: "directory_sync" },
|
||||
},
|
||||
{
|
||||
name: "admin_api",
|
||||
href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access",
|
||||
isExternalLink: true,
|
||||
trackingMetadata: { section: "organization", page: "admin_api" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -135,31 +146,31 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
icon: "lock",
|
||||
children: [
|
||||
//
|
||||
{ name: "features", href: "/settings/admin/flags" },
|
||||
{ name: "license", href: "/auth/setup?step=1" },
|
||||
{ name: "impersonation", href: "/settings/admin/impersonation" },
|
||||
{ name: "apps", href: "/settings/admin/apps/calendar" },
|
||||
{ name: "users", href: "/settings/admin/users" },
|
||||
{ name: "organizations", href: "/settings/admin/organizations" },
|
||||
{ name: "lockedSMS", href: "/settings/admin/lockedSMS" },
|
||||
{ name: "oAuth", href: "/settings/admin/oAuth" },
|
||||
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms" },
|
||||
{ name: "Playground", href: "/settings/admin/playground" },
|
||||
{ name: "features", href: "/settings/admin/flags", trackingMetadata: { section: "admin", page: "features" } },
|
||||
{ name: "license", href: "/auth/setup?step=1", trackingMetadata: { section: "admin", page: "license" } },
|
||||
{ name: "impersonation", href: "/settings/admin/impersonation", trackingMetadata: { section: "admin", page: "impersonation" } },
|
||||
{ name: "apps", href: "/settings/admin/apps/calendar", trackingMetadata: { section: "admin", page: "apps" } },
|
||||
{ name: "users", href: "/settings/admin/users", trackingMetadata: { section: "admin", page: "users" } },
|
||||
{ name: "organizations", href: "/settings/admin/organizations", trackingMetadata: { section: "admin", page: "organizations" } },
|
||||
{ name: "lockedSMS", href: "/settings/admin/lockedSMS", trackingMetadata: { section: "admin", page: "locked_sms" } },
|
||||
{ name: "oAuth", href: "/settings/admin/oAuth", trackingMetadata: { section: "admin", page: "oauth" } },
|
||||
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms", trackingMetadata: { section: "admin", page: "workspace_platforms" } },
|
||||
{ name: "Playground", href: "/settings/admin/playground", trackingMetadata: { section: "admin", page: "playground" } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
tabs.find((tab) => {
|
||||
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
|
||||
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso" });
|
||||
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso", trackingMetadata: { section: "security", page: "sso_configuration" } });
|
||||
// TODO: Enable dsync for self hosters
|
||||
// tab.children?.push({ name: "directory_sync", href: "/settings/security/dsync" });
|
||||
}
|
||||
if (tab.name === "admin" && IS_CALCOM) {
|
||||
tab.children?.push({ name: "create_org", href: "/settings/organizations/new" });
|
||||
tab.children?.push({ name: "create_org", href: "/settings/organizations/new", trackingMetadata: { section: "admin", page: "create_org" } });
|
||||
}
|
||||
if (tab.name === "admin" && IS_CALCOM) {
|
||||
tab.children?.push({ name: "create_license_key", href: "/settings/license-key/new" });
|
||||
tab.children?.push({ name: "create_license_key", href: "/settings/license-key/new", trackingMetadata: { section: "admin", page: "create_license_key" } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -181,6 +192,7 @@ export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
canViewOrganizationBilling?: boolean;
|
||||
canUpdateOrganization?: boolean;
|
||||
canViewAttributes?: boolean;
|
||||
}
|
||||
|
||||
const useTabs = ({
|
||||
@@ -211,10 +223,11 @@ const useTabs = ({
|
||||
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
|
||||
);
|
||||
|
||||
if (permissions?.canUpdateOrganization) {
|
||||
if (permissions?.canViewAttributes) {
|
||||
newArray.splice(4, 0, {
|
||||
name: "attributes",
|
||||
href: "/settings/organizations/attributes",
|
||||
trackingMetadata: { section: "organization", page: "attributes" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,6 +236,7 @@ const useTabs = ({
|
||||
newArray.push({
|
||||
name: "delegation_credential",
|
||||
href: "/settings/organizations/delegation-credential",
|
||||
trackingMetadata: { section: "organization", page: "delegation_credential" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -232,6 +246,7 @@ const useTabs = ({
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
trackingMetadata: { section: "organization", page: "roles_and_permissions" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,6 +254,7 @@ const useTabs = ({
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
trackingMetadata: { section: "organization", page: "billing" },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -246,6 +262,7 @@ const useTabs = ({
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
trackingMetadata: { section: "organization", page: "billing" },
|
||||
});
|
||||
}
|
||||
// Show roles page (modal will appear if PBAC not enabled)
|
||||
@@ -253,7 +270,8 @@ const useTabs = ({
|
||||
newArray.push({
|
||||
name: "roles",
|
||||
href: "/settings/organizations/roles",
|
||||
isBadged: true, // Show "New" badge
|
||||
isBadged: true, // Show "New" badge,
|
||||
trackingMetadata: { section: "organization", page: "roles" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -344,6 +362,7 @@ const TeamRolesNavItem = ({
|
||||
<VerticalTabItem
|
||||
name={t("roles_and_permissions")}
|
||||
href={`/settings/teams/${team.id}/roles`}
|
||||
trackingMetadata={{ section: "team", page: "roles_and_permissions", teamId: team.id }}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
@@ -356,22 +375,26 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
const [teamMenuState, setTeamMenuState] =
|
||||
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const pathname = usePathname();
|
||||
const searchParamsId = searchParams?.get("id");
|
||||
const pathTeamId = pathname?.match(/\/settings\/teams\/(\d+)/)?.[1];
|
||||
const activeTeamId = pathTeamId || searchParamsId;
|
||||
|
||||
useEffect(() => {
|
||||
if (teams) {
|
||||
const teamStates = teams?.map((team) => ({
|
||||
teamId: team.id,
|
||||
teamMenuOpen: String(team.id) === searchParamsId,
|
||||
teamMenuOpen: String(team.id) === activeTeamId,
|
||||
}));
|
||||
setTeamMenuState(teamStates);
|
||||
setTimeout(() => {
|
||||
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
|
||||
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
|
||||
)[1];
|
||||
tabMembers?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
if (activeTeamId) {
|
||||
setTimeout(() => {
|
||||
const teamTrigger = document.querySelector(`[aria-controls="team-content-${activeTeamId}"]`);
|
||||
teamTrigger?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [searchParamsId, teams]);
|
||||
}, [activeTeamId, teams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -419,9 +442,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
setTeamMenuState(newTeamMenuState);
|
||||
}
|
||||
}}
|
||||
aria-label={`${team.name} ${
|
||||
teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
|
||||
}`}>
|
||||
aria-label={`${team.name} ${teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
|
||||
}`}>
|
||||
<div className="me-3">
|
||||
{teamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
@@ -429,6 +451,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{ }
|
||||
{!team.parentId && (
|
||||
<Image
|
||||
src={getPlaceholderAvatar(team.logoUrl, team.name)}
|
||||
@@ -446,21 +469,23 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-0.5" id={`team-content-${team.id}`}>
|
||||
<CollapsibleContent className="stack-y-0.5" id={`team-content-${team.id}`}>
|
||||
{team.accepted && (
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
trackingMetadata={{ section: "team", page: "profile", teamId: team.id }}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/teams/${team.id}/members`}
|
||||
trackingMetadata={{ section: "team", page: "members", teamId: team.id }}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Show roles only for sub-teams with PBAC-enabled parent */}
|
||||
@@ -469,9 +494,9 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore this exists wtf?
|
||||
(team.isOrgAdmin && team.isOrgAdmin)) && (
|
||||
<>
|
||||
{/* TODO */}
|
||||
{/* <VerticalTabItem
|
||||
<>
|
||||
{/* TODO */}
|
||||
{/* <VerticalTabItem
|
||||
name={t("general")}
|
||||
href={`${WEBAPP_URL}/settings/my-account/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
@@ -481,7 +506,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("appearance")}
|
||||
href={`/settings/teams/${team.id}/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
trackingMetadata={{ section: "team", page: "appearance", teamId: team.id }}
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Hide if there is a parent ID */}
|
||||
@@ -491,7 +517,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("billing")}
|
||||
href={`/settings/teams/${team.id}/billing`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
trackingMetadata={{ section: "team", page: "billing", teamId: team.id }}
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
@@ -500,7 +527,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("settings")}
|
||||
href={`/settings/teams/${team.id}/settings`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
trackingMetadata={{ section: "team", page: "settings", teamId: team.id }}
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
@@ -578,7 +606,7 @@ const SettingsSidebarContainer = ({
|
||||
<nav
|
||||
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
|
||||
className={classNames(
|
||||
"no-scrollbar bg-muted fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col space-y-1 overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
|
||||
"no-scrollbar bg-cal-muted stack-y-1 fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
|
||||
className,
|
||||
navigationIsOpenedOnMobile
|
||||
? "translate-x-0 opacity-100"
|
||||
@@ -592,7 +620,7 @@ const SettingsSidebarContainer = ({
|
||||
<React.Fragment key={tab.href}>
|
||||
{!["teams", "other_teams"].includes(tab.name) && (
|
||||
<React.Fragment key={tab.href}>
|
||||
<div className={`${!tab.children?.length ? "!mb-3" : ""}`}>
|
||||
<div className={`${!tab.children?.length ? "mb-3!" : ""}`}>
|
||||
<div className="[&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default group flex h-7 w-full flex-row items-center rounded-md px-2 text-sm font-medium leading-none">
|
||||
{tab && tab.icon && (
|
||||
<Icon
|
||||
@@ -600,6 +628,7 @@ const SettingsSidebarContainer = ({
|
||||
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
|
||||
/>
|
||||
)}
|
||||
{ }
|
||||
{!tab.icon && tab?.avatar && (
|
||||
<Image
|
||||
width={16}
|
||||
@@ -618,16 +647,17 @@ const SettingsSidebarContainer = ({
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-3 space-y-px">
|
||||
<div className="stack-y-px">
|
||||
{tab.children?.map((child, index) => (
|
||||
<div key={child.href} className="flex items-start gap-2">
|
||||
<VerticalTabItem
|
||||
name={t(child.name)}
|
||||
isExternalLink={child.isExternalLink}
|
||||
href={child.href || "/"}
|
||||
trackingMetadata={child.trackingMetadata}
|
||||
textClassNames="text-emphasis font-medium text-sm"
|
||||
className={`h-7 w-fit !px-2 ${
|
||||
tab.children && index === tab.children?.length - 1 && "!mb-3"
|
||||
className={`px-2! h-7 w-fit ${
|
||||
tab.children && index === tab.children?.length - 1 && "mb-3!"
|
||||
}`}
|
||||
disableChevron
|
||||
/>
|
||||
@@ -667,8 +697,9 @@ const SettingsSidebarContainer = ({
|
||||
<VerticalTabItem
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
trackingMetadata={{ section: "team", page: "add_a_team" }}
|
||||
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
icon="plus"
|
||||
disableChevron
|
||||
/>
|
||||
@@ -741,11 +772,10 @@ const SettingsSidebarContainer = ({
|
||||
setOtherTeamMenuState(newOtherTeamMenuState);
|
||||
}
|
||||
}}
|
||||
aria-label={`${otherTeam.name} ${
|
||||
otherTeamMenuState[index].teamMenuOpen
|
||||
? t("collapse_menu")
|
||||
: t("expand_menu")
|
||||
}`}>
|
||||
aria-label={`${otherTeam.name} ${otherTeamMenuState[index].teamMenuOpen
|
||||
? t("collapse_menu")
|
||||
: t("expand_menu")
|
||||
}`}>
|
||||
<div className="me-3">
|
||||
{otherTeamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
@@ -753,6 +783,7 @@ const SettingsSidebarContainer = ({
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{ }
|
||||
{!otherTeam.parentId && (
|
||||
<Image
|
||||
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
|
||||
@@ -766,20 +797,22 @@ const SettingsSidebarContainer = ({
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className="space-y-0.5"
|
||||
className="stack-y-0.5"
|
||||
id={`other-team-content-${otherTeam.id}`}>
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||
trackingMetadata={{ section: "other_team", page: "profile", teamId: otherTeam.id }}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
||||
trackingMetadata={{ section: "other_team", page: "members", teamId: otherTeam.id }}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
<>
|
||||
@@ -812,7 +845,7 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bg-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b px-2 py-2 sm:relative lg:hidden">
|
||||
<nav className="bg-cal-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b px-2 py-2 sm:relative lg:hidden">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button StartIcon="menu" color="minimal" variant="icon" onClick={props.onSideContainerOpen}>
|
||||
<span className="sr-only">{t("show_navigation")}</span>
|
||||
@@ -881,7 +914,7 @@ export default function SettingsLayoutAppDirClient({
|
||||
TopNavContainer={
|
||||
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
|
||||
}>
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<div className="*:flex-1 flex flex-1">
|
||||
<div
|
||||
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
|
||||
@@ -48,6 +48,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
let canViewRoles = false;
|
||||
let canViewOrganizationBilling = false;
|
||||
let canUpdateOrganization = false;
|
||||
let canViewAttributes = false;
|
||||
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
@@ -66,9 +67,10 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
|
||||
if (isPbacEnabled) {
|
||||
// Only fetch and apply PBAC permissions if the feature is enabled
|
||||
const [rolePermissions, organizationPermissions] = await Promise.all([
|
||||
const [rolePermissions, organizationPermissions, attributesPermissions] = await Promise.all([
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Attributes),
|
||||
]);
|
||||
|
||||
// Check if user has permission to read roles
|
||||
@@ -77,11 +79,14 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
|
||||
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
|
||||
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
|
||||
const attributesActions = PermissionMapper.toActionMap(attributesPermissions, Resource.Attributes);
|
||||
canViewAttributes = attributesActions[CrudAction.Read] ?? isOrgAdminOrOwner;
|
||||
} else {
|
||||
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
|
||||
canViewRoles = features ? isOrgAdminOrOwner : false;
|
||||
canViewOrganizationBilling = isOrgAdminOrOwner;
|
||||
canUpdateOrganization = isOrgAdminOrOwner;
|
||||
canViewAttributes = isOrgAdminOrOwner;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +95,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
<SettingsLayoutAppDirClient
|
||||
{...props}
|
||||
teamFeatures={teamFeatures ?? {}}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization }}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization, canViewAttributes }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import GuestNotificationsView from "@calcom/features/ee/organizations/pages/settings/guest-notifications";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("guest_notifications"),
|
||||
(t) => t("guest_notifications_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/guest-notifications"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await validateUserHasOrg();
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
const { canRead, canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!canRead) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("guest_notifications")} description={t("guest_notifications_description")}>
|
||||
<GuestNotificationsView permissions={{ canRead, canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+1
-1
@@ -87,7 +87,7 @@ export function AdvancedPermissionGroup({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-muted border-subtle mb-2 rounded-xl border">
|
||||
<div className="bg-cal-muted border-subtle mb-2 rounded-xl border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: Pbac
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-subtle w-full space-y-px overflow-hidden rounded-xl border border-gray-200">
|
||||
<div className="bg-subtle border-subtle w-full stack-y-px overflow-hidden rounded-xl border">
|
||||
<div className="bg-default flex gap-3 p-3">
|
||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||
<Icon name="shield-check" className="text-subtle h-5 w-5" />
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export default function RoleColorPicker({ value, onChange, disabled }: RoleColor
|
||||
type="button"
|
||||
onClick={handleColorSquareClick}
|
||||
disabled={disabled}
|
||||
className="bg-default text-default border-default hover:bg-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
className="bg-default text-default border-default hover:bg-cal-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<div
|
||||
className="h-5 w-5 rounded border shadow-sm"
|
||||
style={{ backgroundColor: value }}
|
||||
|
||||
+3
-3
@@ -183,7 +183,7 @@ export function RoleSheet({
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Form form={form} handleSubmit={onSubmit}>
|
||||
<div className="space-y-4 py-5">
|
||||
<div className="stack-y-4 py-5">
|
||||
<div className="flex items-end justify-end gap-2">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
@@ -203,7 +203,7 @@ export function RoleSheet({
|
||||
|
||||
<div className="">
|
||||
{isAdvancedMode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="stack-y-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="mb-0">{t("permissions")}</Label>
|
||||
@@ -239,7 +239,7 @@ export function RoleSheet({
|
||||
))}{" "}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted rounded-xl p-1">
|
||||
<div className="bg-cal-muted rounded-xl p-1">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<Label>{t("permissions")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ export function RolesList({
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4">
|
||||
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
|
||||
<div className="bg-cal-muted border-muted flex flex-col rounded-xl border p-px">
|
||||
{/* Roles list header */}
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ export const SkeletonLoader = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
|
||||
<div className="bg-cal-muted border-muted flex flex-col rounded-xl border p-px">
|
||||
{/* Roles list header */}
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
|
||||
|
||||
+8
-60
@@ -5,13 +5,11 @@ import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { getTeamMemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -33,7 +31,7 @@ const getCachedTeamRoles = unstable_cache(
|
||||
try {
|
||||
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
|
||||
return await roleManager.getTeamRoles(teamId);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// PBAC not enabled or error occurred, return empty array
|
||||
return [];
|
||||
}
|
||||
@@ -49,7 +47,7 @@ const getCachedTeamAttributes = unstable_cache(
|
||||
|
||||
try {
|
||||
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -79,65 +77,15 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
const organizationId = team.parentId || teamId;
|
||||
|
||||
// Load PBAC roles and attributes if available
|
||||
const [roles, attributes] = await Promise.all([
|
||||
const [roles, attributes, memberPermissions] = await Promise.all([
|
||||
getCachedTeamRoles(teamId, organizationId),
|
||||
getCachedTeamAttributes(organizationId),
|
||||
getTeamMemberPermissions({
|
||||
userId: session.user.id,
|
||||
team,
|
||||
}),
|
||||
]);
|
||||
|
||||
const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
|
||||
|
||||
// If the team is not private we allow members to list other members
|
||||
if (!team.isPrivate) {
|
||||
fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
|
||||
}
|
||||
|
||||
// Get specific PBAC permissions for team member actions
|
||||
const permissions = await getSpecificPermissions({
|
||||
userId: session.user.id,
|
||||
teamId: teamId,
|
||||
resource: Resource.Team,
|
||||
userRole: team.membership.role,
|
||||
actions: [
|
||||
CustomAction.Invite,
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.ListMembersPrivate,
|
||||
CustomAction.Impersonate,
|
||||
],
|
||||
fallbackRoles: {
|
||||
[CustomAction.Invite]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: fallbackRolesCanListMembers,
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ListMembersPrivate]: {
|
||||
roles: fallbackRolesCanListMembers,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: team.isPrivate
|
||||
? permissions[CustomAction.ListMembersPrivate]
|
||||
: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
canImpersonate: permissions[CustomAction.Impersonate],
|
||||
};
|
||||
|
||||
const facetedTeamValues = {
|
||||
roles,
|
||||
teams: [team],
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsLayoutAppDirClient containerClassName="lg:max-w-screen-2xl">
|
||||
<SettingsLayoutAppDirClient containerClassName="lg:max-w-(--breakpoint-2xl)">
|
||||
<SettingsHeader
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
|
||||
+58
-39
@@ -5,7 +5,7 @@ import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { Resource, CustomAction, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
@@ -63,50 +63,69 @@ const Page = async () => {
|
||||
}
|
||||
|
||||
// Get specific PBAC permissions for organization member actions
|
||||
const permissions = await getSpecificPermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
actions: [
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.ListMembersPrivate,
|
||||
CustomAction.Invite,
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.Impersonate,
|
||||
],
|
||||
fallbackRoles: {
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
const [orgPermissions, attributesPermissions] = await Promise.all([
|
||||
getSpecificPermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
actions: [
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.ListMembersPrivate,
|
||||
CustomAction.Invite,
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.Impersonate,
|
||||
],
|
||||
fallbackRoles: {
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
},
|
||||
[CustomAction.ListMembersPrivate]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
},
|
||||
[CustomAction.Invite]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
[CustomAction.ListMembersPrivate]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
}),
|
||||
getSpecificPermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Attributes,
|
||||
userRole: session.user.org.role,
|
||||
actions: [CrudAction.Read, CustomAction.EditUsers],
|
||||
fallbackRoles: {
|
||||
[CrudAction.Read]: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.EditUsers]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
[CustomAction.Invite]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: org.isPrivate
|
||||
? permissions[CustomAction.ListMembersPrivate]
|
||||
: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
canImpersonate: permissions[CustomAction.Impersonate],
|
||||
? orgPermissions[CustomAction.ListMembersPrivate]
|
||||
: orgPermissions[CustomAction.ListMembers],
|
||||
canInvite: orgPermissions[CustomAction.Invite],
|
||||
canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: orgPermissions[CustomAction.Remove],
|
||||
canImpersonate: orgPermissions[CustomAction.Impersonate],
|
||||
canViewAttributes: attributesPermissions[CrudAction.Read],
|
||||
canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
|
||||
};
|
||||
|
||||
const facetedTeamValues = {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface GeoContextValue {
|
||||
country: string;
|
||||
}
|
||||
|
||||
const GeoContext = createContext<GeoContextValue | undefined>(undefined);
|
||||
|
||||
export function GeoProvider({
|
||||
country,
|
||||
children,
|
||||
}: {
|
||||
country: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <GeoContext.Provider value={{ country }}>{children}</GeoContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGeo() {
|
||||
const context = useContext(GeoContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useGeo must be used within a GeoProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -4,52 +4,71 @@ import jwt from "jsonwebtoken";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
|
||||
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
|
||||
import type { OAuthTokenPayload } from "@calcom/types/oauth";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const { client_id, client_secret, grant_type } = await parseUrlFormData(req);
|
||||
const { client_id, client_secret, grant_type, refresh_token, code_verifier } = await parseUrlFormData(req);
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
return NextResponse.json({ message: "Missing client id or secret" }, { status: 400 });
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!client_id) {
|
||||
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (grant_type !== "refresh_token") {
|
||||
return NextResponse.json({ message: "grant type invalid" }, { status: 400 });
|
||||
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [hashedSecret] = generateSecret(client_secret);
|
||||
const oAuthClientRepository = new OAuthClientRepository(prisma);
|
||||
|
||||
const client = await prisma.oAuthClient.findFirst({
|
||||
where: {
|
||||
clientId: client_id,
|
||||
clientSecret: hashedSecret,
|
||||
},
|
||||
select: {
|
||||
redirectUri: true,
|
||||
},
|
||||
});
|
||||
const client = await oAuthClientRepository.findByClientId(client_id);
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
|
||||
}
|
||||
|
||||
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||
const isValidClient = OAuthService.validateClient(client, client_secret);
|
||||
|
||||
if (!isValidClient) {
|
||||
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
|
||||
}
|
||||
|
||||
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
|
||||
|
||||
let decodedRefreshToken: OAuthTokenPayload;
|
||||
|
||||
try {
|
||||
const refreshToken = req.headers.get("authorization")?.split(" ")[1] || "";
|
||||
decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload;
|
||||
const refreshTokenValue = refresh_token || req.headers.get("authorization")?.split(" ")[1] || "";
|
||||
|
||||
if (!refreshTokenValue) {
|
||||
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
decodedRefreshToken = jwt.verify(refreshTokenValue, secretKey) as OAuthTokenPayload;
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (decodedRefreshToken.clientId !== client_id) {
|
||||
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pkceError = OAuthService.verifyPKCE(client, decodedRefreshToken, code_verifier);
|
||||
if (pkceError) {
|
||||
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
|
||||
}
|
||||
|
||||
const accessTokenExpiresIn = 1800; // 30 minutes
|
||||
|
||||
const payloadAuthToken: OAuthTokenPayload = {
|
||||
userId: decodedRefreshToken.userId,
|
||||
teamId: decodedRefreshToken.teamId,
|
||||
@@ -64,17 +83,37 @@ async function handler(req: NextRequest) {
|
||||
scope: decodedRefreshToken.scope,
|
||||
token_type: "Refresh Token",
|
||||
clientId: client_id,
|
||||
// Preserve PKCE information for any client that used PKCE originally
|
||||
...(decodedRefreshToken.codeChallenge && {
|
||||
codeChallenge: decodedRefreshToken.codeChallenge,
|
||||
codeChallengeMethod: decodedRefreshToken.codeChallengeMethod,
|
||||
}),
|
||||
};
|
||||
|
||||
const access_token = jwt.sign(payloadAuthToken, secretKey, {
|
||||
expiresIn: 1800, // 30 min
|
||||
expiresIn: accessTokenExpiresIn,
|
||||
});
|
||||
|
||||
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
|
||||
const refresh_token_new = jwt.sign(payloadRefreshToken, secretKey, {
|
||||
expiresIn: 30 * 24 * 60 * 60, // 30 days
|
||||
});
|
||||
|
||||
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
access_token,
|
||||
token_type: "bearer",
|
||||
refresh_token: refresh_token_new,
|
||||
expires_in: accessTokenExpiresIn,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Cache-Control": "no-store",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
// Import mocked dependencies after mocks are set up
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { verifyCodeChallenge } from "@calcom/lib/pkce";
|
||||
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
// Create mock NextResponse.json function using hoisted
|
||||
const mockNextResponseJson = vi.hoisted(() => {
|
||||
return vi.fn((data: unknown, options?: { status?: number; headers?: Record<string, string> }) => ({
|
||||
json: vi.fn().mockResolvedValue(data),
|
||||
status: options?.status || 200,
|
||||
headers: new Headers(options?.headers || {}),
|
||||
}));
|
||||
});
|
||||
|
||||
// Mock next/server before importing anything that uses it
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { pathname: string; searchParams: URLSearchParams };
|
||||
private _body: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
options: { method?: string; body?: string; headers?: Record<string, string> } = {}
|
||||
) {
|
||||
this.url = url;
|
||||
this.method = options.method || "POST";
|
||||
this._body = options.body || "";
|
||||
const urlObj = new URL(url);
|
||||
this.nextUrl = {
|
||||
pathname: urlObj.pathname,
|
||||
searchParams: urlObj.searchParams,
|
||||
};
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
return this._body;
|
||||
}
|
||||
},
|
||||
NextResponse: {
|
||||
json: mockNextResponseJson,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/lib/pkce", () => ({
|
||||
verifyCodeChallenge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/oAuth/addClient.handler", () => ({
|
||||
generateSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("jsonwebtoken", () => ({
|
||||
default: {
|
||||
sign: vi.fn(() => "mock_jwt_token"),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock("app/api/defaultResponderForAppDir", async () => {
|
||||
const { NextResponse } = await import("next/server");
|
||||
return {
|
||||
defaultResponderForAppDir:
|
||||
(
|
||||
handler: (req: NextRequest, context: { params: Promise<Record<string, string>> }) => Promise<Response>
|
||||
) =>
|
||||
async (req: NextRequest, context?: { params: Promise<Record<string, string>> }) => {
|
||||
try {
|
||||
const result = await handler(req, context || { params: Promise.resolve({}) });
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
return NextResponse.json({});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Internal server error";
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("app/api/parseRequestData", () => ({
|
||||
parseUrlFormData: async (req: NextRequest): Promise<Record<string, string>> => {
|
||||
const text = await req.text();
|
||||
const params = new URLSearchParams(text);
|
||||
return Object.fromEntries(params);
|
||||
},
|
||||
}));
|
||||
|
||||
const mockVerifyCodeChallenge = vi.mocked(verifyCodeChallenge);
|
||||
const mockGenerateSecret = vi.mocked(generateSecret);
|
||||
const mockJwt = vi.mocked(jwt);
|
||||
|
||||
// Set up environment
|
||||
vi.stubEnv("CALENDSO_ENCRYPTION_KEY", "test_encryption_key");
|
||||
|
||||
// Helper to create token exchange request
|
||||
function createTokenRequest(data: Record<string, string>) {
|
||||
const formData = new URLSearchParams(data);
|
||||
const request = new NextRequest("http://localhost:3000/api/auth/oauth/token", {
|
||||
method: "POST",
|
||||
body: formData.toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
describe("POST /api/auth/oauth/token", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockJwt.sign.mockReturnValue("mock_jwt_token" as never);
|
||||
mockVerifyCodeChallenge.mockReturnValue(false);
|
||||
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
|
||||
// Reset the mock for NextResponse.json
|
||||
mockNextResponseJson.mockImplementation(
|
||||
(data: unknown, options?: { status?: number; headers?: Record<string, string> }) => ({
|
||||
json: vi.fn().mockResolvedValue(data),
|
||||
status: options?.status || 200,
|
||||
headers: new Headers(options?.headers || {}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("PUBLIC clients", () => {
|
||||
const mockPublicClient = {
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
clientSecret: null,
|
||||
clientType: "PUBLIC" as const,
|
||||
} as const;
|
||||
|
||||
const mockAccessCode = {
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
scopes: [] as const,
|
||||
codeChallenge: "test_challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
} as const;
|
||||
|
||||
it("should exchange authorization code for tokens with valid PKCE", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
|
||||
mockVerifyCodeChallenge.mockReturnValue(true);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "public_client_123",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
code_verifier: "test_verifier",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data).toEqual({
|
||||
access_token: "mock_jwt_token",
|
||||
token_type: "bearer",
|
||||
refresh_token: "mock_jwt_token",
|
||||
expires_in: 1800,
|
||||
});
|
||||
|
||||
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("test_verifier", "test_challenge", "S256");
|
||||
expect(prismaMock.accessCode.deleteMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject PUBLIC client without code_verifier", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "public_client_123",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("invalid_request");
|
||||
});
|
||||
|
||||
it("should reject PUBLIC client with missing code challenge in access code", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue({
|
||||
...mockAccessCode,
|
||||
codeChallenge: null,
|
||||
} as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>);
|
||||
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "public_client_123",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
code_verifier: "test_verifier",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("invalid_request");
|
||||
});
|
||||
|
||||
it("should reject PUBLIC client with invalid code_verifier", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
|
||||
mockVerifyCodeChallenge.mockReturnValue(false);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "public_client_123",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
code_verifier: "wrong_verifier",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("invalid_grant");
|
||||
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("wrong_verifier", "test_challenge", "S256");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CONFIDENTIAL clients", () => {
|
||||
const mockConfidentialClient = {
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
clientSecret: "hashed_secret",
|
||||
clientType: "CONFIDENTIAL" as const,
|
||||
} as const;
|
||||
|
||||
const mockAccessCode = {
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
scopes: [] as const,
|
||||
codeChallenge: null,
|
||||
codeChallengeMethod: null,
|
||||
} as const;
|
||||
|
||||
it("should exchange authorization code for tokens with valid client secret", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
|
||||
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "confidential_client_456",
|
||||
client_secret: "plain_secret",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data).toEqual({
|
||||
access_token: "mock_jwt_token",
|
||||
token_type: "bearer",
|
||||
refresh_token: "mock_jwt_token",
|
||||
expires_in: 1800,
|
||||
});
|
||||
|
||||
expect(mockGenerateSecret).toHaveBeenCalledWith("plain_secret");
|
||||
expect(prismaMock.accessCode.deleteMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject CONFIDENTIAL client without client_secret", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "confidential_client_456",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe("invalid_client");
|
||||
});
|
||||
|
||||
it("should reject CONFIDENTIAL client with invalid client_secret", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
mockGenerateSecret.mockReturnValue(["different_hashed_secret", "wrong_secret"]);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "confidential_client_456",
|
||||
client_secret: "wrong_secret",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe("invalid_client");
|
||||
});
|
||||
|
||||
it("should accept CONFIDENTIAL client with code_verifier for enhanced security", async () => {
|
||||
const mockAccessCodeWithPKCE = {
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
scopes: [] as const,
|
||||
codeChallenge: "test_challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
} as const;
|
||||
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
|
||||
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
|
||||
mockVerifyCodeChallenge.mockReturnValue(true);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "confidential_client_456",
|
||||
client_secret: "plain_secret",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
code_verifier: "test_verifier",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data).toEqual({
|
||||
access_token: "mock_jwt_token",
|
||||
token_type: "bearer",
|
||||
refresh_token: "mock_jwt_token",
|
||||
expires_in: 1800,
|
||||
});
|
||||
|
||||
expect(mockGenerateSecret).toHaveBeenCalledWith("plain_secret");
|
||||
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("test_verifier", "test_challenge", "S256");
|
||||
});
|
||||
|
||||
it("should reject CONFIDENTIAL client with invalid code_verifier", async () => {
|
||||
const mockAccessCodeWithPKCE = {
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
scopes: [] as const,
|
||||
codeChallenge: "test_challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
} as const;
|
||||
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
);
|
||||
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
|
||||
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
|
||||
mockVerifyCodeChallenge.mockReturnValue(false);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "confidential_client_456",
|
||||
client_secret: "plain_secret",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
code_verifier: "wrong_verifier",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("invalid_grant");
|
||||
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("wrong_verifier", "test_challenge", "S256");
|
||||
});
|
||||
});
|
||||
|
||||
describe("General validation", () => {
|
||||
it("should reject invalid grant_type", async () => {
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "implicit",
|
||||
code: "test_auth_code",
|
||||
client_id: "test_client",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("invalid_request");
|
||||
});
|
||||
|
||||
it("should reject invalid client_id", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(null);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "invalid_client",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe("invalid_client");
|
||||
});
|
||||
|
||||
it("should reject mismatched redirect_uri", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue({
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
clientSecret: null,
|
||||
clientType: "PUBLIC" as const,
|
||||
} as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "test_auth_code",
|
||||
client_id: "test_client",
|
||||
redirect_uri: "https://malicious.com/callback",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("invalid_grant");
|
||||
});
|
||||
|
||||
it("should reject expired authorization code", async () => {
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue({
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
clientSecret: null,
|
||||
clientType: "PUBLIC" as const,
|
||||
} as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(null);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
grant_type: "authorization_code",
|
||||
code: "expired_auth_code",
|
||||
client_id: "test_client",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
code_verifier: "test_verifier",
|
||||
});
|
||||
|
||||
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
|
||||
expect(response).toBeDefined();
|
||||
if (!response) throw new Error("Response is undefined");
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("invalid_grant");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,64 +4,63 @@ import jwt from "jsonwebtoken";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository";
|
||||
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
|
||||
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
|
||||
import type { OAuthTokenPayload } from "@calcom/types/oauth";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const { code, client_id, client_secret, grant_type, redirect_uri } = await parseUrlFormData(req);
|
||||
const { code, client_id, client_secret, grant_type, redirect_uri, code_verifier } = await parseUrlFormData(
|
||||
req
|
||||
);
|
||||
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!client_id || !code || !redirect_uri) {
|
||||
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (grant_type !== "authorization_code") {
|
||||
return NextResponse.json({ message: "grant_type invalid" }, { status: 400 });
|
||||
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [hashedSecret] = generateSecret(client_secret);
|
||||
const oAuthClientRepository = new OAuthClientRepository(prisma);
|
||||
const accessCodeRepository = new AccessCodeRepository(prisma);
|
||||
|
||||
const client = await prisma.oAuthClient.findFirst({
|
||||
where: {
|
||||
clientId: client_id,
|
||||
clientSecret: hashedSecret,
|
||||
},
|
||||
select: {
|
||||
redirectUri: true,
|
||||
},
|
||||
});
|
||||
const client = await oAuthClientRepository.findByClientId(client_id);
|
||||
|
||||
if (!client || client.redirectUri !== redirect_uri) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
|
||||
}
|
||||
|
||||
const accessCode = await prisma.accessCode.findFirst({
|
||||
where: {
|
||||
code: code,
|
||||
clientId: client_id,
|
||||
expiresAt: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (client.redirectUri !== redirect_uri) {
|
||||
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValidClient = OAuthService.validateClient(client, client_secret);
|
||||
|
||||
if (!isValidClient) {
|
||||
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
|
||||
}
|
||||
|
||||
const accessCode = await accessCodeRepository.findValidCode(code, client_id);
|
||||
|
||||
// Delete all expired accessCodes + the one that is used here
|
||||
await prisma.accessCode.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
{
|
||||
code: code,
|
||||
clientId: client_id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await accessCodeRepository.deleteExpiredAndUsedCodes(code, client_id);
|
||||
|
||||
if (!accessCode) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
|
||||
}
|
||||
|
||||
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||
const pkceError = OAuthService.verifyPKCE(client, accessCode, code_verifier);
|
||||
if (pkceError) {
|
||||
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
|
||||
}
|
||||
|
||||
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
|
||||
|
||||
const payloadAuthToken: OAuthTokenPayload = {
|
||||
userId: accessCode.userId,
|
||||
@@ -77,6 +76,11 @@ async function handler(req: NextRequest) {
|
||||
scope: accessCode.scopes,
|
||||
token_type: "Refresh Token",
|
||||
clientId: client_id,
|
||||
// Include PKCE information for clients that used PKCE (PUBLIC mandatory, CONFIDENTIAL optional)
|
||||
...(accessCode.codeChallenge && {
|
||||
codeChallenge: accessCode.codeChallenge,
|
||||
codeChallengeMethod: accessCode.codeChallengeMethod,
|
||||
}),
|
||||
};
|
||||
|
||||
const accessTokenExpiresIn = 1800; // 30 minutes
|
||||
@@ -89,7 +93,6 @@ async function handler(req: NextRequest) {
|
||||
expiresIn: 30 * 24 * 60 * 60, // 30 days
|
||||
});
|
||||
|
||||
// @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
|
||||
return NextResponse.json(
|
||||
{ access_token, token_type: "bearer", refresh_token, expires_in: accessTokenExpiresIn },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const querySchema = z.object({
|
||||
session_id: z.string().min(1),
|
||||
paymentStatus: z.enum(["success", "failed"]),
|
||||
});
|
||||
|
||||
async function getHandler(req: NextRequest) {
|
||||
try {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const { session_id, paymentStatus } = querySchema.parse({
|
||||
session_id: searchParams.get("session_id"),
|
||||
paymentStatus: searchParams.get("paymentStatus"),
|
||||
});
|
||||
|
||||
// Retrieve checkout session from Stripe to get metadata
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id);
|
||||
if (!checkoutSession) {
|
||||
throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
|
||||
}
|
||||
|
||||
// Extract organizationOnboardingId from metadata
|
||||
const organizationOnboardingId = checkoutSession.metadata?.organizationOnboardingId;
|
||||
|
||||
// Check if onboarding-v3 feature flag is enabled
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isOnboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
|
||||
// Build query params to preserve
|
||||
const params = new URLSearchParams({
|
||||
session_id,
|
||||
paymentStatus,
|
||||
});
|
||||
|
||||
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
|
||||
if (isOnboardingV3Enabled && organizationOnboardingId) {
|
||||
const redirectUrl = new URL(
|
||||
`/onboarding/personal/settings?${params.toString()}`,
|
||||
WEBAPP_URL
|
||||
).toString();
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
// Otherwise, redirect to the current flow
|
||||
// Preserve any additional query params that were passed
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== "session_id" && key !== "paymentStatus") {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const redirectUrl = new URL(
|
||||
`/settings/organizations/new/status?${params.toString()}`,
|
||||
WEBAPP_URL
|
||||
).toString();
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
throw error;
|
||||
}
|
||||
// If there's an error, fall back to the default status page
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const fallbackParams = new URLSearchParams({
|
||||
session_id: searchParams.get("session_id") || "",
|
||||
paymentStatus: searchParams.get("paymentStatus") || "failed",
|
||||
});
|
||||
const fallbackUrl = new URL(
|
||||
`/settings/organizations/new/status?${fallbackParams.toString()}`,
|
||||
WEBAPP_URL
|
||||
).toString();
|
||||
return NextResponse.redirect(fallbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
@@ -113,8 +113,10 @@ async function getHandler(req: NextRequest) {
|
||||
const isOnboarding = checkoutSessionMetadata.isOnboarding === "true";
|
||||
|
||||
if (isOnboarding) {
|
||||
// Redirect to event-types for onboarding flow
|
||||
return NextResponse.redirect(new URL("/onboarding/personal/settings", WEBAPP_URL), {
|
||||
// Redirect to invite flow after payment for onboarding with teamId as query param
|
||||
const inviteUrl = new URL("/onboarding/teams/invite", WEBAPP_URL);
|
||||
inviteUrl.searchParams.set("teamId", team.id.toString());
|
||||
return NextResponse.redirect(inviteUrl, {
|
||||
status: 302,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons");
|
||||
}
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
src: "../../fonts/CalSans-SemiBold.woff2",
|
||||
variable: "--font-cal",
|
||||
|
||||
+17
-15
@@ -15,7 +15,7 @@ import { AppRouterI18nProvider } from "./AppRouterI18nProvider";
|
||||
import { SpeculationRules } from "./SpeculationRules";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
src: "../fonts/CalSans-SemiBold.woff2",
|
||||
variable: "--font-cal",
|
||||
@@ -98,6 +98,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const h = await headers();
|
||||
const nonce = h.get("x-csp-nonce") ?? "";
|
||||
|
||||
const country = h.get("cf-ipcountry") || h.get("x-vercel-ip-country") || "Unknown";
|
||||
|
||||
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps();
|
||||
|
||||
const ns = "common";
|
||||
@@ -115,7 +117,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<head nonce={nonce}>
|
||||
<style>{`
|
||||
:root {
|
||||
--font-inter: ${interFont.style.fontFamily.replace(/\'/g, "")};
|
||||
--font-sans: ${interFont.style.fontFamily.replace(/\'/g, "")};
|
||||
--font-cal: ${calFont.style.fontFamily.replace(/\'/g, "")};
|
||||
}
|
||||
`}</style>
|
||||
@@ -125,19 +127,19 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
// - gives it the appropriate styles if UI instruction is there.
|
||||
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
|
||||
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
|
||||
visibility: "hidden",
|
||||
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
|
||||
opacity: 0,
|
||||
}
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
// - gives it the appropriate styles if UI instruction is there.
|
||||
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
|
||||
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
|
||||
visibility: "hidden",
|
||||
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
|
||||
opacity: 0,
|
||||
}
|
||||
: {
|
||||
visibility: "visible",
|
||||
opacity: 1,
|
||||
}
|
||||
visibility: "visible",
|
||||
opacity: 1,
|
||||
}
|
||||
}>
|
||||
<IconSprites />
|
||||
<SpeculationRules
|
||||
@@ -154,7 +156,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
]}
|
||||
/>
|
||||
|
||||
<Providers isEmbed={isEmbed} nonce={nonce}>
|
||||
<Providers isEmbed={isEmbed} nonce={nonce} country={country}>
|
||||
<AppRouterI18nProvider translations={translations} locale={locale} ns={ns}>
|
||||
{children}
|
||||
</AppRouterI18nProvider>
|
||||
|
||||
@@ -145,7 +145,7 @@ export function NotFound({ host }: { host: string }) {
|
||||
target="_blank"
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse"
|
||||
rel="noreferrer">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="shrink-0">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
|
||||
<Icon name="check" className="h-6 w-6 text-green-500" aria-hidden="true" />
|
||||
</span>
|
||||
@@ -164,7 +164,7 @@ export function NotFound({ host }: { host: string }) {
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t(`404_claim_entity_${pageType.toLowerCase()}`)}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<div className="shrink-0 self-center">
|
||||
<Icon name="chevron-right" className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</a>
|
||||
@@ -180,8 +180,8 @@ export function NotFound({ host }: { host: string }) {
|
||||
<a
|
||||
href={link.href}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<div className="shrink-0">
|
||||
<span className="bg-cal-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Icon name={link.icon} className="text-default h-6 w-6" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@ export function NotFound({ host }: { host: string }) {
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{link.description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<div className="shrink-0 self-center">
|
||||
<Icon name="chevron-right" className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
+15
-10
@@ -9,23 +9,28 @@ import { NotificationSoundHandler } from "@calcom/web/components/notification-so
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
|
||||
import { GeoProvider } from "./GeoContext";
|
||||
|
||||
type ProvidersProps = {
|
||||
isEmbed: boolean;
|
||||
children: React.ReactNode;
|
||||
nonce: string | undefined;
|
||||
country: string;
|
||||
};
|
||||
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
|
||||
export function Providers({ isEmbed, children, country }: ProvidersProps) {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<TrpcProvider>
|
||||
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
|
||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||
<CacheProvider>
|
||||
<WebPushProvider>{children}</WebPushProvider>
|
||||
</CacheProvider>
|
||||
</TrpcProvider>
|
||||
</SessionProvider>
|
||||
<GeoProvider country={country}>
|
||||
<SessionProvider>
|
||||
<TrpcProvider>
|
||||
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
|
||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||
<CacheProvider>
|
||||
<WebPushProvider>{children}</WebPushProvider>
|
||||
</CacheProvider>
|
||||
</TrpcProvider>
|
||||
</SessionProvider>
|
||||
</GeoProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function AddToHomescreen() {
|
||||
<div className="rounded-lg p-2 shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex w-0 flex-1 items-center">
|
||||
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
|
||||
<span className="text-brandcontrast dark:bg-darkmodebrand/30 dark:text-darkmodebrandcontrast flex rounded-lg p-2">
|
||||
<svg
|
||||
className="h-7 w-7 fill-current text-[#5B93F9]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -34,7 +34,7 @@ export default function AddToHomescreen() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="order-2 flex-shrink-0 sm:order-3">
|
||||
<div className="order-2 shrink-0 sm:order-3">
|
||||
<button
|
||||
onClick={() => setCloseBanner(true)}
|
||||
type="button"
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function EnterprisePage() {
|
||||
features={features}
|
||||
background="/tips/enterprise"
|
||||
buttons={
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href="https://go.cal.com/quote" target="_blank">
|
||||
{t("contact_sales")}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router
|
||||
*
|
||||
* Currently only serves the /router endpoint (routing forms redirect page).
|
||||
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
|
||||
*
|
||||
* For App Router, use PageWrapperAppDir.tsx instead.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { DefaultSeo } from "next-seo";
|
||||
@@ -24,7 +33,7 @@ export interface CalPageWrapper {
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
}
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
src: "../fonts/CalSans-SemiBold.woff2",
|
||||
variable: "--font-cal",
|
||||
@@ -80,7 +89,7 @@ function PageWrapper(props: AppProps) {
|
||||
|
||||
<style jsx global>{`
|
||||
:root {
|
||||
--font-inter: ${interFont.style.fontFamily};
|
||||
--font-sans: ${interFont.style.fontFamily};
|
||||
--font-cal: ${calFont.style.fontFamily};
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -46,7 +46,6 @@ function PageWrapper(props: PageWrapperProps) {
|
||||
id="page-status"
|
||||
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
|
||||
// And we don't want it to error here anyways
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
{props.requiresLicense ? (
|
||||
|
||||
@@ -497,7 +497,7 @@ export const AppPage = ({
|
||||
)}
|
||||
</ul>
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<span className="leading-1 text-subtle block text-xs">
|
||||
<span className="text-subtle block text-xs">
|
||||
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
|
||||
</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
|
||||
|
||||
@@ -25,8 +25,8 @@ export default function AppleCalendarSetup() {
|
||||
|
||||
return (
|
||||
<div className="bg-emphasis flex h-screen dark:bg-inherit">
|
||||
<div className="bg-default dark:bg-muted border-subtle m-auto rounded p-5 dark:border md:w-[560px] md:p-10">
|
||||
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
|
||||
<div className="bg-default dark:bg-cal-muted border-subtle m-auto rounded p-5 dark:border md:w-[560px] md:p-10">
|
||||
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
|
||||
<div>
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -75,7 +75,7 @@ export default function AppleCalendarSetup() {
|
||||
}
|
||||
}}>
|
||||
<fieldset
|
||||
className="space-y-4"
|
||||
className="stack-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
data-testid="apple-calendar-form">
|
||||
<TextField
|
||||
|
||||
@@ -249,13 +249,13 @@ function BTCPaySetupPage(props: IBTCPaySetupProps) {
|
||||
<>
|
||||
<div className="bg-default flex h-screen items-center justify-center">
|
||||
{showContent ? (
|
||||
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center space-y-4 p-4 lg:space-y-5">
|
||||
<form className="w-full space-y-4" onSubmit={onSubmit}>
|
||||
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center stack-y-4 p-4 lg:stack-y-5">
|
||||
<form className="w-full stack-y-4" onSubmit={onSubmit}>
|
||||
<div className="bg-default border-subtle overflow-auto rounded border">
|
||||
<div className="border-subtle flex items-center justify-between border-b-[1px] p-4 md:p-5">
|
||||
<div className="border-subtle flex items-center justify-between border-b p-4 md:p-5">
|
||||
<h2 className="text-2xl font-semibold">BTCPay Server Information</h2>
|
||||
</div>
|
||||
<div className="w-full space-y-4 p-4 md:p-5">
|
||||
<div className="w-full stack-y-4 p-4 md:p-5">
|
||||
<div className="w-full">
|
||||
<KeyField
|
||||
{...register("serverUrl", { required: true })}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function CalDavCalendarSetup() {
|
||||
return (
|
||||
<div className="bg-emphasis flex h-screen">
|
||||
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
|
||||
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
|
||||
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
|
||||
<div>
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -60,7 +60,7 @@ export default function CalDavCalendarSetup() {
|
||||
router.push(json.url);
|
||||
}
|
||||
}}>
|
||||
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
|
||||
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
|
||||
<TextField
|
||||
required
|
||||
type="text"
|
||||
@@ -95,7 +95,7 @@ export default function CalDavCalendarSetup() {
|
||||
href={errorActionUrl}
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
className="ml-5 w-32 !p-5">
|
||||
className="ml-5 w-32 p-5!">
|
||||
Go to Admin
|
||||
</Button>
|
||||
) : undefined
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function Exchange2013CalendarSetup() {
|
||||
router.push(json.url);
|
||||
}
|
||||
}}>
|
||||
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
|
||||
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
|
||||
<TextField
|
||||
required
|
||||
type="text"
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function Exchange2016CalendarSetup() {
|
||||
router.push(json.url);
|
||||
}
|
||||
}}>
|
||||
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
|
||||
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
|
||||
<TextField
|
||||
required
|
||||
type="text"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user