Compare commits
@@ -15,6 +15,7 @@
|
||||
# - LARK
|
||||
# - WEB3
|
||||
# - SALESFORCE
|
||||
# - ZOHOCRM
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
@@ -109,4 +110,10 @@ INFURA_API_KEY=""
|
||||
# Used for the Salesforce (Sales Cloud) app
|
||||
SALESFORCE_CONSUMER_KEY=""
|
||||
SALESFORCE_CONSUMER_SECRET=""
|
||||
|
||||
# - ZOHOCRM
|
||||
# Used for the Zoho CRM integration
|
||||
ZOHOCRM_CLIENT_ID=""
|
||||
ZOHOCRM_CLIENT_SECRET=""
|
||||
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -22,6 +22,10 @@ CALCOM_LICENSE_KEY=
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
# Opt-in to a dedicated connection pool for Prisma using Prisma Data Proxy
|
||||
# Cold boots will be faster and you will be able to scale your DB independently of your application.
|
||||
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
|
||||
PRISMA_GENERATE_DATAPROXY=false
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - SHARED **************************************************************************************************
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
packages/prisma/zod
|
||||
apps/web/public/embed
|
||||
+1
-1
@@ -30,4 +30,4 @@ packages/features/ee/workflows @CarinaWolli @calcom/core
|
||||
packages/features/tips @PeerRich @calcom/core
|
||||
packages/ui @calcom/ui
|
||||
packages/config @calcom/core
|
||||
packages/app-store/ee/routing-forms @hariombalhara @calcom/core
|
||||
packages/app-store/routing-forms @hariombalhara @calcom/core
|
||||
|
||||
@@ -3,7 +3,7 @@ description: "Cache or restore if necessary"
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v16.x
|
||||
default: v18.x
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -20,6 +20,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/apps/web/.next
|
||||
${{ github.workspace }}/apps/web/public/embed
|
||||
**/.turbo/**
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
name: Yarn install
|
||||
description: "Install all NPM dependencies, caches them and restores if necessary"
|
||||
########################################################################################
|
||||
# "yarn install" composite action for yarn 2/3/4+ and "nodeLinker: node-modules" #
|
||||
#--------------------------------------------------------------------------------------#
|
||||
# Cache: #
|
||||
# - Downloaded zip archive (multi-arch, preserved across yarn.lock changes) #
|
||||
# - Yarn install state (discarded on yarn.lock changes) #
|
||||
# References: #
|
||||
# - bench: https://gist.github.com/belgattitude/0ecd26155b47e7be1be6163ecfbb0f0b #
|
||||
# - vs @setup/node: https://github.com/actions/setup-node/issues/325 #
|
||||
########################################################################################
|
||||
|
||||
name: "Yarn install"
|
||||
description: "Run yarn install with node_modules linker and cache enabled"
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v16.x
|
||||
default: v18.x
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -11,9 +23,40 @@ runs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
cache: "yarn"
|
||||
- name: Yarn install
|
||||
- name: Expose yarn config as "$GITHUB_OUTPUT"
|
||||
id: yarn-config
|
||||
shell: bash
|
||||
run: |
|
||||
yarn install --prefer-offline --frozen-lockfile
|
||||
echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
|
||||
# Yarn cache is also reusable between arch and os.
|
||||
- name: Restore yarn cache
|
||||
uses: actions/cache@v3
|
||||
id: yarn-download-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
|
||||
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: |
|
||||
yarn-download-cache-
|
||||
|
||||
# Invalidated on yarn.lock changes
|
||||
- name: Restore yarn install state
|
||||
id: yarn-install-state-cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .yarn/ci-cache/
|
||||
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
yarn install --inline-builds
|
||||
yarn prisma generate
|
||||
env:
|
||||
# CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action.
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS: "false" # So it doesn't try to remove our private submodule deps
|
||||
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
|
||||
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
|
||||
# Other environment variables
|
||||
HUSKY: "0" # By default do not run HUSKY install
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Cleanup Preview Databases
|
||||
|
||||
env:
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_PROJECT_ID: cl4u26bwz7962859ply7ibuo43t
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
delete:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: snaplet/action@v1
|
||||
if: ${{ env.SNAPLET_ACCESS_TOKEN && env.SNAPLET_PROJECT_ID }}
|
||||
with:
|
||||
delete: true
|
||||
@@ -18,5 +18,3 @@ jobs:
|
||||
days-before-stale: 60
|
||||
include-only-assigned: true
|
||||
days-before-close: -1
|
||||
|
||||
|
||||
|
||||
@@ -17,11 +17,14 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@1.4.9
|
||||
uses: crowdin/github-action@1.5.1
|
||||
with:
|
||||
upload_translations: true
|
||||
upload_sources: true
|
||||
download_translations: true
|
||||
pull_request_labels: "♻️ autoupdate, automerge"
|
||||
push_translations: true
|
||||
commit_message: "New Crowdin translations by Github Action"
|
||||
localization_branch_name: main
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# .github/workflows/preview.yml
|
||||
|
||||
name: Preview Environment
|
||||
|
||||
env:
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_PROJECT_ID: ${{ secrets.SNAPLET_PROJECT_ID }}
|
||||
VERCEL_ACCESS_TOKEN: ${{ secrets.VERCEL_ACCESS_TOKEN }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
db-preview-deploy:
|
||||
if: ${{ github.event.action == 'opened' || github.event.action == 'synchronize' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: snaplet
|
||||
uses: snaplet/action@v1
|
||||
- uses: snaplet/vercel-action@v1
|
||||
with:
|
||||
env: |
|
||||
DATABASE_URL=${{ steps.snaplet.outputs.database-url }}
|
||||
db-preview-delete:
|
||||
if: ${{ github.event.action == 'closed' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: snaplet/action@v1
|
||||
with:
|
||||
delete: true
|
||||
- uses: snaplet/vercel-action@v1
|
||||
with:
|
||||
delete: true
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Staging DB Snapshot
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1-5" # At 04:00 on every day-of-week from Monday through Friday.
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
db-staging-snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Snaplet CLI
|
||||
run: curl -sL https://app.snaplet.dev/get-cli/ | bash
|
||||
- name: Restore Snapshot
|
||||
run: snaplet snapshot restore -y
|
||||
env:
|
||||
SNAPLET_DATABASE_URL: ${{ secrets.SNAPLET_DATA_TARGET_DB_URL }}
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_DATABASE_ID: ${{ secrets.SNAPLET_DATABASE_ID }}
|
||||
@@ -0,0 +1,36 @@
|
||||
name: E2E Embed tests and booking flow(for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-playwright-install
|
||||
- uses: ./.github/actions/env-read-file
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn test-e2e:embed-react
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results
|
||||
@@ -25,19 +25,12 @@ jobs:
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn turbo run embed-tests-update-snapshots:ci --scope=@calcom/embed-react --concurrency=1
|
||||
run: yarn test-e2e:embed
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload embed-core results
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-embed-core
|
||||
path: packages/embeds/embed-core/playwright/results
|
||||
|
||||
- name: Upload embed-react results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-embed-react
|
||||
path: packages/embeds/embed-react/playwright/results
|
||||
name: test-results
|
||||
path: test-results
|
||||
|
||||
@@ -20,7 +20,7 @@ env:
|
||||
INPUT_ENV_PAYMENT_FEE_FIXED: 10
|
||||
INPUT_ENV_SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
INPUT_ENV_SAML_ADMINS: pro@example.com
|
||||
INPUT_ENV_NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
INPUT_ENV_NEXTAUTH_URL: http://127.0.0.1:3000/api/auth
|
||||
INPUT_ENV_NEXT_PUBLIC_IS_E2E: 1
|
||||
# INPUT_ENV_EMAIL_FROM: e2e@cal.com
|
||||
# INPUT_ENV_EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ozaytsev86/create-env-file@v1
|
||||
with:
|
||||
file-name: ${{ github.workspace }}/gh.env
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
outputs:
|
||||
app-store: ${{ steps.filter.outputs.app-store }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
embed-react: ${{ steps.filter.outputs.embed-react }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
@@ -34,6 +35,9 @@ jobs:
|
||||
embed:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
embed-react:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
|
||||
env:
|
||||
name: Create env file
|
||||
@@ -81,6 +85,13 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: E2E React embeds tests
|
||||
if: ${{ needs.changes.outputs.embed-react == 'true' }}
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
needs: build
|
||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||
|
||||
@@ -87,3 +87,12 @@ apps/api
|
||||
apps/website
|
||||
apps/console
|
||||
apps/auth
|
||||
|
||||
# Yarn Modern
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
@@ -14,3 +14,4 @@ public
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
packages/prisma/zod
|
||||
apps/web/public/embed
|
||||
|
||||
+541
File diff suppressed because one or more lines are too long
+873
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.4.1.cjs
|
||||
+1
-2
@@ -4,7 +4,7 @@ Contributions are what make the open source community such an amazing place to l
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
## Priorities
|
||||
## Priorities
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -57,7 +57,6 @@ Contributions are what make the open source community such an amazing place to l
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
|
||||
@@ -350,17 +350,16 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
|
||||
<img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
<!-- CONTRIBUTORS -->
|
||||
|
||||
### Contributors
|
||||
|
||||
|
||||
<a href="https://github.com/calcom/cal.com/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=calcom/cal.com" />
|
||||
</a>
|
||||
|
||||
|
||||
<!-- TRANSLATIONS -->
|
||||
|
||||
### Translations
|
||||
@@ -452,6 +451,19 @@ following
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
### Obtaining ZohoCRM Client ID and Secret
|
||||
|
||||
1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
|
||||
2. From within the API console page, go to "Applications".
|
||||
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
|
||||
4. Fill in any information you want in the "Client Details" tab
|
||||
5. Go to tab "Client Secret" tab.
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zohocrm/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
|
||||
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
||||
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
||||
|
||||
## Workflows
|
||||
|
||||
### Setting up SendGrid for Email reminders
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
14.17
|
||||
v18
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/config": "*",
|
||||
"eslint": "^8.34.0"
|
||||
"@calcom/config": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
node_modules
|
||||
prisma/zod
|
||||
public/embed
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v16
|
||||
v18
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public/embed
|
||||
@@ -669,8 +669,11 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
embedUrl: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const router = useRouter();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const dialogContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const s = (href: string) => {
|
||||
const searchParams = new URLSearchParams(router.asPath.split("?")[1] || "");
|
||||
const [a, b] = href.split("=");
|
||||
@@ -809,7 +812,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<DialogContent size="xl" className="p-0.5" type="creation">
|
||||
<DialogContent ref={dialogContentRef} size="xl" className="p-0.5" type="creation">
|
||||
<div className="flex">
|
||||
<div className="flex w-1/3 flex-col bg-gray-50 p-8">
|
||||
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||
@@ -960,6 +963,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<div>Button Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
@@ -979,6 +984,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<div>Text Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
@@ -1060,6 +1067,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<div className="mb-2 pt-2">{palette.title}</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
|
||||
@@ -24,6 +24,7 @@ type AvailableTimesProps = {
|
||||
eventTypeSlug: string;
|
||||
date?: Dayjs;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
bookingAttendees?: number | null;
|
||||
slots?: Slot[];
|
||||
isLoading: boolean;
|
||||
ethSignature?: string;
|
||||
@@ -39,6 +40,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
timeFormat,
|
||||
onTimeFormatChange,
|
||||
seatsPerTimeSlot,
|
||||
bookingAttendees,
|
||||
ethSignature,
|
||||
}) => {
|
||||
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
||||
@@ -64,13 +66,13 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
return (
|
||||
<div ref={slotPickerRef}>
|
||||
{!!date ? (
|
||||
<div className="dark:bg-darkgray-100 mt-8 flex h-full w-full flex-col px-4 text-center sm:mt-0 sm:p-5 md:-mb-5 md:min-w-[200px] lg:min-w-[300px]">
|
||||
<div className="mb-6 flex items-center text-left text-base">
|
||||
<div className="dark:bg-darkgray-100 mt-8 flex h-full w-full flex-col rounded-md px-4 text-center sm:mt-0 sm:p-4 md:-mb-5 md:min-w-[200px] md:p-4 lg:min-w-[300px]">
|
||||
<div className="mb-4 flex items-center text-left text-base">
|
||||
<div className="mr-4">
|
||||
<span className="text-bookingdarker dark:text-darkgray-800 font-semibold text-gray-900">
|
||||
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
||||
</span>
|
||||
<span className="text-bookinglight font-medium">
|
||||
<span className="text-bookinglight">
|
||||
, {date.toDate().toLocaleString(i18n.language, { month: "short" })} {date.format(" D ")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -85,7 +87,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={ref} className="flex-grow overflow-y-auto sm:block md:h-[364px]">
|
||||
<div
|
||||
ref={ref}
|
||||
className="scroll-bar scrollbar-track-w-20 relative -mb-4 flex-grow overflow-y-auto sm:block md:h-[364px]">
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
@@ -114,18 +118,28 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
bookingUrl.query.bookingUid = slot.bookingUid;
|
||||
}
|
||||
|
||||
let slotFull, notEnoughSeats;
|
||||
|
||||
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
|
||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
|
||||
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
|
||||
|
||||
return (
|
||||
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
||||
{/* ^ data-slot-owner is helpful in debugging and used to identify the owners of the slot. Owners are the users which have the timeslot in their schedule. It doesn't consider if a user has that timeslot booked */}
|
||||
{/* Current there is no way to disable Next.js Links */}
|
||||
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||
{seatsPerTimeSlot && slot.attendees && (slotFull || notEnoughSeats) ? (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-primary-500 dark:bg-darkgray-200 dark:text-darkgray-900 mb-2 block rounded-sm border bg-white py-2 font-medium opacity-25 dark:border-transparent ",
|
||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}>
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && <p className="text-sm">{t("booking_full")}</p>}
|
||||
{notEnoughSeats ? (
|
||||
<p className="text-sm">{t("not_enough_seats")}</p>
|
||||
) : slots ? (
|
||||
<p className="text-sm">{t("booking_full")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
|
||||
@@ -83,20 +83,20 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
size="sm"
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="mt-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
<h2 className="mt-1 mb-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold text-gray-900">
|
||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold leading-none text-gray-900">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="dark:text-darkgray-600 flex flex-col space-y-4 text-sm font-medium text-gray-600">
|
||||
{eventType?.description && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex",
|
||||
"scroll-bar scrollbar-track-w-20 -mx-5 flex max-h-[180px] overflow-y-scroll px-5 ",
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
)}>
|
||||
<div className="scroll-bar scrollbar-track-w-20 max-h-[200px] max-w-full flex-shrink overflow-scroll break-words [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
<div className="max-w-full flex-shrink break-words [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,6 +94,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
if (!booking.seatsReferences[0]) {
|
||||
return undefined;
|
||||
}
|
||||
return booking.seatsReferences[0].referenceUid;
|
||||
};
|
||||
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
@@ -135,7 +142,8 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}`,
|
||||
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}
|
||||
`,
|
||||
icon: FiX,
|
||||
},
|
||||
{
|
||||
@@ -146,7 +154,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
id: "reschedule",
|
||||
icon: FiClock,
|
||||
label: t("reschedule_booking"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
href: `/reschedule/${booking.uid}${
|
||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
@@ -179,10 +189,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||
<FiSend className="-mt-[1px] w-4 rotate-45" />
|
||||
<p className="ml-2 ">{t("reschedule_request_sent")}</p>
|
||||
</div>
|
||||
<Badge startIcon={FiSend} size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -224,7 +233,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const showRecordingsButtons =
|
||||
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||
|
||||
const title = decodeURIComponent(booking.title);
|
||||
const title = booking.title;
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -386,13 +395,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block text-left text-sm md:hidden">
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
@@ -23,18 +23,26 @@ type Props = {
|
||||
setIsCancellationMode: (value: boolean) => void;
|
||||
theme: string | null;
|
||||
allRemainingBookings: boolean;
|
||||
seatReferenceUid?: string;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { booking, allRemainingBookings } = props;
|
||||
const { booking, allRemainingBookings, seatReferenceUid } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
useTheme(props.theme);
|
||||
|
||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||
if (node !== null) {
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
@@ -53,6 +61,7 @@ export default function CancelBooking(props: Props) {
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
||||
<TextArea
|
||||
ref={cancelBookingRef}
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
@@ -73,16 +82,16 @@ export default function CancelBooking(props: Props) {
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const payload = {
|
||||
id: booking?.id,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
};
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
id: booking?.id,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
// @NOTE: very important this shouldn't cancel with number ID use uid instead
|
||||
seatReferenceUid,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -78,6 +78,7 @@ export const SlotPicker = ({
|
||||
recurringEventCount,
|
||||
users,
|
||||
seatsPerTimeSlot,
|
||||
bookingAttendees,
|
||||
weekStart = 0,
|
||||
ethSignature,
|
||||
}: {
|
||||
@@ -89,6 +90,7 @@ export const SlotPicker = ({
|
||||
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||
timeZone?: string;
|
||||
seatsPerTimeSlot?: number;
|
||||
bookingAttendees?: number;
|
||||
recurringEventCount?: number;
|
||||
users: string[];
|
||||
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
@@ -164,7 +166,7 @@ export const SlotPicker = ({
|
||||
<DatePicker
|
||||
isLoading={isLoading}
|
||||
className={classNames(
|
||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-4 lg:min-w-[455px]",
|
||||
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||
)}
|
||||
includedDates={Object.keys(monthSlots).filter((k) => monthSlots[k].length > 0)}
|
||||
@@ -192,6 +194,7 @@ export const SlotPicker = ({
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
bookingAttendees={bookingAttendees}
|
||||
recurringCount={recurringEventCount}
|
||||
ethSignature={ethSignature}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function TimezoneDropdown({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit max-w-[20rem] items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit max-w-[20rem] items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
|
||||
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||
</div>
|
||||
|
||||
@@ -23,8 +23,9 @@ import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { FiCreditCard, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||
import { FiCreditCard, FiUser, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
|
||||
@@ -52,6 +53,7 @@ const dateQuerySchema = z.object({
|
||||
rescheduleUid: z.string().optional().default(""),
|
||||
date: z.string().optional().default(""),
|
||||
timeZone: z.string().optional().default(""),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
@@ -124,6 +126,15 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
: undefined,
|
||||
];
|
||||
|
||||
const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery(
|
||||
{
|
||||
seatReferenceUid: rescheduleUid,
|
||||
},
|
||||
{
|
||||
enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
|
||||
<HeadSeo
|
||||
@@ -168,10 +179,26 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||
"min-w-full md:w-[280px] md:min-w-[280px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{rescheduleUid && eventType.seatsPerTimeSlot && bookingAttendees && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-nowrap items-center text-sm font-medium",
|
||||
"dark:text-darkgray-600 text-gray-600",
|
||||
"ltr:mr-[10px] rtl:ml-[10px]"
|
||||
)}>
|
||||
<FiUser
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||
"mt-[2px]"
|
||||
)}
|
||||
/>{" "}
|
||||
{t("event_type_seats", { numberOfSeats: bookingAttendees })}
|
||||
</div>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
@@ -247,6 +274,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
bookingAttendees={bookingAttendees || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
ethSignature={gateState.rainbowToken}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
@@ -15,6 +15,7 @@ import { getEventLocationType, locationKeyToString } from "@calcom/app-store/loc
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
@@ -38,6 +39,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button, Form, Tooltip } from "@calcom/ui";
|
||||
import { FiAlertTriangle, FiCalendar, FiRefreshCw, FiUser } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -113,7 +115,10 @@ const BookingFields = ({
|
||||
}
|
||||
|
||||
// We don't show `notes` field during reschedule
|
||||
if (field.name === SystemField.Enum.notes && !!rescheduleUid) {
|
||||
if (
|
||||
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
|
||||
!!rescheduleUid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -213,6 +218,20 @@ const BookingPage = ({
|
||||
duration = Number(queryDuration);
|
||||
}
|
||||
|
||||
// This is a workaround for forcing the same time format for both server side rendering and client side rendering
|
||||
// At initial render, we use the default time format which is 12H
|
||||
const [withDefaultTimeFormat, setWithDefaultTimeFormat] = useState(true);
|
||||
const parseDateFunc = useCallback(
|
||||
(date: string | null | Dayjs) => {
|
||||
return parseDate(date, i18n, withDefaultTimeFormat);
|
||||
},
|
||||
[withDefaultTimeFormat]
|
||||
);
|
||||
// After intial render on client side, we let parseDateFunc to use the time format from the localStorage
|
||||
useEffect(() => {
|
||||
setWithDefaultTimeFormat(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
@@ -226,12 +245,12 @@ const BookingPage = ({
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { uid, paymentUid } = responseData;
|
||||
const { uid } = responseData;
|
||||
|
||||
if (paymentUid) {
|
||||
if ("paymentUid" in responseData && !!responseData.paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
paymentUid,
|
||||
paymentUid: responseData.paymentUid,
|
||||
date,
|
||||
name: bookingForm.getValues("responses.name"),
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
@@ -246,6 +265,7 @@ const BookingPage = ({
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
|
||||
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||
},
|
||||
});
|
||||
@@ -370,19 +390,30 @@ const BookingPage = ({
|
||||
// Calculate the booking date(s)
|
||||
let recurringStrings: string[] = [],
|
||||
recurringDates: Date[] = [];
|
||||
const parseRecurringDatesFunc = useCallback(
|
||||
(date: string | null | Dayjs, recurringEvent: RecurringEvent, recurringCount: number) => {
|
||||
return parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
timeZone: timeZone(),
|
||||
recurringEvent: recurringEvent,
|
||||
recurringCount: recurringCount,
|
||||
withDefaultTimeFormat: withDefaultTimeFormat,
|
||||
},
|
||||
i18n
|
||||
);
|
||||
},
|
||||
[withDefaultTimeFormat, date, eventType.recurringEvent, recurringEventCount]
|
||||
);
|
||||
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
timeZone: timeZone(),
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: parseInt(recurringEventCount.toString()),
|
||||
},
|
||||
i18n
|
||||
[recurringStrings, recurringDates] = parseRecurringDatesFunc(
|
||||
date,
|
||||
eventType.recurringEvent,
|
||||
parseInt(recurringEventCount.toString())
|
||||
);
|
||||
}
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
const bookEvent = (bookingValues: BookingFormValues) => {
|
||||
telemetry.event(
|
||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||
{ isTeamBooking: document.URL.includes("team/") }
|
||||
@@ -405,7 +436,7 @@ const BookingPage = ({
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...booking,
|
||||
...bookingValues,
|
||||
start: dayjs(recurringDate).format(),
|
||||
end: dayjs(recurringDate).add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
@@ -425,7 +456,7 @@ const BookingPage = ({
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
...bookingValues,
|
||||
start: dayjs(date).tz(timeZone()).format(),
|
||||
end: dayjs(date).tz(timeZone()).add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
@@ -433,12 +464,13 @@ const BookingPage = ({
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
bookingUid: router.query.bookingUid as string,
|
||||
bookingUid: (router.query.bookingUid as string) || booking?.uid,
|
||||
user: router.query.user,
|
||||
metadata,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
ethSignature: gateState.rainbowToken,
|
||||
seatReferenceUid: router.query.seatReferenceUid as string,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -507,7 +539,7 @@ const BookingPage = ({
|
||||
<div className="text-sm font-medium">
|
||||
{isClientTimezoneAvailable &&
|
||||
(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
||||
`${parseDate(date, i18n)}`}
|
||||
`${parseDateFunc(date)}`}
|
||||
{isClientTimezoneAvailable &&
|
||||
!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
@@ -537,7 +569,7 @@ const BookingPage = ({
|
||||
<FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{isClientTimezoneAvailable &&
|
||||
typeof booking.startTime === "string" &&
|
||||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
parseDateFunc(dayjs(booking.startTime))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -264,6 +264,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
defaultValue={!!eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
data-testid="offer-seats-toggle"
|
||||
title={t("offer_seats")}
|
||||
description={t("offer_seats_description")}
|
||||
checked={value}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { Key } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { SingleValue } from "react-select";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
@@ -11,8 +13,17 @@ import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { PeriodType } from "@calcom/prisma/client";
|
||||
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import {
|
||||
Button,
|
||||
DateRangePicker,
|
||||
Input,
|
||||
InputField,
|
||||
Label,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
import { FiPlus, FiTrash } from "@calcom/ui/components/icon";
|
||||
|
||||
const MinimumBookingNoticeInput = React.forwardRef<
|
||||
@@ -260,7 +271,34 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
formMethods.setValue("bookingLimits", {});
|
||||
}
|
||||
}}>
|
||||
<BookingLimits />
|
||||
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="durationLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("durationLimits", {
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("durationLimits", {});
|
||||
}
|
||||
}}>
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
@@ -348,124 +386,158 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
);
|
||||
};
|
||||
|
||||
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
|
||||
type BookingLimitsKey = keyof BookingLimit;
|
||||
const BookingLimits = () => {
|
||||
type IntervalLimitsKey = keyof IntervalLimit;
|
||||
|
||||
const intervalOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"] as const;
|
||||
|
||||
const INTERVAL_LIMIT_OPTIONS = intervalOrderKeys.map((key) => ({
|
||||
value: key as keyof IntervalLimit,
|
||||
label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
|
||||
}));
|
||||
|
||||
type IntervalLimitItemProps = {
|
||||
key: Key;
|
||||
limitKey: IntervalLimitsKey;
|
||||
step: number;
|
||||
value: number;
|
||||
textFieldSuffix?: string;
|
||||
selectOptions: { value: keyof IntervalLimit; label: string }[];
|
||||
hasDeleteButton?: boolean;
|
||||
onDelete: (intervalLimitsKey: IntervalLimitsKey) => void;
|
||||
onLimitChange: (intervalLimitsKey: IntervalLimitsKey, limit: number) => void;
|
||||
onIntervalSelect: (interval: SingleValue<{ value: keyof IntervalLimit; label: string }>) => void;
|
||||
};
|
||||
|
||||
const IntervalLimitItem = ({
|
||||
limitKey,
|
||||
step,
|
||||
value,
|
||||
textFieldSuffix,
|
||||
selectOptions,
|
||||
hasDeleteButton,
|
||||
onDelete,
|
||||
onLimitChange,
|
||||
onIntervalSelect,
|
||||
}: IntervalLimitItemProps) => {
|
||||
return (
|
||||
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
containerClassName={`${textFieldSuffix ? "w-36" : "w-16"} -mb-1`}
|
||||
placeholder={`${value}`}
|
||||
min={step}
|
||||
step={step}
|
||||
defaultValue={value}
|
||||
addOnSuffix={textFieldSuffix}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
|
||||
/>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
isSearchable={false}
|
||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||
onChange={onIntervalSelect}
|
||||
/>
|
||||
{hasDeleteButton && (
|
||||
<Button variant="icon" StartIcon={FiTrash} color="destructive" onClick={() => onDelete(limitKey)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type IntervalLimitsManagerProps<K extends "durationLimits" | "bookingLimits"> = {
|
||||
propertyName: K;
|
||||
defaultLimit: number;
|
||||
step: number;
|
||||
textFieldSuffix?: string;
|
||||
};
|
||||
|
||||
const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||
propertyName,
|
||||
defaultLimit,
|
||||
step,
|
||||
textFieldSuffix,
|
||||
}: IntervalLimitsManagerProps<K>) => {
|
||||
const { watch, setValue, control } = useFormContext<FormValues>();
|
||||
const watchBookingLimits = watch("bookingLimits");
|
||||
const watchIntervalLimits = watch(propertyName);
|
||||
const { t } = useLocale();
|
||||
|
||||
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
const BOOKING_LIMIT_OPTIONS: {
|
||||
value: keyof BookingLimit;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "PER_DAY",
|
||||
label: "Per Day",
|
||||
},
|
||||
{
|
||||
value: "PER_WEEK",
|
||||
label: "Per Week",
|
||||
},
|
||||
{
|
||||
value: "PER_MONTH",
|
||||
label: "Per Month",
|
||||
},
|
||||
{
|
||||
value: "PER_YEAR",
|
||||
label: "Per Year",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
name={propertyName}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const currentBookingLimits = value;
|
||||
const currentIntervalLimits = value;
|
||||
|
||||
const addLimit = () => {
|
||||
if (!currentIntervalLimits || !watchIntervalLimits) return;
|
||||
const currentKeys = Object.keys(watchIntervalLimits);
|
||||
|
||||
const [rest] = Object.values(INTERVAL_LIMIT_OPTIONS).filter(
|
||||
(option) => !currentKeys.includes(option.value)
|
||||
);
|
||||
if (!rest || !currentKeys.length) return;
|
||||
//currentDurationLimits is always defined so can be casted
|
||||
// @ts-expect-error FIXME Fix these typings
|
||||
setValue(propertyName, {
|
||||
...watchIntervalLimits,
|
||||
[rest.value]: defaultLimit,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ul ref={animateRef}>
|
||||
{currentBookingLimits &&
|
||||
watchBookingLimits &&
|
||||
Object.entries(currentBookingLimits)
|
||||
.sort(([limitkeyA], [limitKeyB]) => {
|
||||
{currentIntervalLimits &&
|
||||
watchIntervalLimits &&
|
||||
Object.entries(currentIntervalLimits)
|
||||
.sort(([limitKeyA], [limitKeyB]) => {
|
||||
return (
|
||||
validationOrderKeys.indexOf(limitkeyA as BookingLimitsKey) -
|
||||
validationOrderKeys.indexOf(limitKeyB as BookingLimitsKey)
|
||||
intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
);
|
||||
})
|
||||
.map(([key, bookingAmount]) => {
|
||||
const bookingLimitKey = key as BookingLimitsKey;
|
||||
.map(([key, value]) => {
|
||||
const limitKey = key as IntervalLimitsKey;
|
||||
return (
|
||||
<div
|
||||
className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse"
|
||||
key={bookingLimitKey}>
|
||||
<Input
|
||||
id={`${bookingLimitKey}-limit`}
|
||||
type="number"
|
||||
className="mb-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||
placeholder="1"
|
||||
min={1}
|
||||
defaultValue={bookingAmount}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setValue(`bookingLimits.${bookingLimitKey}`, parseInt(val));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={BOOKING_LIMIT_OPTIONS.filter(
|
||||
(option) => !Object.keys(currentBookingLimits).includes(option.value)
|
||||
)}
|
||||
isSearchable={false}
|
||||
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
|
||||
onChange={(val) => {
|
||||
const current = currentBookingLimits;
|
||||
const currentValue = watchBookingLimits[bookingLimitKey];
|
||||
<IntervalLimitItem
|
||||
key={key}
|
||||
limitKey={limitKey}
|
||||
step={step}
|
||||
value={value}
|
||||
textFieldSuffix={textFieldSuffix}
|
||||
hasDeleteButton={Object.keys(currentIntervalLimits).length > 1}
|
||||
selectOptions={INTERVAL_LIMIT_OPTIONS.filter(
|
||||
(option) => !Object.keys(currentIntervalLimits).includes(option.value)
|
||||
)}
|
||||
onLimitChange={(intervalLimitKey, val) =>
|
||||
// @ts-expect-error FIXME Fix these typings
|
||||
setValue(`${propertyName}.${intervalLimitKey}`, val)
|
||||
}
|
||||
onDelete={(intervalLimitKey) => {
|
||||
const current = currentIntervalLimits;
|
||||
delete current[intervalLimitKey];
|
||||
onChange(current);
|
||||
}}
|
||||
onIntervalSelect={(interval) => {
|
||||
const current = currentIntervalLimits;
|
||||
const currentValue = watchIntervalLimits[limitKey];
|
||||
|
||||
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||
delete current[bookingLimitKey];
|
||||
const newData = {
|
||||
...current,
|
||||
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||
[val?.value as BookingLimitsKey]: currentValue,
|
||||
};
|
||||
onChange(newData);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
StartIcon={FiTrash}
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
const current = currentBookingLimits;
|
||||
delete current[key as BookingLimitsKey];
|
||||
onChange(current);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||
delete current[limitKey];
|
||||
const newData = {
|
||||
...current,
|
||||
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||
[interval?.value as IntervalLimitsKey]: currentValue,
|
||||
};
|
||||
onChange(newData);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={FiPlus}
|
||||
onClick={() => {
|
||||
if (!currentBookingLimits || !watchBookingLimits) return;
|
||||
const currentKeys = Object.keys(watchBookingLimits);
|
||||
|
||||
const rest = Object.values(BOOKING_LIMIT_OPTIONS).filter(
|
||||
(option) => !currentKeys.includes(option.value)
|
||||
);
|
||||
if (!rest || !currentKeys) return;
|
||||
//currentBookingLimits is always defined so can be casted
|
||||
|
||||
setValue("bookingLimits", {
|
||||
...watchBookingLimits,
|
||||
[rest[0].value]: 1,
|
||||
});
|
||||
}}>
|
||||
{currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && (
|
||||
<Button color="minimal" StartIcon={FiPlus} onClick={addLimit}>
|
||||
{t("add_limit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form, Label, PasswordField } from "@calcom/ui";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BaseSyntheticEvent } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form } from "@calcom/ui";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form, Label, PasswordField } from "@calcom/ui";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BaseSyntheticEvent } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form, TextField } from "@calcom/ui";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { isPasswordValid } from "@calcom/lib/auth";
|
||||
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmailField, EmptyScreen, Label, PasswordField, TextField } from "@calcom/ui";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import type { TeamPageProps } from "pages/team/[slug]";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
type TeamType = TeamPageProps["team"];
|
||||
type TeamType = NonNullable<TeamWithMembers>;
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number];
|
||||
|
||||
@@ -57,7 +57,7 @@ const Members = ({ members, teamName }: { members: MembersType; teamName: string
|
||||
);
|
||||
};
|
||||
|
||||
const Team = ({ team }: Omit<TeamPageProps, "trpcState">) => {
|
||||
const Team = ({ team }: { team: TeamType }) => {
|
||||
return (
|
||||
<div>
|
||||
<Members members={team.members} teamName={team.name} />
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import type { Session } from "next-auth";
|
||||
import type { GetSessionParams } from "next-auth/react";
|
||||
import { getSession as getSessionInner } from "next-auth/react";
|
||||
|
||||
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||
export async function hashPassword(password: string) {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
return hashedPassword;
|
||||
}
|
||||
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||
export async function verifyPassword(password: string, hashedPassword: string) {
|
||||
const isValid = await compare(password, hashedPassword);
|
||||
return isValid;
|
||||
}
|
||||
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||
export async function getSession(options: GetSessionParams): Promise<Session | null> {
|
||||
const session = await getSessionInner(options);
|
||||
|
||||
// that these are equal are ensured in `[...nextauth]`'s callback
|
||||
return session as Session | null;
|
||||
}
|
||||
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||
export enum ErrorCode {
|
||||
UserNotFound = "user-not-found",
|
||||
IncorrectPassword = "incorrect-password",
|
||||
UserMissingPassword = "missing-password",
|
||||
TwoFactorDisabled = "two-factor-disabled",
|
||||
TwoFactorAlreadyEnabled = "two-factor-already-enabled",
|
||||
TwoFactorSetupRequired = "two-factor-setup-required",
|
||||
SecondFactorRequired = "second-factor-required",
|
||||
IncorrectTwoFactorCode = "incorrect-two-factor-code",
|
||||
InternalServerError = "internal-server-error",
|
||||
NewPasswordMatchesOld = "new-password-matches-old",
|
||||
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
|
||||
RateLimitExceeded = "rate-limit-exceeded",
|
||||
InvalidPassword = "invalid-password",
|
||||
}
|
||||
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
|
||||
[IdentityProvider.CAL]: "Cal",
|
||||
[IdentityProvider.GOOGLE]: "Google",
|
||||
[IdentityProvider.SAML]: "SAML",
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import parser from "accept-language-parser";
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { Maybe } from "@calcom/trpc/server";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import { i18n } from "../../../next-i18next.config";
|
||||
|
||||
export function getLocaleFromHeaders(req: IncomingMessage): string {
|
||||
@@ -15,9 +15,13 @@ export function getLocaleFromHeaders(req: IncomingMessage): string {
|
||||
return preferredLocale ?? i18n.defaultLocale;
|
||||
}
|
||||
|
||||
export const getOrSetUserLocaleFromHeaders = async (req: IncomingMessage): Promise<string> => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const session = await getSession({ req });
|
||||
export const getOrSetUserLocaleFromHeaders = async (
|
||||
req: GetServerSidePropsContext["req"],
|
||||
res: GetServerSidePropsContext["res"]
|
||||
): Promise<string> => {
|
||||
const { default: prisma } = await import("@calcom/prisma");
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
const preferredLocale = getLocaleFromHeaders(req);
|
||||
|
||||
if (session?.user?.id) {
|
||||
|
||||
@@ -55,6 +55,8 @@ async function getBooking(
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
startTime: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
@@ -65,6 +67,12 @@ async function getBooking(
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,21 +3,23 @@ import { RRule } from "rrule";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { detectBrowserTimeFormat, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { parseZone } from "./parseZone";
|
||||
|
||||
const processDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
const processDate = (date: string | null | Dayjs, i18n: I18n, withDefaultTimeFormat: boolean) => {
|
||||
const parsedZone = parseZone(date);
|
||||
if (!parsedZone?.isValid()) return "Invalid date";
|
||||
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
|
||||
const formattedTime = parsedZone?.format(
|
||||
withDefaultTimeFormat ? TimeFormat.TWELVE_HOUR : detectBrowserTimeFormat
|
||||
);
|
||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
||||
};
|
||||
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n, withDefaultTimeFormat: boolean) => {
|
||||
if (!date) return ["No date"];
|
||||
return processDate(date, i18n);
|
||||
return processDate(date, i18n, withDefaultTimeFormat);
|
||||
};
|
||||
|
||||
export const parseRecurringDates = (
|
||||
@@ -26,11 +28,13 @@ export const parseRecurringDates = (
|
||||
timeZone,
|
||||
recurringEvent,
|
||||
recurringCount,
|
||||
withDefaultTimeFormat,
|
||||
}: {
|
||||
startDate: string | null | Dayjs;
|
||||
timeZone?: string;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
recurringCount: number;
|
||||
withDefaultTimeFormat: boolean;
|
||||
},
|
||||
i18n: I18n
|
||||
): [string[], Date[]] => {
|
||||
@@ -50,7 +54,7 @@ export const parseRecurringDates = (
|
||||
});
|
||||
const dateStrings = times.map((t) => {
|
||||
// finally; show in local timeZone again
|
||||
return processDate(t.tz(timeZone), i18n);
|
||||
return processDate(t.tz(timeZone), i18n, withDefaultTimeFormat);
|
||||
});
|
||||
|
||||
return [dateStrings, times.map((t) => t.toDate())];
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import type { Attendee, Booking } from "@prisma/client";
|
||||
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
paymentUid?: string;
|
||||
attendees: Attendee[];
|
||||
appsStatus?: AppsStatus[];
|
||||
};
|
||||
export type BookingResponse = Awaited<
|
||||
ReturnType<typeof import("@calcom/features/bookings/lib/handleNewBooking").default>
|
||||
>;
|
||||
|
||||
+10
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.6.9",
|
||||
"version": "2.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -13,25 +13,24 @@
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start:bg": "next start &",
|
||||
"lint": "eslint . --ignore-path .gitignore",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/web.json",
|
||||
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": "16.x || 18.x",
|
||||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
"node": "18",
|
||||
"yarn": "3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "1.7.1",
|
||||
"@boxyhq/saml-jackson": "1.8.2",
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/app-store-cli": "*",
|
||||
"@calcom/core": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/embed-core": "*",
|
||||
"@calcom/embed-react": "*",
|
||||
"@calcom/embed-snippet": "*",
|
||||
"@calcom/embed-core": "workspace:*",
|
||||
"@calcom/embed-react": "workspace:*",
|
||||
"@calcom/embed-snippet": "workspace:*",
|
||||
"@calcom/features": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
@@ -70,6 +69,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"classnames": "^2.3.1",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"entities": "^4.4.0",
|
||||
"eslint-config-next": "^13.2.1",
|
||||
"googleapis": "^84.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -87,7 +87,7 @@
|
||||
"micro": "^10.0.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"next": "^13.2.1",
|
||||
"next-auth": "^4.18.8",
|
||||
"next-auth": "^4.20.1",
|
||||
"next-axiom": "^0.16.0",
|
||||
"next-collect": "^0.2.1",
|
||||
"next-i18next": "^11.3.0",
|
||||
@@ -134,7 +134,7 @@
|
||||
"@calcom/config": "*",
|
||||
"@calcom/types": "*",
|
||||
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
|
||||
"@playwright/test": "^1.25.0",
|
||||
"@playwright/test": "^1.31.2",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@types/accept-language-parser": "1.5.2",
|
||||
"@types/async": "^3.2.15",
|
||||
@@ -162,7 +162,6 @@
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"detect-port": "^1.3.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"eslint": "^8.34.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"module-alias": "^2.2.2",
|
||||
"msw": "^0.42.3",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import {
|
||||
@@ -12,15 +14,13 @@ import {
|
||||
getUsernameList,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { bookEventTypeSelect } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
|
||||
import {
|
||||
customInputSchema,
|
||||
EventTypeMetaDataSchema,
|
||||
userMetadata as userMetadataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { GetBookingType } from "@lib/getBooking";
|
||||
import getBooking from "@lib/getBooking";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -70,11 +70,26 @@ export default function Book(props: BookPageProps) {
|
||||
|
||||
Book.isThemeSupported = true;
|
||||
|
||||
const querySchema = z.object({
|
||||
bookingUid: z.string().optional(),
|
||||
count: z.coerce.number().optional(),
|
||||
embed: z.string().optional(),
|
||||
rescheduleUid: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
/** This is the event "type" ID */
|
||||
type: z.coerce.number().optional(),
|
||||
user: z.string(),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
duration: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
|
||||
const eventTypeSlug = context.query.slug as string;
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
const query = querySchema.parse(context.query);
|
||||
const usernameList = getUsernameList(query.user);
|
||||
const eventTypeSlug = query.slug;
|
||||
const recurringEventCountQuery = query.count;
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
@@ -99,24 +114,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
if (!users.length) return { notFound: true };
|
||||
const [user] = users;
|
||||
const isDynamicGroupBooking = users.length > 1;
|
||||
const isDynamicGroupBooking = users.length > 1 && !!eventTypeSlug;
|
||||
|
||||
// Dynamic Group link doesn't need a type but it must have a slug
|
||||
if ((!isDynamicGroupBooking && !context.query.type) || (isDynamicGroupBooking && !eventTypeSlug)) {
|
||||
if ((!isDynamicGroupBooking && !query.type) || (users.length > 1 && !eventTypeSlug)) {
|
||||
return { notFound: true };
|
||||
}
|
||||
|
||||
const eventTypeRaw =
|
||||
usernameList.length > 1
|
||||
? getDefaultEvent(eventTypeSlug)
|
||||
: await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: parseInt(asStringOrThrow(context.query.type)),
|
||||
},
|
||||
select: {
|
||||
...bookEventTypeSelect,
|
||||
},
|
||||
});
|
||||
const eventTypeRaw = isDynamicGroupBooking
|
||||
? getDefaultEvent(eventTypeSlug)
|
||||
: await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: query.type,
|
||||
},
|
||||
select: {
|
||||
...bookEventTypeSelect,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
const eventType = {
|
||||
@@ -178,22 +192,68 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
})[0];
|
||||
|
||||
// If rescheduleUid and event has seats lets convert Uid to bookingUid
|
||||
let rescheduleUid = query.rescheduleUid;
|
||||
const rescheduleEventTypeHasSeats = query.rescheduleUid && eventTypeRaw.seatsPerTimeSlot;
|
||||
let attendeeEmail: string;
|
||||
let bookingUidWithSeats: string | null = null;
|
||||
|
||||
if (rescheduleEventTypeHasSeats) {
|
||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
||||
where: {
|
||||
referenceUid: query.rescheduleUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attendee: true,
|
||||
booking: {
|
||||
select: {
|
||||
uid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
attendeeEmail = bookingSeat.attendee.email;
|
||||
}
|
||||
} else if (eventTypeRaw.seatsPerTimeSlot && query.duration && query.date) {
|
||||
// If it's not reschedule but event Type has seats we should obtain
|
||||
// the bookingUid regardless and use it to get the booking
|
||||
const currentSeats = await prisma.booking.findFirst({
|
||||
where: {
|
||||
eventTypeId: eventTypeRaw.id,
|
||||
startTime: dayjs(query.date).toISOString(),
|
||||
endTime: dayjs(query.date).add(query.duration, "minutes").toISOString(),
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
},
|
||||
});
|
||||
if (currentSeats && currentSeats) {
|
||||
bookingUidWithSeats = currentSeats.uid;
|
||||
}
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (context.query.rescheduleUid || context.query.bookingUid) {
|
||||
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
|
||||
booking = await getBooking(
|
||||
prisma,
|
||||
context.query.rescheduleUid
|
||||
? (context.query.rescheduleUid as string)
|
||||
: (context.query.bookingUid as string),
|
||||
rescheduleUid || query.bookingUid || bookingUidWithSeats || "",
|
||||
eventTypeObject.bookingFields
|
||||
);
|
||||
}
|
||||
|
||||
const dynamicNames = isDynamicGroupBooking
|
||||
? users.map((user) => {
|
||||
return user.name || "";
|
||||
})
|
||||
: [];
|
||||
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
|
||||
const currentAttendee = booking?.attendees.find((attendee) => {
|
||||
return attendee.email === attendeeEmail;
|
||||
});
|
||||
if (currentAttendee) {
|
||||
booking.attendees = [currentAttendee] || [];
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicNames = isDynamicGroupBooking ? users.map((user) => user.name || "") : [];
|
||||
|
||||
const profile = isDynamicGroupBooking
|
||||
? {
|
||||
@@ -203,9 +263,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
theme: null,
|
||||
brandColor: "",
|
||||
darkBrandColor: "",
|
||||
allowDynamicBooking: !users.some((user) => {
|
||||
return !user.allowDynamicBooking;
|
||||
}),
|
||||
allowDynamicBooking: !users.some((user) => !user.allowDynamicBooking),
|
||||
eventName: getDynamicEventName(dynamicNames, eventTypeSlug),
|
||||
}
|
||||
: {
|
||||
@@ -222,8 +280,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const recurringEventCount =
|
||||
(eventType.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
|
||||
? parseInt(recurringEventCountQuery)
|
||||
(recurringEventCountQuery <= eventType.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
@@ -238,7 +296,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
isDynamicGroupBooking,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
isEmbed: typeof context.query.embed === "string",
|
||||
isEmbed: !!query.embed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,659 +1,5 @@
|
||||
import type { UserPermissionRole } from "@prisma/client";
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import { readFileSync } from "fs";
|
||||
import Handlebars from "handlebars";
|
||||
import { SignJWT } from "jose";
|
||||
import type { Session } from "next-auth";
|
||||
import NextAuth from "next-auth";
|
||||
import { encode } from "next-auth/jwt";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import type { TransportOptions } from "nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
import { authenticator } from "otplib";
|
||||
import path from "path";
|
||||
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { hostedCal, isSAMLLoginEnabled, clientSecretVerifier } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { ErrorCode, isPasswordValid, verifyPassword } from "@calcom/lib/auth";
|
||||
import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import rateLimit from "@calcom/lib/rateLimit";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { AUTH_OPTIONS } from "@calcom/features/auth/lib/next-auth-options";
|
||||
|
||||
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
|
||||
|
||||
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||
|
||||
const transporter = nodemailer.createTransport<TransportOptions>({
|
||||
...(serverConfig.transport as TransportOptions),
|
||||
} as TransportOptions);
|
||||
|
||||
const usernameSlug = (username: string) => slugify(username) + "-" + randomString(6).toLowerCase();
|
||||
|
||||
const signJwt = async (payload: { email: string }) => {
|
||||
const secret = new TextEncoder().encode(process.env.CALENDSO_ENCRYPTION_KEY);
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(payload.email)
|
||||
.setIssuedAt()
|
||||
.setIssuer(WEBSITE_URL)
|
||||
.setAudience(`${WEBSITE_URL}/auth/login`)
|
||||
.setExpirationTime("2m")
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
const loginWithTotp = async (user: { email: string }) =>
|
||||
`/auth/login?totp=${await signJwt({ email: user.email })}`;
|
||||
|
||||
const providers: Provider[] = [
|
||||
CredentialsProvider({
|
||||
id: "credentials",
|
||||
name: "Cal.com",
|
||||
type: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
|
||||
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
|
||||
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials) {
|
||||
console.error(`For some reason credentials are missing`);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: credentials.email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
metadata: true,
|
||||
identityProvider: true,
|
||||
password: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
teams: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Don't leak information about it being username or password that is invalid
|
||||
if (!user) {
|
||||
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||
}
|
||||
|
||||
if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
|
||||
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
|
||||
}
|
||||
|
||||
if (!user.password && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
|
||||
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||
}
|
||||
|
||||
if (user.password) {
|
||||
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||
}
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
if (!credentials.totpCode) {
|
||||
throw new Error(ErrorCode.SecondFactorRequired);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
|
||||
if (secret.length !== 32) {
|
||||
console.error(
|
||||
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
|
||||
);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
const isValidToken = authenticator.check(credentials.totpCode, secret);
|
||||
if (!isValidToken) {
|
||||
throw new Error(ErrorCode.IncorrectTwoFactorCode);
|
||||
}
|
||||
}
|
||||
|
||||
const limiter = rateLimit({
|
||||
intervalInMs: 60 * 1000, // 1 minute
|
||||
});
|
||||
await limiter.check(10, user.email); // 10 requests per minute
|
||||
// Check if the user you are logging into has any active teams
|
||||
const hasActiveTeams =
|
||||
user.teams.filter((m: { team: { metadata: unknown } }) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) return true;
|
||||
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
|
||||
if (metadata.success && metadata.data?.subscriptionId) return true;
|
||||
return false;
|
||||
}).length > 0;
|
||||
|
||||
// authentication success- but does it meet the minimum password requirements?
|
||||
if (user.role === "ADMIN" && !isPasswordValid(credentials.password, false, true)) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: "INACTIVE_ADMIN",
|
||||
belongsToActiveTeam: hasActiveTeams,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
belongsToActiveTeam: hasActiveTeams,
|
||||
};
|
||||
},
|
||||
}),
|
||||
ImpersonationProvider,
|
||||
];
|
||||
|
||||
if (IS_GOOGLE_LOGIN_ENABLED) {
|
||||
providers.push(
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (isSAMLLoginEnabled) {
|
||||
providers.push({
|
||||
id: "saml",
|
||||
name: "BoxyHQ",
|
||||
type: "oauth",
|
||||
version: "2.0",
|
||||
checks: ["pkce", "state"],
|
||||
authorization: {
|
||||
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
|
||||
params: {
|
||||
scope: "",
|
||||
response_type: "code",
|
||||
provider: "saml",
|
||||
},
|
||||
},
|
||||
token: {
|
||||
url: `${WEBAPP_URL}/api/auth/saml/token`,
|
||||
params: { grant_type: "authorization_code" },
|
||||
},
|
||||
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id || "",
|
||||
firstName: profile.firstName || "",
|
||||
lastName: profile.lastName || "",
|
||||
email: profile.email || "",
|
||||
name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
|
||||
email_verified: true,
|
||||
};
|
||||
},
|
||||
options: {
|
||||
clientId: "dummy",
|
||||
clientSecret: clientSecretVerifier,
|
||||
},
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
});
|
||||
|
||||
// Idp initiated login
|
||||
providers.push(
|
||||
CredentialsProvider({
|
||||
id: "saml-idp",
|
||||
name: "IdP Login",
|
||||
credentials: {
|
||||
code: {},
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { code } = credentials;
|
||||
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { oauthController } = await jackson();
|
||||
|
||||
// Fetch access token
|
||||
const { access_token } = await oauthController.token({
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: `${process.env.NEXTAUTH_URL}`,
|
||||
client_id: "dummy",
|
||||
client_secret: clientSecretVerifier,
|
||||
});
|
||||
|
||||
if (!access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch user info
|
||||
const userInfo = await oauthController.userInfo(access_token);
|
||||
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, firstName, lastName, email } = userInfo;
|
||||
|
||||
return {
|
||||
id: id as unknown as number,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
name: `${firstName} ${lastName}`.trim(),
|
||||
email_verified: true,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (true) {
|
||||
const emailsDir = path.resolve(process.cwd(), "..", "..", "packages/emails", "templates");
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
type: "email",
|
||||
maxAge: 10 * 60 * 60, // Magic links are valid for 10 min only
|
||||
// Here we setup the sendVerificationRequest that calls the email template with the identifier (email) and token to verify.
|
||||
sendVerificationRequest: ({ identifier, url }) => {
|
||||
const originalUrl = new URL(url);
|
||||
const webappUrl = new URL(WEBAPP_URL);
|
||||
if (originalUrl.origin !== webappUrl.origin) {
|
||||
url = url.replace(originalUrl.origin, webappUrl.origin);
|
||||
}
|
||||
const emailFile = readFileSync(path.join(emailsDir, "confirm-email.html"), {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const emailTemplate = Handlebars.compile(emailFile);
|
||||
transporter.sendMail({
|
||||
from: `${process.env.EMAIL_FROM}` || APP_NAME,
|
||||
to: identifier,
|
||||
subject: "Your sign-in link for " + APP_NAME,
|
||||
html: emailTemplate({
|
||||
base_url: WEBAPP_URL,
|
||||
signin_url: url,
|
||||
email: identifier,
|
||||
}),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isNumber(n: string) {
|
||||
return !isNaN(parseFloat(n)) && !isNaN(+n);
|
||||
}
|
||||
|
||||
const calcomAdapter = CalComAdapter(prisma);
|
||||
|
||||
export default NextAuth({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
adapter: calcomAdapter,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
jwt: {
|
||||
// decorate the native JWT encode function
|
||||
// Impl. detail: We don't pass through as this function is called with encode/decode functions.
|
||||
encode: async ({ token, maxAge, secret }) => {
|
||||
if (token?.sub && isNumber(token.sub)) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: Number(token.sub) },
|
||||
select: { metadata: true },
|
||||
});
|
||||
// if no user is found, we still don't want to crash here.
|
||||
if (user) {
|
||||
const metadata = userMetadata.parse(user.metadata);
|
||||
if (metadata?.sessionTimeout) {
|
||||
maxAge = metadata.sessionTimeout * 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
return encode({ secret, token, maxAge });
|
||||
},
|
||||
},
|
||||
cookies: defaultCookies(WEBAPP_URL?.startsWith("https://")),
|
||||
pages: {
|
||||
signIn: "/auth/login",
|
||||
signOut: "/auth/logout",
|
||||
error: "/auth/error", // Error code passed in query string as ?error=
|
||||
verifyRequest: "/auth/verify",
|
||||
// newUser: "/auth/new", // New users will be directed here on first sign in (leave the property out if not of interest)
|
||||
},
|
||||
providers,
|
||||
callbacks: {
|
||||
async jwt({ token, user, account }) {
|
||||
const autoMergeIdentities = async () => {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
where: { email: token.email! },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return {
|
||||
...existingUser,
|
||||
...token,
|
||||
};
|
||||
};
|
||||
if (!user) {
|
||||
return await autoMergeIdentities();
|
||||
}
|
||||
if (!account) {
|
||||
return token;
|
||||
}
|
||||
if (account.type === "credentials") {
|
||||
// return token if credentials,saml-idp
|
||||
if (account.provider === "saml-idp") {
|
||||
return token;
|
||||
}
|
||||
// any other credentials, add user info
|
||||
return {
|
||||
...token,
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
impersonatedByUID: user?.impersonatedByUID,
|
||||
belongsToActiveTeam: user?.belongsToActiveTeam,
|
||||
};
|
||||
}
|
||||
|
||||
// The arguments above are from the provider so we need to look up the
|
||||
// user based on those values in order to construct a JWT.
|
||||
if (account.type === "oauth") {
|
||||
if (!account.provider || !account.providerAccountId) {
|
||||
return token;
|
||||
}
|
||||
const idP = account.provider === "saml" ? IdentityProvider.SAML : IdentityProvider.GOOGLE;
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
identityProvider: idP,
|
||||
},
|
||||
{
|
||||
identityProviderId: account.providerAccountId as string,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return await autoMergeIdentities();
|
||||
}
|
||||
|
||||
return {
|
||||
...token,
|
||||
id: existingUser.id,
|
||||
name: existingUser.name,
|
||||
username: existingUser.username,
|
||||
email: existingUser.email,
|
||||
role: existingUser.role,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
const calendsoSession: Session = {
|
||||
...session,
|
||||
hasValidLicense,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.id as number,
|
||||
name: token.name,
|
||||
username: token.username as string,
|
||||
role: token.role as UserPermissionRole,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
},
|
||||
};
|
||||
return calendsoSession;
|
||||
},
|
||||
async signIn(params) {
|
||||
const { user, account, profile } = params;
|
||||
|
||||
if (account?.provider === "email") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In this case we've already verified the credentials in the authorize
|
||||
// callback so we can sign the user in.
|
||||
// Only if provider is not saml-idp
|
||||
if (account?.provider !== "saml-idp") {
|
||||
if (account?.type === "credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (account?.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (account?.provider) {
|
||||
let idP: IdentityProvider = IdentityProvider.GOOGLE;
|
||||
if (account.provider === "saml" || account.provider === "saml-idp") {
|
||||
idP = IdentityProvider.SAML;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error TODO validate email_verified key on profile
|
||||
user.email_verified = user.email_verified || !!user.emailVerified || profile.email_verified;
|
||||
|
||||
if (!user.email_verified) {
|
||||
return "/auth/error?error=unverified-email";
|
||||
}
|
||||
|
||||
// Only google oauth on this path
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
include: {
|
||||
accounts: {
|
||||
where: {
|
||||
provider: idP,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
identityProvider: idP as IdentityProvider,
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// In this case there's an existing user and their email address
|
||||
// hasn't changed since they last logged in.
|
||||
if (existingUser.email === user.email) {
|
||||
try {
|
||||
// If old user without Account entry we link their google account
|
||||
if (existingUser.accounts.length === 0) {
|
||||
const linkAccountWithUserData = { ...account, userId: existingUser.id };
|
||||
await calcomAdapter.linkAccount(linkAccountWithUserData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error("Error while linking account of already existing user");
|
||||
}
|
||||
}
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUser);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the email address doesn't match, check if an account already exists
|
||||
// with the new email address. If it does, for now we return an error. If
|
||||
// not, update the email of their account and log them in.
|
||||
const userWithNewEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
if (!userWithNewEmail) {
|
||||
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUser);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return "/auth/error?error=new-email-conflict";
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no existing user for this identity provider and id, create
|
||||
// a new account. If an account already exists with the incoming email
|
||||
// address return an error for now.
|
||||
const existingUserWithEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
// if self-hosted then we can allow auto-merge of identity providers if email is verified
|
||||
if (!hostedCal && existingUserWithEmail.emailVerified) {
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if user was invited
|
||||
if (
|
||||
!existingUserWithEmail.password &&
|
||||
!existingUserWithEmail.emailVerified &&
|
||||
!existingUserWithEmail.username
|
||||
) {
|
||||
await prisma.user.update({
|
||||
where: { email: user.email },
|
||||
data: {
|
||||
// Slugify the incoming name and append a few random characters to
|
||||
// prevent conflicts for users with the same name.
|
||||
username: usernameSlug(user.name),
|
||||
emailVerified: new Date(Date.now()),
|
||||
name: user.name,
|
||||
identityProvider: idP,
|
||||
identityProviderId: String(user.id),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// User signs up with email/password and then tries to login with Google/SAML using the same email
|
||||
if (
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
|
||||
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
|
||||
) {
|
||||
await prisma.user.update({
|
||||
where: { email: existingUserWithEmail.email },
|
||||
data: { password: null },
|
||||
});
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
|
||||
return "/auth/error?error=use-password-login";
|
||||
}
|
||||
|
||||
return "/auth/error?error=use-identity-login";
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
// Slugify the incoming name and append a few random characters to
|
||||
// prevent conflicts for users with the same name.
|
||||
username: usernameSlug(user.name),
|
||||
emailVerified: new Date(Date.now()),
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
identityProvider: idP,
|
||||
identityProviderId: String(user.id),
|
||||
},
|
||||
});
|
||||
|
||||
const linkAccountNewUserData = { ...account, userId: newUser.id };
|
||||
await calcomAdapter.linkAccount(linkAccountNewUserData);
|
||||
|
||||
if (account.twoFactorEnabled) {
|
||||
return loginWithTotp(newUser);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
async redirect({ url, baseUrl }) {
|
||||
// Allows relative callback URLs
|
||||
if (url.startsWith("/")) return `${baseUrl}${url}`;
|
||||
// Allows callback URLs on the same domain
|
||||
else if (new URL(url).hostname === new URL(WEBAPP_URL).hostname) return url;
|
||||
return baseUrl;
|
||||
},
|
||||
},
|
||||
});
|
||||
export default NextAuth(AUTH_OPTIONS);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import { ErrorCode, hashPassword, verifyPassword } from "../../../lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (!session || !session.user || !session.user.email) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { hashPassword } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(400).json({ message: "" });
|
||||
|
||||
@@ -2,8 +2,8 @@ import { IdentityProvider } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { isPasswordValid } from "@calcom/lib/auth";
|
||||
import { hashPassword } from "@calcom/lib/auth";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { hashPassword } from "@calcom/lib/auth";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const session = await getSession({ req });
|
||||
const session = await getServerSession({ req, res });
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { ErrorCode, getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const session = await getSession({ req });
|
||||
const session = await getServerSession({ req, res });
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
@@ -3,17 +3,18 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authenticator } from "otplib";
|
||||
import qrcode from "qrcode";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import { symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const session = await getSession({ req });
|
||||
const session = await getServerSession({ req, res });
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const session = await getSession({ req });
|
||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||
const session = await getServerSession({ req, res });
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
const booking = await handleNewBooking(req, {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { defaultResponder, defaultHandler } from "@calcom/lib/server";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const session = await getSession({ req });
|
||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||
const session = await getServerSession({ req, res });
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
return await handleCancelBooking(req);
|
||||
|
||||
@@ -60,7 +60,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
|
||||
res.write(
|
||||
renderEmail("OrganizerScheduledEmail", {
|
||||
renderEmail("OrganizerRequestEmail", {
|
||||
calEvent: evt,
|
||||
attendee: evt.organizer,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -42,7 +42,7 @@ const defaultIntegrationAddHandler = async ({
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Check that user is authenticated
|
||||
req.session = await getSession({ req });
|
||||
req.session = await getServerSession({ req, res });
|
||||
|
||||
const { args } = req.query;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
|
||||
let isCold = true;
|
||||
@@ -9,7 +9,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const prePrismaDate = performance.now();
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const preSessionDate = performance.now();
|
||||
const session = await getSession({ req });
|
||||
const session = await getServerSession({ req, res });
|
||||
if (!session) return res.status(409).json({ message: "Unauthorized" });
|
||||
const preUserDate = performance.now();
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import * as trpcNext from "@calcom/trpc/server/adapters/next";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { createContext as createTrpcContext } from "@calcom/trpc/server/createContext";
|
||||
import { appRouter } from "@calcom/trpc/server/routers/_app";
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
@@ -12,7 +13,11 @@ export default trpcNext.createNextApiHandler({
|
||||
/**
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
createContext,
|
||||
createContext: ({ req, res }) => {
|
||||
const sessionGetter = () => getServerSession({ req, res });
|
||||
|
||||
return createTrpcContext({ req, res }, sessionGetter);
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/error-handling
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import RoutingFormsRoutingConfig from "@calcom/app-store/ee/routing-forms/pages/app-routing.config";
|
||||
import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config";
|
||||
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function getServerSideProps(
|
||||
appPages?: string[];
|
||||
}>
|
||||
) {
|
||||
const { params } = context;
|
||||
const { params, req, res } = context;
|
||||
if (!params) {
|
||||
return {
|
||||
notFound: true,
|
||||
@@ -120,7 +120,7 @@ export async function getServerSideProps(
|
||||
// appPages is actually hardcoded here and no matter the fileName the same variable would be used.
|
||||
// We can write some validation logic later on that ensures that [...appPages].tsx file exists
|
||||
params.appPages = pages.slice(1);
|
||||
const session = await getSession({ req: context.req });
|
||||
const session = await getServerSession({ req, res });
|
||||
const user = session?.user;
|
||||
|
||||
const result = await route.getServerSideProps(
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { ChangeEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/client";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
@@ -86,9 +86,11 @@ export default function Apps({ categories, appStore }: inferSSRProps<typeof getS
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, res } = context;
|
||||
|
||||
const ssg = await ssgInit(context);
|
||||
|
||||
const session = await getSession(context);
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
let appStore;
|
||||
if (session?.user?.id) {
|
||||
|
||||
@@ -22,7 +22,7 @@ type Props = {
|
||||
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
const { t } = useLocale();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<{ message: string } | null>(null);
|
||||
const [, setError] = React.useState<{ message: string } | null>(null);
|
||||
const [success, setSuccess] = React.useState(false);
|
||||
|
||||
const [password, setPassword] = React.useState("");
|
||||
|
||||
@@ -7,11 +7,10 @@ import { useRouter } from "next/router";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, EmailField } from "@calcom/ui";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
|
||||
export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
@@ -133,18 +132,21 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
ForgotPassword.getInitialProps = async (context: GetServerSidePropsContext) => {
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, res } = context;
|
||||
const session = await getSession({ req });
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (session) {
|
||||
res.writeHead(302, { Location: "/" });
|
||||
res.end();
|
||||
return;
|
||||
return { props: {} };
|
||||
}
|
||||
|
||||
return {
|
||||
csrfToken: await getCsrfToken(context),
|
||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
||||
props: {
|
||||
csrfToken: await getCsrfToken(context),
|
||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,8 +9,9 @@ import { FormProvider, useForm } from "react-hook-form";
|
||||
import { FaGoogle } from "react-icons/fa";
|
||||
|
||||
import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { ErrorCode, getSession } from "@calcom/lib/auth";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -220,8 +221,9 @@ export default function Login({
|
||||
|
||||
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
|
||||
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { req } = context;
|
||||
const session = await getSession({ req });
|
||||
const { req, res } = context;
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const verifyJwt = (jwt: string) => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
@@ -136,10 +136,12 @@ export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, res } = context;
|
||||
|
||||
const ssr = await ssrInit(context);
|
||||
const userCount = await prisma.user.count();
|
||||
const { req } = context;
|
||||
const session = await getSession({ req });
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (session?.user.role && session?.user.role !== UserPermissionRole.ADMIN) {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getProviders, signIn, getSession, getCsrfToken } from "next-auth/react";
|
||||
import { getProviders, signIn, getCsrfToken } from "next-auth/react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
type Provider = {
|
||||
@@ -25,7 +26,9 @@ function signin({ providers }: { providers: Provider[] }) {
|
||||
export default signin;
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const session = await getSession(context);
|
||||
const { req, res } = context;
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
const csrfToken = await getCsrfToken(context);
|
||||
const providers = await getProviders();
|
||||
if (session) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import {
|
||||
hostedCal,
|
||||
@@ -16,7 +17,6 @@ import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getSession } from "@lib/auth";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
@@ -60,9 +60,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
throw new Error(`File is not named sso/[provider]`);
|
||||
}
|
||||
|
||||
const { req } = context;
|
||||
const { req, res } = context;
|
||||
|
||||
const session = await getSession({ req });
|
||||
const session = await getServerSession({ req, res });
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
if (session) {
|
||||
|
||||
@@ -39,6 +39,7 @@ import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
@@ -46,7 +47,7 @@ import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Badge, Button, EmailInput, HeadSeo } from "@calcom/ui";
|
||||
import { Button, EmailInput, HeadSeo, Badge } from "@calcom/ui";
|
||||
import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
@@ -182,6 +183,7 @@ const querySchema = z.object({
|
||||
isSuccessBookingPage: z.string().optional(),
|
||||
formerTime: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function Success(props: SuccessProps) {
|
||||
@@ -194,11 +196,9 @@ export default function Success(props: SuccessProps) {
|
||||
changes,
|
||||
formerTime,
|
||||
email,
|
||||
seatReferenceUid,
|
||||
} = querySchema.parse(router.query);
|
||||
|
||||
if ((isCancellationMode || changes) && typeof window !== "undefined") {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
const tz =
|
||||
(isSuccessBookingPage
|
||||
? props.bookingInfo.attendees.find((attendee) => attendee.email === email)?.timeZone
|
||||
@@ -210,10 +210,6 @@ export default function Success(props: SuccessProps) {
|
||||
props?.bookingInfo?.metadata || {}
|
||||
)?.videoCallUrl;
|
||||
|
||||
if (!location) {
|
||||
// Can't use logger.error because it throws error on client. stdout isn't available to it.
|
||||
console.error(`No location found `);
|
||||
}
|
||||
const status = props.bookingInfo?.status;
|
||||
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
|
||||
const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason;
|
||||
@@ -236,12 +232,24 @@ export default function Success(props: SuccessProps) {
|
||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||
|
||||
function setIsCancellationMode(value: boolean) {
|
||||
if (value) router.query.cancel = "true";
|
||||
else delete router.query.cancel;
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query },
|
||||
});
|
||||
const query_ = { ...router.query };
|
||||
|
||||
if (value) {
|
||||
query_.cancel = "true";
|
||||
} else {
|
||||
if (query_.cancel) {
|
||||
delete query_.cancel;
|
||||
}
|
||||
}
|
||||
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: { ...query_ },
|
||||
},
|
||||
undefined,
|
||||
{ scroll: false }
|
||||
);
|
||||
}
|
||||
|
||||
const eventNameObject = {
|
||||
@@ -259,7 +267,16 @@ export default function Success(props: SuccessProps) {
|
||||
|
||||
const eventName = getEventName(eventNameObject, true);
|
||||
const needsConfirmation = eventType.requiresConfirmation && reschedule != true;
|
||||
const isCancelled = status === "CANCELLED" || status === "REJECTED";
|
||||
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
|
||||
|
||||
const isCancelled =
|
||||
status === "CANCELLED" ||
|
||||
status === "REJECTED" ||
|
||||
(isCancellationMode &&
|
||||
(!!seatReferenceUid
|
||||
? !bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid)
|
||||
: !userIsOwner));
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
@@ -343,7 +360,6 @@ export default function Success(props: SuccessProps) {
|
||||
return t("emailed_you_and_attendees" + titleSuffix);
|
||||
}
|
||||
|
||||
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
|
||||
useTheme(isSuccessBookingPage ? props.profile.theme : "light");
|
||||
const title = t(
|
||||
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
|
||||
@@ -437,7 +453,9 @@ export default function Success(props: SuccessProps) {
|
||||
? t("submitted_recurring")
|
||||
: t("submitted")
|
||||
: isCancelled
|
||||
? t("event_cancelled")
|
||||
? seatReferenceUid
|
||||
? t("no_longer_attending")
|
||||
: t("event_cancelled")
|
||||
: props.recurringBookings
|
||||
? t("meeting_is_scheduled_recurring")
|
||||
: t("meeting_is_scheduled")}
|
||||
@@ -501,25 +519,21 @@ export default function Success(props: SuccessProps) {
|
||||
<>
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 last:mb-0">
|
||||
<>
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<p>
|
||||
<span className="mr-2">{bookingInfo.user.name}</span>
|
||||
<Badge variant="blue" bold>
|
||||
{t("Host")}
|
||||
</Badge>
|
||||
</p>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<span className="mr-2">{bookingInfo.user.name}</span>
|
||||
<Badge variant="blue">{t("Host")}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee) => (
|
||||
<div key={attendee.name} className="mb-3 last:mb-0">
|
||||
{attendee.name && <p>{attendee.name}</p>}
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee) => (
|
||||
<div key={attendee.name} className="mb-3 last:mb-0">
|
||||
{attendee.name && <p>{attendee.name}</p>}
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -593,7 +607,9 @@ export default function Success(props: SuccessProps) {
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-bookinglight inline text-gray-700">
|
||||
<span className="underline">
|
||||
<Link href={`/reschedule/${bookingInfo?.uid}`} legacyBehavior>
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
</span>
|
||||
@@ -623,6 +639,7 @@ export default function Success(props: SuccessProps) {
|
||||
setIsCancellationMode={setIsCancellationMode}
|
||||
theme={isSuccessBookingPage ? props.profile.theme : "light"}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
seatReferenceUid={seatReferenceUid}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
@@ -630,7 +647,7 @@ export default function Success(props: SuccessProps) {
|
||||
!needsConfirmation &&
|
||||
!isCancellationMode &&
|
||||
!isCancelled &&
|
||||
calculatedDuration && (
|
||||
!!calculatedDuration && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
|
||||
@@ -738,6 +755,7 @@ export default function Success(props: SuccessProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{session === null && !(userIsOwner || props.hideBranding) && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
@@ -1005,7 +1023,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const bookingInfoRaw = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
uid: await maybeGetBookingUidFromSeat(prisma, uid),
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
@@ -1047,6 +1065,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
seatsReferences: {
|
||||
select: {
|
||||
referenceUid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!bookingInfoRaw) {
|
||||
@@ -1116,19 +1139,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
handleSeatsEventTypeOnBooking(eventType, bookingInfo, email);
|
||||
}
|
||||
|
||||
let recurringBookings = null;
|
||||
if (bookingInfo.recurringEventId) {
|
||||
// We need to get the dates for the bookings to be able to show them in the UI
|
||||
recurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: bookingInfo.recurringEventId,
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
bookingId: bookingInfo.id,
|
||||
@@ -1138,21 +1148,30 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
refunded: true,
|
||||
},
|
||||
});
|
||||
const paymentStatus = {
|
||||
success: payment?.success,
|
||||
refunded: payment?.refunded,
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
|
||||
profile,
|
||||
eventType,
|
||||
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
|
||||
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
||||
trpcState: ssr.dehydrate(),
|
||||
dynamicEventName: bookingInfo?.eventType?.eventName || "",
|
||||
bookingInfo,
|
||||
paymentStatus: payment ? paymentStatus : null,
|
||||
paymentStatus: payment,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getRecurringBookings(recurringEventId: string | null) {
|
||||
if (!recurringEventId) return null;
|
||||
const recurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
},
|
||||
});
|
||||
return recurringBookings.map((obj) => obj.startTime.toString());
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { z } from "zod";
|
||||
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import type { EventLocationType } from "@calcom/core/location";
|
||||
import { validateBookingLimitOrder } from "@calcom/lib";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import getEventTypeById from "@calcom/lib/getEventTypeById";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -22,11 +23,10 @@ import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { BookingLimit, RecurringEvent } from "@calcom/types/Calendar";
|
||||
import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Form, showToast } from "@calcom/ui";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { getSession } from "@lib/auth";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { AvailabilityTab } from "@components/eventtype/AvailabilityTab";
|
||||
@@ -86,7 +86,8 @@ export type FormValues = {
|
||||
externalId: string;
|
||||
};
|
||||
successRedirectUrl: string;
|
||||
bookingLimits?: BookingLimit;
|
||||
durationLimits?: IntervalLimit;
|
||||
bookingLimits?: IntervalLimit;
|
||||
hosts: { userId: number; isFixed: boolean }[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
};
|
||||
@@ -194,6 +195,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
description: eventType.description ?? undefined,
|
||||
schedule: eventType.schedule || undefined,
|
||||
bookingLimits: eventType.bookingLimits || undefined,
|
||||
durationLimits: eventType.durationLimits || undefined,
|
||||
length: eventType.length,
|
||||
hidden: eventType.hidden,
|
||||
periodDates: {
|
||||
@@ -303,6 +305,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
recurringEvent,
|
||||
locations,
|
||||
metadata,
|
||||
@@ -316,10 +319,15 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
} = values;
|
||||
|
||||
if (bookingLimits) {
|
||||
const isValid = validateBookingLimitOrder(bookingLimits);
|
||||
const isValid = validateIntervalLimitOrder(bookingLimits);
|
||||
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
|
||||
}
|
||||
|
||||
if (durationLimits) {
|
||||
const isValid = validateIntervalLimitOrder(durationLimits);
|
||||
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
|
||||
}
|
||||
|
||||
if (metadata?.multipleDuration !== undefined) {
|
||||
if (metadata?.multipleDuration.length < 1) {
|
||||
throw new Error(t("event_setup_multiple_duration_error"));
|
||||
@@ -341,6 +349,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
beforeEventBuffer: beforeBufferTime,
|
||||
afterEventBuffer: afterBufferTime,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
metadata,
|
||||
@@ -362,8 +371,10 @@ const EventTypePageWrapper = (props: inferSSRProps<typeof getServerSideProps>) =
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, query } = context;
|
||||
const session = await getSession({ req });
|
||||
const { req, res, query } = context;
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
const typeParam = parseInt(asStringOrThrow(query.type));
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
@@ -384,9 +395,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
try {
|
||||
const res = await getEventTypeById({ eventTypeId: typeParam, userId: session.user.id, prisma });
|
||||
|
||||
return {
|
||||
props: {
|
||||
session,
|
||||
// session,
|
||||
type: typeParam,
|
||||
trpcState: ssr.dehydrate(),
|
||||
initialData: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -140,8 +140,10 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, res } = context;
|
||||
|
||||
const crypto = await import("crypto");
|
||||
const session = await getSession(context);
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { NextPageContext } from "next";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
function RedirectPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: NextPageContext) {
|
||||
const session = await getSession(context);
|
||||
export async function getServerSideProps({ req, res }: GetServerSidePropsContext) {
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { URLSearchParams } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
|
||||
import { asStringOrUndefined } from "@lib/asStringOrNull";
|
||||
|
||||
export default function Type() {
|
||||
// Just redirect to the schedule page to reschedule it.
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { uid: bookingId } = z
|
||||
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
|
||||
.parse(context.query);
|
||||
let seatReferenceUid;
|
||||
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
|
||||
if (uid) {
|
||||
seatReferenceUid = bookingId;
|
||||
}
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: asStringOrUndefined(context.query.uid),
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
@@ -42,9 +51,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!booking) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
notFound: true;
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
|
||||
@@ -66,9 +73,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
: booking.user?.username || "rick") /* This shouldn't happen */ +
|
||||
"/" +
|
||||
eventType?.slug;
|
||||
const destinationUrl = new URLSearchParams();
|
||||
if (seatReferenceUid) {
|
||||
destinationUrl.set("rescheduleUid", seatReferenceUid);
|
||||
} else {
|
||||
destinationUrl.set("rescheduleUid", bookingId);
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/" + eventPage + "?rescheduleUid=" + context.query.uid,
|
||||
destination: `/${eventPage}?${destinationUrl.toString()}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { BaseSyntheticEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
|
||||
@@ -2,8 +2,8 @@ import { IdentityProvider } from "@prisma/client";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { identityProviderNameMap } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
@@ -7,13 +7,14 @@ import { useEffect } from "react";
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { Avatar, Button, HeadSeo, AvatarGroup } from "@calcom/ui";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Avatar, AvatarGroup, Button, EmptyScreen, HeadSeo } from "@calcom/ui";
|
||||
import { FiArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
@@ -24,13 +25,15 @@ import Team from "@components/team/screens/Team";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
function TeamPage({ team }: TeamPageProps) {
|
||||
function TeamPage({ team, isUnpublished }: TeamPageProps) {
|
||||
useTheme(team.theme);
|
||||
const showMembers = useToggleQuery("members");
|
||||
const { t } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
const telemetry = useTelemetry();
|
||||
const router = useRouter();
|
||||
const teamName = team.name || "Nameless Team";
|
||||
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.event(
|
||||
@@ -39,6 +42,18 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
);
|
||||
}, [telemetry, router.asPath]);
|
||||
|
||||
if (isUnpublished) {
|
||||
return (
|
||||
<div className="m-8 flex items-center justify-center">
|
||||
<EmptyScreen
|
||||
avatar={<Avatar alt={teamName} imageSrc={getPlaceholderAvatar(team.logo, team.name)} size="lg" />}
|
||||
headline={t("team_is_unpublished", { team: teamName })}
|
||||
description={t("team_is_unpublished_description")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EventTypes = () => (
|
||||
<ul className="dark:border-darkgray-300 rounded-md border border-gray-200">
|
||||
{team.eventTypes.map((type, index) => (
|
||||
@@ -78,12 +93,8 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
</ul>
|
||||
);
|
||||
|
||||
const teamName = team.name || "Nameless Team";
|
||||
|
||||
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<HeadSeo
|
||||
title={teamName}
|
||||
description={teamName}
|
||||
@@ -124,7 +135,6 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="mt-8 flex justify-center text-center dark:text-white">
|
||||
<Button
|
||||
color="minimal"
|
||||
@@ -140,7 +150,7 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,6 +158,25 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const ssr = await ssrInit(context);
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
|
||||
const unpublishedTeam = await prisma.team.findFirst({
|
||||
where: {
|
||||
metadata: {
|
||||
path: ["requestedSlug"],
|
||||
equals: slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (unpublishedTeam) {
|
||||
return {
|
||||
props: {
|
||||
isUnpublished: true,
|
||||
team: unpublishedTeam,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
const team = await getTeamWithMembers(undefined, slug);
|
||||
|
||||
if (!team) return { notFound: true } as { notFound: true };
|
||||
@@ -165,7 +194,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
team,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
};
|
||||
|
||||
export default TeamPage;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
@@ -24,6 +25,11 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||
|
||||
TeamBookingPage.isThemeSupported = true;
|
||||
|
||||
const querySchema = z.object({
|
||||
rescheduleUid: z.string().optional(),
|
||||
bookingUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||
@@ -119,8 +125,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
})[0];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (context.query.rescheduleUid) {
|
||||
booking = await getBooking(prisma, context.query.rescheduleUid as string, eventTypeObject.bookingFields);
|
||||
const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
|
||||
if (rescheduleUid || bookingUid) {
|
||||
booking = await getBooking(prisma, rescheduleUid || bookingUid || "", eventTypeObject.bookingFields);
|
||||
}
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import DailyIframe from "@daily-co/daily-js";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import { useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { FiChevronRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
export default function JoinCall(props: JoinCallPageProps) {
|
||||
const { t } = useLocale();
|
||||
const { meetingUrl, meetingPassword } = props;
|
||||
const { meetingUrl, meetingPassword, booking } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const callFrame = DailyIframe.createFrame({
|
||||
@@ -53,7 +59,6 @@ export default function JoinCall(props: JoinCallPageProps) {
|
||||
<>
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={APP_NAME + " Video"} />
|
||||
<meta name="description" content={t("quick_video_meeting")} />
|
||||
<meta property="og:image" content={SEO_IMG_OGIMG_VIDEO} />
|
||||
<meta property="og:type" content="website" />
|
||||
@@ -77,11 +82,119 @@ export default function JoinCall(props: JoinCallPageProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<VideoMeetingInfo booking={booking} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProgressBarProps {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
function ProgressBar(props: ProgressBarProps) {
|
||||
const { startTime, endTime } = props;
|
||||
const currentTime = dayjs().second(0).millisecond(0);
|
||||
const startingTime = dayjs(startTime).second(0).millisecond(0);
|
||||
const isPast = currentTime.isAfter(startingTime);
|
||||
const currentDifference = dayjs().diff(startingTime, "minutes");
|
||||
const startDuration = dayjs(endTime).diff(startingTime, "minutes");
|
||||
const [duration] = useState(() => {
|
||||
if (currentDifference >= 0 && isPast) {
|
||||
return startDuration - currentDifference;
|
||||
} else {
|
||||
return startDuration;
|
||||
}
|
||||
});
|
||||
|
||||
const prev = startDuration - duration;
|
||||
const percentage = prev * (100 / startDuration);
|
||||
return (
|
||||
<div>
|
||||
<p>{duration} minutes</p>
|
||||
<div className="relative h-2 max-w-xl overflow-hidden rounded-full">
|
||||
<div className="absolute h-full w-full bg-gray-500/10" />
|
||||
<div className={classNames("relative h-full bg-green-500")} style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoMeetingInfo {
|
||||
booking: JoinCallPageProps["booking"];
|
||||
}
|
||||
|
||||
export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { booking } = props;
|
||||
|
||||
const endTime = new Date(booking.endTime);
|
||||
const startTime = new Date(booking.startTime);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={classNames(
|
||||
"no-scrollbar fixed top-0 left-0 z-30 flex h-full w-64 transform justify-between overflow-x-hidden overflow-y-scroll transition-all duration-300 ease-in-out",
|
||||
open ? "translate-x-0" : "-translate-x-[232px]"
|
||||
)}>
|
||||
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden border-r border-gray-300/20 bg-black/80 p-4 text-white shadow-sm backdrop-blur-lg">
|
||||
<h3>What:</h3>
|
||||
<p>{booking.title}</p>
|
||||
<h3>Invitee Time Zone:</h3>
|
||||
<p>{booking.user?.timeZone}</p>
|
||||
<h3>When:</h3>
|
||||
<p>
|
||||
{formatToLocalizedDate(startTime)} <br />
|
||||
{formatToLocalizedTime(startTime)}
|
||||
</p>
|
||||
<h3>Time left</h3>
|
||||
<ProgressBar
|
||||
key={String(open)}
|
||||
endTime={endTime.toISOString()}
|
||||
startTime={startTime.toISOString()}
|
||||
/>
|
||||
|
||||
<h3>Who:</h3>
|
||||
<p>
|
||||
{booking?.user?.name} - Organizer{" "}
|
||||
<a href={`mailto:${booking?.user?.email}`}>{booking?.user?.email}</a>
|
||||
</p>
|
||||
|
||||
{booking.attendees.length
|
||||
? booking.attendees.map((attendee) => (
|
||||
<p key={attendee.id}>
|
||||
{attendee.name} – <a href={`mailto:${attendee.email}`}>{attendee.email}</a>
|
||||
</p>
|
||||
))
|
||||
: null}
|
||||
|
||||
<h3>Description</h3>
|
||||
|
||||
<div
|
||||
className="prose-sm prose prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: md.render(booking.description ?? "") }}
|
||||
/>
|
||||
</main>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
aria-label={`${open ? "close" : "open"} booking description sidebar`}
|
||||
className="h-20 w-6 rounded-r-md border border-l-0 border-gray-300/20 bg-black/60 text-white shadow-sm backdrop-blur-lg"
|
||||
onClick={() => setOpen(!open)}>
|
||||
<FiChevronRight
|
||||
aria-hidden
|
||||
className={classNames(open && "rotate-180", "w-5 transition-all duration-300 ease-in-out")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { req, res } = context;
|
||||
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
@@ -91,10 +204,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
uid: true,
|
||||
description: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
references: {
|
||||
@@ -139,7 +256,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
startTime: booking.startTime.toString(),
|
||||
endTime: booking.endTime.toString(),
|
||||
});
|
||||
const session = await getSession();
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
// set meetingPassword to null for guests
|
||||
if (session?.user.id !== bookingObj.user?.id) {
|
||||
@@ -154,6 +272,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
...(typeof bookingObj.references[0].meetingPassword === "string" && {
|
||||
meetingPassword: bookingObj.references[0].meetingPassword,
|
||||
}),
|
||||
booking: bookingObj,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,63 +5,35 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Button, HeadSeo } from "@calcom/ui";
|
||||
import { FiArrowRight, FiCalendar, FiX } from "@calcom/ui/components/icon";
|
||||
import { Button, HeadSeo, EmptyScreen } from "@calcom/ui";
|
||||
import { FiArrowRight, FiCalendar, FiClock } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function MeetingNotStarted(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<HeadSeo title="Meeting Unavailable" description="Meeting Unavailable" />
|
||||
<>
|
||||
<HeadSeo title={t("this_meeting_has_not_started_yet")} description={props.booking.title} />
|
||||
<main className="mx-auto my-24 max-w-3xl">
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<FiX className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
|
||||
This meeting has not started yet
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-b py-4">
|
||||
<h2 className="font-cal mb-2 text-center text-lg font-medium text-gray-600">
|
||||
{props.booking.title}
|
||||
</h2>
|
||||
<p className="text-center text-gray-500">
|
||||
<FiCalendar className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
{dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<p className="text-sm text-gray-500">
|
||||
This meeting will be accessible 60 minutes in advance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 text-center sm:mt-6">
|
||||
<div className="mt-5">
|
||||
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
Icon={FiClock}
|
||||
headline={t("this_meeting_has_not_started_yet")}
|
||||
description={
|
||||
<>
|
||||
<h2 className="mb-2 text-center font-medium">{props.booking.title}</h2>
|
||||
<p className="text-center text-gray-500">
|
||||
<FiCalendar className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
{dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
buttonRaw={
|
||||
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, HeadSeo } from "@calcom/ui";
|
||||
import { Button, EmptyScreen, HeadSeo } from "@calcom/ui";
|
||||
import { FiX, FiArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function NoMeetingFound() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<HeadSeo title={t("no_meeting_found")} description={t("no_meeting_found")} />
|
||||
<main className="mx-auto my-24 max-w-3xl">
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<FiX className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
|
||||
{t("no_meeting_found")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-center text-sm text-gray-500">{t("no_meeting_found_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 text-center sm:mt-6">
|
||||
<div className="mt-5">
|
||||
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
|
||||
{t("go_back_home")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
Icon={FiX}
|
||||
headline={t("no_meeting_found")}
|
||||
description={t("no_meeting_found_description")}
|
||||
buttonRaw={
|
||||
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
|
||||
{t("go_back_home")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewSeatedEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
|
||||
const slug = "seats";
|
||||
const user = await users.create({
|
||||
eventTypes: [
|
||||
{ title: "Seated event", slug, seatsPerTimeSlot: 10, requiresConfirmation: true, length: 30 },
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
|
||||
return { user, eventType };
|
||||
}
|
||||
|
||||
async function createUserWithSeatedEventAndAttendees(
|
||||
fixtures: Pick<Fixtures, "users" | "bookings">,
|
||||
attendees: Prisma.AttendeeCreateManyBookingInput[]
|
||||
) {
|
||||
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
|
||||
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
// startTime with 1 day from now and endTime half hour after
|
||||
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: attendees,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { user, eventType, booking };
|
||||
}
|
||||
|
||||
test.describe("Booking with Seats", () => {
|
||||
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
|
||||
const user = await users.create({ name: "Seated event" });
|
||||
await user.login();
|
||||
const eventTitle = "My 2-seated event";
|
||||
await createNewSeatedEventType(page, { eventTitle });
|
||||
await expect(page.locator(`text=${eventTitle} event type updated successfully`)).toBeVisible();
|
||||
});
|
||||
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
|
||||
const slug = "my-2-seated-event";
|
||||
const user = await users.create({
|
||||
name: "Seated event user",
|
||||
eventTypes: [
|
||||
{ title: "My 2-seated event", slug, length: 60, seatsPerTimeSlot: 2, seatsShowAttendees: true },
|
||||
],
|
||||
});
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname.endsWith("/book");
|
||||
},
|
||||
});
|
||||
const bookingUrl = page.url();
|
||||
await test.step("Attendee #1 can book a seated event time slot", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #2 can book the same seated event time slot", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #3 cannot book the same seated event time slot", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
|
||||
});
|
||||
});
|
||||
// TODO: Make E2E test: Attendee #1 should be able to cancel his booking
|
||||
// todo("Attendee #1 should be able to cancel his booking");
|
||||
// TODO: Make E2E test: Attendee #1 should be able to reschedule his booking
|
||||
// todo("Attendee #1 should be able to reschedule his booking");
|
||||
// TODO: Make E2E test: All attendees canceling should delete the booking for the User
|
||||
// todo("All attendees canceling should delete the booking for the User");
|
||||
|
||||
test.describe("Reschedule for booking with seats", () => {
|
||||
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[2].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[2].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// expect input to be filled with attendee number 3 data
|
||||
const thirdAttendeeElement = await page.locator("input[name=name]");
|
||||
const attendeeName = await thirdAttendeeElement.inputValue();
|
||||
expect(attendeeName).toBe("John Third");
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
// Should expect old booking to be accepted with two attendees
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
expect(oldBooking?.attendees.length).toBe(2);
|
||||
expect(oldBooking?.seatsReferences.length).toBe(2);
|
||||
});
|
||||
|
||||
test("Should reschedule booking with seats and if everyone rescheduled it should be deleted", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[0].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForNavigation({ url: /.*booking/ });
|
||||
|
||||
await page.goto(`/reschedule/${references[1].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForNavigation({ url: /.*booking/ });
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: {
|
||||
seatsReferences: true,
|
||||
attendees: true,
|
||||
eventType: {
|
||||
include: { users: true, hosts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(oldBooking?.attendees).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Should cancel with seats and have no attendees and cancelled", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
await user.login();
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
// Now we cancel the booking as the organizer
|
||||
await page.goto(`/booking/${booking.uid}?cancel=true`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
expect(oldBooking?.startTime).not.toBe(updatedBooking?.startTime);
|
||||
});
|
||||
|
||||
test("If rescheduled/cancelled booking with seats it should display the correct number of seats", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/booking/${references[0].referenceUid}?cancel=true&seatReferenceUid=${references[0].referenceUid}`
|
||||
);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
|
||||
await page.goto(`/reschedule/${references[1].referenceUid}`);
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
|
||||
|
||||
// Validate that the number of seats its 10
|
||||
expect(await page.locator("text=9 / 10 Seats available").count()).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
|
||||
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
@@ -29,38 +29,17 @@ test.describe("Event Types tests", () => {
|
||||
});
|
||||
|
||||
test("can add new event type", async ({ page }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `hello ${nonce}`;
|
||||
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
await page.fill("[name=length]", "10");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname !== "/event-types";
|
||||
},
|
||||
});
|
||||
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("enabling recurring event comes with default options", async ({ page }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `my recurring event ${nonce}`;
|
||||
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
await page.fill("[name=length]", "15");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname !== "/event-types";
|
||||
},
|
||||
});
|
||||
await createNewEventType(page, { eventTitle });
|
||||
|
||||
await page.click("[data-testid=vertical-tab-recurring]");
|
||||
await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeHidden();
|
||||
|
||||
@@ -18,7 +18,18 @@ export const createBookingsFixture = (page: Page) => {
|
||||
userId: number,
|
||||
username: string | null,
|
||||
eventTypeId = -1,
|
||||
{ rescheduled = false, paid = false, status = "ACCEPTED" }: Partial<Prisma.BookingCreateInput> = {},
|
||||
{
|
||||
rescheduled = false,
|
||||
paid = false,
|
||||
status = "ACCEPTED",
|
||||
attendees = {
|
||||
create: {
|
||||
email: "attendee@example.com",
|
||||
name: "Attendee Example",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
},
|
||||
}: Partial<Prisma.BookingCreateInput> = {},
|
||||
startDateParam?: Date,
|
||||
endDateParam?: Date
|
||||
) => {
|
||||
@@ -36,13 +47,7 @@ export const createBookingsFixture = (page: Page) => {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "attendee@example.com",
|
||||
name: "Attendee Example",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
},
|
||||
attendees,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
|
||||
@@ -69,61 +69,24 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
} = {}
|
||||
) => {
|
||||
const _user = await prisma.user.create({
|
||||
data: await createUser(workerInfo, opts),
|
||||
});
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
owner: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
title: "30 min",
|
||||
slug: "30-min",
|
||||
length: 30,
|
||||
},
|
||||
});
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
users: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
title: "Paid",
|
||||
slug: "paid",
|
||||
length: 30,
|
||||
price: 1000,
|
||||
},
|
||||
});
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
users: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
title: "Opt in",
|
||||
slug: "opt-in",
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
},
|
||||
data: createUser(workerInfo, opts),
|
||||
});
|
||||
|
||||
let defaultEventTypes: SupportedTestEventTypes[] = [
|
||||
{ title: "30 min", slug: "30-min", length: 30 },
|
||||
{ title: "Paid", slug: "paid", length: 30, price: 1000 },
|
||||
{ title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 },
|
||||
];
|
||||
|
||||
if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes);
|
||||
for (const eventTypeData of defaultEventTypes) {
|
||||
eventTypeData.owner = { connect: { id: _user.id } };
|
||||
eventTypeData.users = { connect: { id: _user.id } };
|
||||
await prisma.eventType.create({
|
||||
data: eventTypeData,
|
||||
});
|
||||
}
|
||||
|
||||
if (scenario.seedRoutingForms) {
|
||||
await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
@@ -314,8 +277,14 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
};
|
||||
};
|
||||
|
||||
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
|
||||
_bookings?: PrismaType.BookingCreateInput[];
|
||||
};
|
||||
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & { timeZone?: TimeZoneEnum };
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test as base } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
@@ -18,6 +19,21 @@ export interface Fixtures {
|
||||
prisma: typeof prisma;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace PlaywrightTest {
|
||||
//FIXME: how to restrict it to Frame only
|
||||
interface Matchers<R> {
|
||||
toBeEmbedCalLink(
|
||||
calNamespace: string,
|
||||
// eslint-disable-next-line
|
||||
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
|
||||
expectedUrlDetails?: ExpectedUrlDetails
|
||||
): Promise<R>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-fixtures
|
||||
*/
|
||||
|
||||
@@ -133,10 +133,10 @@ export async function bookFirstEvent(page: Page) {
|
||||
await bookEventOnThisPage(page);
|
||||
}
|
||||
|
||||
export const bookTimeSlot = async (page: Page) => {
|
||||
export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string }) => {
|
||||
// --- fill form
|
||||
await page.fill('[name="name"]', "Test Testson");
|
||||
await page.fill('[name="email"]', "test@example.com");
|
||||
await page.fill('[name="name"]', opts?.name ?? "Test Testson");
|
||||
await page.fill('[name="email"]', opts?.email ?? "test@example.com");
|
||||
await page.press('[name="email"]', "Enter");
|
||||
};
|
||||
// Provide an standalone localize utility not managed by next-i18n
|
||||
@@ -148,3 +148,25 @@ export async function localize(locale: string) {
|
||||
throw "No locale found for the given entry message";
|
||||
};
|
||||
}
|
||||
|
||||
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const eventTitle = args.eventTitle;
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
await page.fill("[name=length]", "10");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname !== "/event-types";
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
const eventTitle = args.eventTitle;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await page.locator('[data-testid="vertical-tab-event_advanced_tab_title"]').click();
|
||||
await page.locator('[data-testid="offer-seats-toggle"]').click();
|
||||
await page.locator('[data-testid="update-eventtype"]').click();
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ test.afterEach(({ users }) => users.deleteAll());
|
||||
test.describe("Reschedule Tests", async () => {
|
||||
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
@@ -45,15 +45,18 @@ test.describe("Reschedule Tests", async () => {
|
||||
});
|
||||
|
||||
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
|
||||
test.skip(true, "TODO: Re-enable after v1.7 launch");
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.CANCELLED,
|
||||
rescheduled: true,
|
||||
});
|
||||
|
||||
await page.goto(`/${user.username}/${user.eventTypes[0].slug}?rescheduleUid=${booking.uid}`);
|
||||
const formerTimeElement = page.locator('[data-testid="former_time_p_desktop"]');
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
const formerTimeElement = page.locator('[data-testid="former_time_p"]');
|
||||
await expect(formerTimeElement).toBeVisible();
|
||||
await booking.delete();
|
||||
});
|
||||
@@ -90,10 +93,12 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// NOTE: remove if old booking should not be deleted
|
||||
expect(await booking.self()).toBeNull();
|
||||
expect(await (await booking.self()).status).toBe(BookingStatus.CANCELLED);
|
||||
|
||||
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } });
|
||||
expect(newBooking).not.toBeNull();
|
||||
@@ -106,6 +111,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
await user.getPaymentCredential();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
rescheduled: true,
|
||||
@@ -150,6 +156,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
await user.login();
|
||||
await user.getPaymentCredential();
|
||||
await users.logout();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
rescheduled: true,
|
||||
@@ -171,6 +178,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
@@ -191,6 +199,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
test("Opt in event should be ACCEPTED when rescheduled by OWNER", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke="white" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
Binary file not shown.
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke="white" stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user