diff --git a/.github/actions/cache-db/action.yml b/.github/actions/cache-db/action.yml
index 32b9b0c083bda3..84308b518863ab 100644
--- a/.github/actions/cache-db/action.yml
+++ b/.github/actions/cache-db/action.yml
@@ -7,6 +7,14 @@ inputs:
path:
required: false
default: "backups/backup.sql"
+ SEED_PLATFORM_OAUTH_CLIENT_ID:
+ required: false
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET:
+ required: false
+ SEED_PLATFORM_OAUTH_CLIENT_ID_E2E:
+ required: false
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E:
+ required: false
runs:
using: "composite"
steps:
@@ -29,6 +37,11 @@ runs:
- run: yarn db-seed
if: steps.cache-db.outputs.cache-hit != 'true'
shell: bash
+ env:
+ SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ inputs.SEED_PLATFORM_OAUTH_CLIENT_ID }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ inputs.SEED_PLATFORM_OAUTH_CLIENT_SECRET }}
+ SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ inputs.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ inputs.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }}
- name: Postgres Dump Backup
if: steps.cache-db.outputs.cache-hit != 'true'
uses: tj-actions/pg-dump@v2.3
diff --git a/.github/workflows/e2e-api-v2.yml b/.github/workflows/e2e-api-v2.yml
index 254d6c90858351..5b22c617ac1680 100644
--- a/.github/workflows/e2e-api-v2.yml
+++ b/.github/workflows/e2e-api-v2.yml
@@ -27,6 +27,11 @@ env:
VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }}
JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
NODE_ENV: ${{ vars.CI_NODE_ENV }}
+ ## seed script env variables
+ SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET }}
+ SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }}
jobs:
e2e:
timeout-minutes: 20
@@ -72,6 +77,11 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
+ with:
+ SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET }}
+ SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }}
- name: Generate Swagger
working-directory: apps/api/v2
diff --git a/.github/workflows/e2e-atoms.yml b/.github/workflows/e2e-atoms.yml
index f6b347dbaa395f..f5a6a8b353418f 100644
--- a/.github/workflows/e2e-atoms.yml
+++ b/.github/workflows/e2e-atoms.yml
@@ -1,28 +1,105 @@
name: E2E Atoms
on:
- workflow_call:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - "packages/platform/atoms/**"
+ - "packages/platform/examples/base/**"
+ - "apps/api/v2/**"
+ - ".github/workflows/e2e-atoms.yml"
+
permissions:
actions: write
contents: read
env:
- NODE_OPTIONS: --max-old-space-size=8096
- ATOMS_E2E_OAUTH_CLIENT_ID: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID }}
- ATOMS_E2E_OAUTH_CLIENT_SECRET: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET }}
- ATOMS_E2E_API_URL: ${{ secrets.ATOMS_E2E_API_URL }}
- ATOMS_E2E_ORG_ID: ${{ secrets.ATOMS_E2E_ORG_ID }}
- ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED }}
- ATOMS_E2E_APPLE_ID: ${{ secrets.ATOMS_E2E_APPLE_ID }}
- ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE: ${{ secrets.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE }}
+ ## api v2 env
+ ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+ API_KEY_PREFIX: ${{ secrets.CI_API_KEY_PREFIX }}
+ API_PORT: ${{ vars.CI_API_V2_PORT }}
+ CALCOM_LICENSE_KEY: ${{ secrets.CI_CALCOM_LICENSE_KEY }}
+ DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
+ DATABASE_READ_URL: ${{ secrets.CI_DATABASE_URL }}
+ DATABASE_WRITE_URL: ${{ secrets.CI_DATABASE_URL }}
+ GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
+ IS_E2E: true
+ CI: true
+ NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
+ NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
+ REDIS_URL: "redis://localhost:6379"
+ LINGO_DOT_DEV_API_KEY: ${{ secrets.CI_LINGO_DOT_DEV_API_KEY }}
+ STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
+ STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
+ STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
+ STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
+ SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
+ NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }}
+ VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }}
+ JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
+ NODE_ENV: ${{ vars.CI_NODE_ENV }}
+
+ ## atoms e2e examples app env
+ ATOMS_E2E_OAUTH_CLIENT_ID_2025_12: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_2025_12 }}
+ ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12 }}
+ ATOMS_E2E_API_URL_2025_12: ${{ secrets.ATOMS_E2E_API_URL_2025_12 }}
+ ATOMS_E2E_ORG_ID_2025_12: ${{ secrets.ATOMS_E2E_ORG_ID_2025_12 }}
+ ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED_2025_12: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED_2025_12 }}
+ ATOMS_E2E_APPLE_ID_2025_12: ${{ secrets.ATOMS_E2E_APPLE_ID_2025_12 }}
+ ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE_2025_12: ${{ secrets.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE_2025_12 }}
+
+ ## atoms e2e examples app env aliases for playwright
+ NEXT_PUBLIC_X_CAL_ID: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_2025_12 }}
+ X_CAL_SECRET_KEY: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12 }}
+ NEXT_PUBLIC_CALCOM_API_URL: ${{ secrets.ATOMS_E2E_API_URL_2025_12 }}
+
+ ## env variables needed for both api v2 and examples app
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
+ NODE_OPTIONS: --max-old-space-size=29000
+
+ ## seed script env variables
+ SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET }}
+ SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }}
jobs:
e2e-atoms:
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
timeout-minutes: 15
name: E2E Atoms
- runs-on: buildjet-4vcpu-ubuntu-2204
+ runs-on: buildjet-16vcpu-ubuntu-2204
+ services:
+ postgres:
+ image: postgres:13
+ credentials:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: calendso
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+ redis:
+ image: redis:latest
+ credentials:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
steps:
- uses: docker/login-action@v3
with:
@@ -31,10 +108,42 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
+ - uses: ./.github/actions/cache-db
+ with:
+ SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET }}
+ SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }}
+ SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }}
- uses: ./.github/actions/yarn-playwright-install
+ - name: Start API v2
+ working-directory: apps/api/v2
+ run: |
+ yarn dev:no-docker &
+ API_PID=$!
+ echo "API_PID=$API_PID" >> $GITHUB_ENV
+
+ # Wait for API to be ready
+ echo "Waiting for API v2 to be ready on port ${{ vars.CI_API_V2_PORT }}..."
+ timeout 300 bash -c 'until curl -f http://localhost:${{ vars.CI_API_V2_PORT }}/health > /dev/null 2>&1; do sleep 2; done'
+ echo "API v2 is ready!"
- name: Run E2E Atoms Tests
working-directory: packages/platform/examples/base
run: yarn test:e2e
+ - name: Upload Atoms dist (entire folder)
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: atoms-dist-${{ github.sha }}
+ path: packages/platform/atoms/dist
+ retention-days: 7
+ if-no-files-found: warn
+
+ - name: Stop API v2
+ if: always()
+ run: |
+ if [ ! -z "$API_PID" ]; then
+ kill $API_PID || true
+ fi
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: always()
diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts
index 85c65d2b3fa163..f2bfa59ce2b35a 100644
--- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts
+++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts
@@ -824,6 +824,11 @@ describe("OAuth Client Users Endpoints", () => {
} catch (e) {
console.log(e);
}
+ try {
+ await userRepositoryFixture.delete(postResponseDataTwo.user.id);
+ } catch (e) {
+ // User might have been deleted by the test
+ }
try {
await userRepositoryFixture.delete(platformAdmin.id);
} catch (e) {
diff --git a/packages/features/shell/SideBar.tsx b/packages/features/shell/SideBar.tsx
index 586e9405f871dd..f9bf1fd9881eca 100644
--- a/packages/features/shell/SideBar.tsx
+++ b/packages/features/shell/SideBar.tsx
@@ -43,13 +43,23 @@ export type SideBarProps = {
export function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarContainerProps) {
const { status, data } = useSession();
- // Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning.
- // This improves the experience of refresh on app store pages(when logged in) which are SSG.
- // Though when logged out, app store pages would temporarily show SideBar until session status is confirmed.
+ // Render nothing once we know the user isn't authenticated.
if (status !== "loading" && status !== "authenticated") return null;
return ;
}
+// Build a safe absolute public page URL, or empty string if we can't.
+const buildPublicPageUrl = (user?: UserAuth | null): string => {
+ const base =
+ getBookerBaseUrlSync(user?.org?.slug ?? null) ||
+ process.env.NEXT_PUBLIC_WEBAPP_URL ||
+ process.env.NEXTAUTH_URL ||
+ "";
+ const username = user?.orgAwareUsername;
+ if (!base || !username) return "";
+ return `${String(base).replace(/\/+$/, "")}/${username}`;
+};
+
export function SideBar({ bannersHeight, user }: SideBarProps) {
const session = useSession();
const { t, isLocaleReady } = useLocale();
@@ -57,7 +67,7 @@ export function SideBar({ bannersHeight, user }: SideBarProps) {
const isPlatformPages = pathname?.startsWith("/settings/platform");
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
- const publicPageUrl = `${getBookerBaseUrlSync(user?.org?.slug ?? null)}/${user?.orgAwareUsername}`;
+ const publicPageUrl = buildPublicPageUrl(user);
const bottomNavItems = useBottomNavItems({
publicPageUrl,
@@ -112,19 +122,13 @@ export function SideBar({ bannersHeight, user }: SideBarProps) {
color="minimal"
onClick={() => window.history.back()}
className="todesktop:block hover:text-emphasis text-subtle group hidden text-sm font-medium">
-
+
{!!user?.org && (
@@ -134,10 +138,12 @@ export function SideBar({ bannersHeight, user }: SideBarProps) {
+
{/* logo icon for tablet */}
+
@@ -146,21 +152,13 @@ export function SideBar({ bannersHeight, user }: SideBarProps) {
- {bottomNavItems.map((item, index) => (
-
-
+
+ {bottomNavItems.map((item, index) => {
+ const isActionOnly = !item.href;
+ const isInternal = !!item.href && item.href.startsWith("/");
+
+ const content = (
+ <>
{!!item.icon && (
)}
-
-
- ))}
+ >
+ );
+
+ const commonClassName = classNames(
+ "text-left",
+ "[&[aria-current='page']]:bg-emphasis text-default justify-right group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
+ "[&[aria-current='page']]:text-emphasis mt-0.5 w-full text-sm",
+ isLocaleReady ? "hover:bg-emphasis hover:text-emphasis" : "",
+ index === 0 && "mt-3"
+ );
+
+ return (
+
+ {isActionOnly ? (
+
+ ) : isInternal ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ )}
+
+ );
+ })}
+
{!IS_VISUAL_REGRESSION_TESTING && }
)}
diff --git a/packages/platform/examples/base/.env.e2e.example b/packages/platform/examples/base/.env.e2e.example
new file mode 100644
index 00000000000000..a427c43e08e93f
--- /dev/null
+++ b/packages/platform/examples/base/.env.e2e.example
@@ -0,0 +1,10 @@
+NEXT_PUBLIC_X_CAL_ID=
+X_CAL_SECRET_KEY=
+NEXT_PUBLIC_CALCOM_API_URL=
+VITE_BOOKER_EMBED_OAUTH_CLIENT_ID=
+VITE_BOOKER_EMBED_API_URL=
+ORGANIZATION_ID=
+ATOMS_E2E_APPLE_ID=
+ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE=
+NEXT_PUBLIC_IS_E2E="1"
+NODE_ENV="test"
diff --git a/packages/platform/examples/base/.gitignore b/packages/platform/examples/base/.gitignore
index 9184ed87223ae8..b8065cc21e666c 100644
--- a/packages/platform/examples/base/.gitignore
+++ b/packages/platform/examples/base/.gitignore
@@ -27,6 +27,7 @@ yarn-error.log*
# local env files
.env*.local
+!.env.e2e.example
# vercel
.vercel
@@ -36,6 +37,7 @@ yarn-error.log*
next-env.d.ts
.yarn
dev.db
+test.db
# playwright
test-results
diff --git a/packages/platform/examples/base/global-teardown.ts b/packages/platform/examples/base/global-teardown.ts
new file mode 100644
index 00000000000000..53be4c31738d5b
--- /dev/null
+++ b/packages/platform/examples/base/global-teardown.ts
@@ -0,0 +1,115 @@
+import dotenv from "dotenv";
+import fs from "fs";
+import path from "path";
+
+if (!process.env.CI) {
+ dotenv.config({ path: path.resolve(__dirname, ".env.e2e") });
+}
+async function globalTeardown() {
+ console.log("Cleaning up managed users...");
+ try {
+ const oauthClientId = process.env.NEXT_PUBLIC_X_CAL_ID;
+ const secretKey = process.env.X_CAL_SECRET_KEY;
+ const apiUrl = process.env.NEXT_PUBLIC_CALCOM_API_URL;
+ if (!oauthClientId || !secretKey || !apiUrl) {
+ console.log("Missing environment variables, skipping managed user cleanup");
+ return;
+ }
+ const getManagedUsersResponse = await fetch(`${apiUrl}/oauth-clients/${oauthClientId}/users`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "x-cal-secret-key": secretKey,
+ "x-cal-client-id": oauthClientId,
+ },
+ });
+ if (getManagedUsersResponse.ok) {
+ const managedUsersData = await getManagedUsersResponse.json();
+ const users = managedUsersData.data || [];
+ for (const user of users) {
+ try {
+ const deleteResponse = await fetch(`${apiUrl}/oauth-clients/${oauthClientId}/users/${user.id}`, {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ "x-cal-secret-key": secretKey,
+ "x-cal-client-id": oauthClientId,
+ },
+ });
+ if (deleteResponse.ok) {
+ console.log(`Deleted managed user: with id = ${user.id}`);
+ } else {
+ console.error(`Failed to delete user with id = ${user.id}:`, await deleteResponse.text());
+ }
+ } catch (error) {
+ console.error(`Error deleting user with id = ${user.id}:`, error);
+ }
+ }
+ } else {
+ console.error("Failed to fetch managed users:", await getManagedUsersResponse.text());
+ }
+ const getOAuthClientResponse = await fetch(`${apiUrl}/oauth-clients/${oauthClientId}`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "x-cal-secret-key": secretKey,
+ "x-cal-client-id": oauthClientId,
+ },
+ });
+ if (getOAuthClientResponse.ok) {
+ const oauthClientData = await getOAuthClientResponse.json();
+ const organizationId = oauthClientData.data?.organizationId;
+ if (organizationId) {
+ console.log(`Found organizationId: ${organizationId}`);
+ const getTeamsResponse = await fetch(`${apiUrl}/organizations/${organizationId}/teams`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "x-cal-secret-key": secretKey,
+ "x-cal-client-id": oauthClientId,
+ },
+ });
+ if (getTeamsResponse.ok) {
+ const teamsData = await getTeamsResponse.json();
+ const teams = teamsData.data || [];
+ for (const team of teams) {
+ try {
+ const deleteTeamResponse = await fetch(
+ `${apiUrl}/organizations/${organizationId}/teams/${team.id}`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ "x-cal-secret-key": secretKey,
+ "x-cal-client-id": oauthClientId,
+ },
+ }
+ );
+ if (deleteTeamResponse.ok) {
+ console.log(`Deleted team: ${team.name}`);
+ } else {
+ console.error(`Failed to delete team ${team.name}:`, await deleteTeamResponse.text());
+ }
+ } catch (error) {
+ console.error(`Error deleting team ${team.name}:`, error);
+ }
+ }
+ } else {
+ console.error("Failed to fetch teams:", await getTeamsResponse.text());
+ }
+ } else {
+ console.log("No organizationId found in OAuth client");
+ }
+ } else {
+ console.error("Failed to fetch OAuth client:", await getOAuthClientResponse.text());
+ }
+ } catch (error) {
+ console.error("Failed to clean up:", error);
+ } finally {
+ console.log("Cleaning up test database...");
+ const testDbPath = path.resolve(__dirname, "prisma", "test.db");
+ fs.rmSync(testDbPath, { force: true });
+ console.log("Test database cleaned up successfully");
+ }
+}
+export default globalTeardown;
diff --git a/packages/platform/examples/base/package.json b/packages/platform/examples/base/package.json
index 4c4c21541b669b..13c49a3656d4be 100644
--- a/packages/platform/examples/base/package.json
+++ b/packages/platform/examples/base/package.json
@@ -9,7 +9,10 @@
"start": "next start",
"lint": "eslint .",
"test:e2e": "playwright test",
- "test:e2e:ui": "playwright test --ui"
+ "test:e2e:ui": "playwright test --ui",
+ "db:push:test": "prisma db push --schema=prisma/schema.test.prisma",
+ "db:generate:test": "prisma generate --schema=prisma/schema.test.prisma",
+ "db:reset:test": "rm -f prisma/test.db && yarn db:push:test"
},
"dependencies": {
"@calcom/atoms": "workspace:*",
diff --git a/packages/platform/examples/base/playwright.config.ts b/packages/platform/examples/base/playwright.config.ts
index d77d9e71361d81..8c399d646f2465 100644
--- a/packages/platform/examples/base/playwright.config.ts
+++ b/packages/platform/examples/base/playwright.config.ts
@@ -2,9 +2,9 @@ import { defineConfig, devices } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
-const envPath = process.env.CI ? path.resolve(__dirname, ".env") : path.resolve(__dirname, ".env.local");
-
-dotenv.config({ path: envPath });
+if (!process.env.CI) {
+ dotenv.config({ path: path.resolve(__dirname, ".env.e2e") });
+}
const DEFAULT_EXPECT_TIMEOUT = process.env.CI ? 30000 : 120000;
const DEFAULT_TEST_TIMEOUT = process.env.CI ? 60000 : 240000;
@@ -14,9 +14,10 @@ const headless = !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS;
export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
- workers: process.env.CI ? 1 : undefined,
+ workers: 1,
timeout: DEFAULT_TEST_TIMEOUT,
fullyParallel: false,
+ globalTeardown: require.resolve("./global-teardown"),
reporter: [
["list"],
["html", { outputFolder: "./test-results/reports/playwright-html-report", open: "never" }],
@@ -44,10 +45,28 @@ export default defineConfig({
],
webServer: {
command: process.env.CI
- ? `yarn workspace @calcom/atoms dev-on && yarn workspace @calcom/atoms build && rm -f prisma/dev.db && yarn prisma db push && NEXT_PUBLIC_IS_E2E=1 NODE_ENV=test NEXT_PUBLIC_X_CAL_ID="${process.env.ATOMS_E2E_OAUTH_CLIENT_ID}" X_CAL_SECRET_KEY="${process.env.ATOMS_E2E_OAUTH_CLIENT_SECRET}" NEXT_PUBLIC_CALCOM_API_URL="${process.env.ATOMS_E2E_API_URL}" VITE_BOOKER_EMBED_OAUTH_CLIENT_ID="${process.env.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED}" VITE_BOOKER_EMBED_API_URL="${process.env.ATOMS_E2E_API_URL}" ORGANIZATION_ID=${process.env.ATOMS_E2E_ORG_ID} yarn dev:e2e`
- : `rm -f prisma/dev.db && yarn prisma db push && yarn dev:e2e`,
+ ? `yarn workspace @calcom/atoms dev-on && yarn workspace @calcom/atoms build && yarn db:generate:test && yarn db:reset:test && yarn dev:e2e`
+ : `yarn db:generate:test && yarn db:reset:test && yarn dev:e2e`,
url: "http://localhost:4322",
timeout: 600_000,
reuseExistingServer: !process.env.CI,
+ ...(process.env.CI
+ ? {
+ env: {
+ NEXT_PUBLIC_IS_E2E: "1",
+ NODE_ENV: "test",
+ NEXT_PUBLIC_X_CAL_ID: process.env.ATOMS_E2E_OAUTH_CLIENT_ID_2025_12 ?? "",
+ X_CAL_SECRET_KEY: process.env.ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12 ?? "",
+ NEXT_PUBLIC_CALCOM_API_URL: process.env.ATOMS_E2E_API_URL_2025_12 ?? "",
+ VITE_BOOKER_EMBED_OAUTH_CLIENT_ID:
+ process.env.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED_2025_12 ?? "",
+ VITE_BOOKER_EMBED_API_URL: process.env.ATOMS_E2E_API_URL_2025_12 ?? "",
+ ORGANIZATION_ID: String(process.env.ATOMS_E2E_ORG_ID_2025_12 ?? ""),
+ ATOMS_E2E_APPLE_ID: process.env.ATOMS_E2E_APPLE_ID_2025_12 ?? "",
+ ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE:
+ process.env.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE_2025_12 ?? "",
+ },
+ }
+ : {}),
},
});
diff --git a/packages/platform/examples/base/prisma/schema.test.prisma b/packages/platform/examples/base/prisma/schema.test.prisma
new file mode 100644
index 00000000000000..f6fdeb9e8c7896
--- /dev/null
+++ b/packages/platform/examples/base/prisma/schema.test.prisma
@@ -0,0 +1,19 @@
+generator testClient {
+ provider = "prisma-client-js"
+ output = "../node_modules/.prisma/test-client"
+}
+datasource testDb {
+ provider = "sqlite"
+ url = "file:./test.db"
+}
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ name String?
+ calcomUserId Int? @unique
+ calcomUsername String? @unique
+ refreshToken String? @unique
+ accessToken String? @unique
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
\ No newline at end of file
diff --git a/packages/platform/examples/base/src/lib/prismaClient.ts b/packages/platform/examples/base/src/lib/prismaClient.ts
index 526f0420a369e1..db7e267d1a250e 100644
--- a/packages/platform/examples/base/src/lib/prismaClient.ts
+++ b/packages/platform/examples/base/src/lib/prismaClient.ts
@@ -1,7 +1,8 @@
-// prisma client of example app
-// using local prisma db, not related to the cal.com monorepo prisma client
-// eslint-disable-next-line
-import { PrismaClient } from "@prisma/client";
+const isE2E = process.env.NEXT_PUBLIC_IS_E2E === "1";
+
+const { PrismaClient } = isE2E
+ ? require("../../node_modules/.prisma/test-client")
+ : require("@prisma/client");
const prismaClientSingleton = () => {
return new PrismaClient();
diff --git a/packages/platform/examples/base/src/pages/api/managed-user.ts b/packages/platform/examples/base/src/pages/api/managed-user.ts
index ee6013ac3bcbfc..ac32d86f2e2a84 100644
--- a/packages/platform/examples/base/src/pages/api/managed-user.ts
+++ b/packages/platform/examples/base/src/pages/api/managed-user.ts
@@ -20,15 +20,13 @@ async function createUserWithDefaultSchedule(email: string, name: string, avatar
});
const managedUserResponse = await fetch(
- // eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
- origin: "http://localhost:4321",
},
body: JSON.stringify({
email,
@@ -40,6 +38,14 @@ async function createUserWithDefaultSchedule(email: string, name: string, avatar
const managedUserResponseBody = await managedUserResponse.json();
+ if (!managedUserResponse.ok) {
+ throw new Error(
+ `Failed to create managed user: ${managedUserResponse.status} ${
+ managedUserResponse.statusText
+ }\n${JSON.stringify(managedUserResponseBody, null, 2)}`
+ );
+ }
+
await prisma.user.update({
data: {
refreshToken: (managedUserResponseBody.data?.refreshToken as string) ?? "",
@@ -142,17 +148,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
async function createTeam(orgId: number, name: string) {
const response = await fetch(
- // eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
- origin: "http://localhost:4321",
},
body: JSON.stringify({
name,
@@ -167,17 +171,15 @@ async function createTeam(orgId: number, name: string) {
async function createOrgTeamMembershipMember(orgId: number, teamId: number, userId: number) {
await fetch(
- // eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/memberships`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
- origin: "http://localhost:4321",
},
body: JSON.stringify({
userId,
@@ -189,41 +191,34 @@ async function createOrgTeamMembershipMember(orgId: number, teamId: number, user
}
async function createOrgMembershipAdmin(orgId: number, userId: number) {
- await fetch(
- // eslint-disable-next-line turbo/no-undeclared-env-vars
- `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/memberships`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
- [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
- [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
- origin: "http://localhost:4321",
- },
- body: JSON.stringify({
- userId,
- accepted: true,
- role: "ADMIN",
- }),
- }
- );
+ await fetch(`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/memberships`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+
+ [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
+
+ [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
+ },
+ body: JSON.stringify({
+ userId,
+ accepted: true,
+ role: "ADMIN",
+ }),
+ });
}
async function createCollectiveEventType(orgId: number, teamId: number, userIds: number[]) {
await fetch(
- // eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
- origin: "http://localhost:4321",
},
body: JSON.stringify({
lengthInMinutes: 60,
@@ -238,17 +233,15 @@ async function createCollectiveEventType(orgId: number, teamId: number, userIds:
async function createRoundRobinEventType(orgId: number, teamId: number, userIds: number[]) {
await fetch(
- // eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
+
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
- origin: "http://localhost:4321",
},
body: JSON.stringify({
lengthInMinutes: 60,
@@ -266,23 +259,19 @@ async function createDefaultSchedule(accessToken: string) {
const timeZone = "Europe/London";
const isDefault = true;
- const response = await fetch(
- // eslint-disable-next-line turbo/no-undeclared-env-vars
- `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/schedules`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- // eslint-disable-next-line turbo/no-undeclared-env-vars
- Authorization: `Bearer ${accessToken}`,
- },
- body: JSON.stringify({
- name,
- timeZone,
- isDefault,
- }),
- }
- );
+ const response = await fetch(`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/schedules`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ name,
+ timeZone,
+ isDefault,
+ }),
+ });
const schedule = await response.json();
return schedule;
diff --git a/packages/platform/examples/base/tests/availability-settings-atom/availability-settings-atom.e2e.ts b/packages/platform/examples/base/tests/availability-settings-atom/availability-settings-atom.e2e.ts
index 2f741fecd2bb1d..a2287bf82c64c9 100644
--- a/packages/platform/examples/base/tests/availability-settings-atom/availability-settings-atom.e2e.ts
+++ b/packages/platform/examples/base/tests/availability-settings-atom/availability-settings-atom.e2e.ts
@@ -11,6 +11,8 @@ async function selectOption(page: Page, optionNumber: number) {
// eslint-disable-next-line playwright/no-skipped-test
test.skip("availability page loads with all components", async ({ page }) => {
await page.goto("/availability");
+ await page.reload();
+
await expect(page).toHaveURL("/availability");
await expect(page.locator('[data-testid="list-schedules-atom"]')).toBeVisible();
diff --git a/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts b/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts
index 4a342f4b10a3cc..401fb05e50ff21 100644
--- a/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts
+++ b/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts
@@ -10,6 +10,7 @@ async function selectOption(page: Page, optionNumber: number) {
test("tweak availability using AvailabilitySettings Atom", async ({ page }) => {
await page.goto("/booking");
+ await page.reload();
await expect(page).toHaveURL("/booking");
diff --git a/packages/platform/examples/base/tests/connect-atoms/apple-connect.e2e.ts b/packages/platform/examples/base/tests/connect-atoms/apple-connect.e2e.ts
index eed8380b0330ec..6e9e67e29a23d8 100644
--- a/packages/platform/examples/base/tests/connect-atoms/apple-connect.e2e.ts
+++ b/packages/platform/examples/base/tests/connect-atoms/apple-connect.e2e.ts
@@ -5,6 +5,7 @@ test("connect calendar using the apple connect atom", async ({ page }) => {
const appSpecificPassword = process.env.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE;
await page.goto("/");
+ await page.reload();
await expect(page.locator("body")).toBeVisible();
diff --git a/packages/platform/examples/base/tests/create-event-type-atom/create-event-type.e2e.ts b/packages/platform/examples/base/tests/create-event-type-atom/create-event-type.e2e.ts
index d0094357553fab..9c2fb7632604ce 100644
--- a/packages/platform/examples/base/tests/create-event-type-atom/create-event-type.e2e.ts
+++ b/packages/platform/examples/base/tests/create-event-type-atom/create-event-type.e2e.ts
@@ -4,6 +4,7 @@ import { generateRandomText } from "../../src/lib/generateRandomText";
test("create event type using CreateEventTypeAtom", async ({ page }) => {
await page.goto("/");
+ await page.reload();
await page.goto("/event-types");
diff --git a/packages/platform/examples/base/tests/create-team-event-type-atom/create-team-event-type.e2e.ts b/packages/platform/examples/base/tests/create-team-event-type-atom/create-team-event-type.e2e.ts
index 6253cbb963e1e0..28c89378b8f3b8 100644
--- a/packages/platform/examples/base/tests/create-team-event-type-atom/create-team-event-type.e2e.ts
+++ b/packages/platform/examples/base/tests/create-team-event-type-atom/create-team-event-type.e2e.ts
@@ -5,6 +5,7 @@ import { generateRandomText } from "../../src/lib/generateRandomText";
// eslint-disable-next-line playwright/no-skipped-test
test.skip("create team event using CreateTeamEventTypeAtom", async ({ page }) => {
await page.goto("/");
+ await page.reload();
await page.goto("/event-types");
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 8d815369a8e1ae..f58709102e9d83 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -1985,10 +1985,13 @@ model PlatformOAuthClient {
accessTokens AccessToken[]
refreshToken RefreshToken[]
authorizationTokens PlatformAuthorizationToken[]
- webhook Webhook[]
- bookingRedirectUri String?
- bookingCancelRedirectUri String?
+ webhook Webhook[]
+
+ bookingRedirectUri String?
+
+ bookingCancelRedirectUri String?
+
bookingRescheduleRedirectUri String?
areEmailsEnabled Boolean @default(false)
areDefaultEventTypesEnabled Boolean @default(true)
diff --git a/packages/trpc/server/routers/viewer/slots/_router.tsx b/packages/trpc/server/routers/viewer/slots/_router.tsx
index c99b7af3b21ae9..96a556e64dd342 100644
--- a/packages/trpc/server/routers/viewer/slots/_router.tsx
+++ b/packages/trpc/server/routers/viewer/slots/_router.tsx
@@ -7,12 +7,6 @@ import { ZRemoveSelectedSlotInputSchema } from "./removeSelectedSlot.schema";
import { ZReserveSlotInputSchema } from "./reserveSlot.schema";
import { ZGetScheduleInputSchema } from "./types";
-type SlotsRouterHandlerCache = {
- getSchedule?: typeof import("./getSchedule.handler").getScheduleHandler;
- reserveSlot?: typeof import("./reserveSlot.handler").reserveSlotHandler;
- isAvailable?: typeof import("./isAvailable.handler").isAvailableHandler;
-};
-
/** This should be called getAvailableSlots */
export const slotsRouter = router({
getSchedule: publicProcedure.input(ZGetScheduleInputSchema).query(async ({ input, ctx }) => {
diff --git a/scripts/seed.ts b/scripts/seed.ts
index 27584c538d29f5..13f7c9b20da891 100644
--- a/scripts/seed.ts
+++ b/scripts/seed.ts
@@ -184,21 +184,42 @@ async function createPlatformAndSetupUser({
},
});
- const clientId = process.env.SEED_PLATFORM_OAUTH_CLIENT_ID;
- const secret = process.env.SEED_PLATFORM_OAUTH_CLIENT_SECRET;
-
- if (clientId && secret) {
+ const exampleAppClientId = process.env.SEED_PLATFORM_OAUTH_CLIENT_ID;
+ const exampleAppClientSecret = process.env.SEED_PLATFORM_OAUTH_CLIENT_SECRET;
+ if (exampleAppClientId && exampleAppClientSecret) {
await prisma.platformOAuthClient.create({
data: {
- name: "Acme",
+ name: "examples-app",
redirectUris: ["http://localhost:4321"],
permissions: 1023,
areEmailsEnabled: true,
organizationId: team.id,
- id: clientId,
- secret,
+ id: exampleAppClientId,
+ secret: exampleAppClientSecret,
+ },
+ });
+ } else {
+ console.log("⚠️ No example app client id and secret found, skipping example app client creation");
+ }
+
+ const exampleAppClientIdE2e = process.env.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E;
+ const exampleAppClientSecretE2e = process.env.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E;
+ if (exampleAppClientIdE2e && exampleAppClientSecretE2e) {
+ await prisma.platformOAuthClient.create({
+ data: {
+ name: "examples-app-e2e",
+ redirectUris: ["http://localhost:4322"],
+ permissions: 1023,
+ areEmailsEnabled: true,
+ organizationId: team.id,
+ id: exampleAppClientIdE2e,
+ secret: exampleAppClientSecretE2e,
},
});
+ } else {
+ console.log(
+ "⚠️ No example app e2e client id and secret found, skipping example app e2e client creation"
+ );
}
console.log(`\t👤 Added '${teamInput.name}' membership for '${username}' with role '${membershipRole}'`);
}
diff --git a/turbo.json b/turbo.json
index fae9ccf3cb1038..8f294e40bc39f4 100644
--- a/turbo.json
+++ b/turbo.json
@@ -10,6 +10,13 @@
"ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED",
"ATOMS_E2E_OAUTH_CLIENT_SECRET",
"ATOMS_E2E_ORG_ID",
+ "ATOMS_E2E_API_URL_2025_12",
+ "ATOMS_E2E_OAUTH_CLIENT_ID_2025_12",
+ "ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED_2025_12",
+ "ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12",
+ "ATOMS_E2E_APPLE_ID_2025_12",
+ "ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE_2025_12",
+ "ATOMS_E2E_ORG_ID_2025_12",
"BASECAMP3_CLIENT_ID",
"BASECAMP3_CLIENT_SECRET",
"BASECAMP3_USER_AGENT",
@@ -277,6 +284,9 @@
"ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE",
"INTERCOM_API_TOKEN",
"NEXT_PUBLIC_INTERCOM_APP_ID",
+ "NEXT_PUBLIC_X_CAL_ID",
+ "X_CAL_SECRET_KEY",
+ "NEXT_PUBLIC_CALCOM_API_URL",
"MICROSOFT_WEBHOOK_TOKEN",
"MICROSOFT_WEBHOOK_URL",
"_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS",
@@ -291,7 +301,9 @@
"GOOGLE_ADS_ENABLED",
"LINKEDIN_ADS_ENABLED",
"SEED_PLATFORM_OAUTH_CLIENT_ID",
- "SEED_PLATFORM_OAUTH_CLIENT_SECRET"
+ "SEED_PLATFORM_OAUTH_CLIENT_SECRET",
+ "SEED_PLATFORM_OAUTH_CLIENT_ID_E2E",
+ "SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E"
],
"tasks": {
"@calcom/web#copy-app-store-static": {