Merge branch 'main' into crypto-base64
This commit is contained in:
commit
d92d346c9a
|
@ -127,4 +127,12 @@ ZOHOCRM_CLIENT_ID=""
|
|||
ZOHOCRM_CLIENT_SECRET=""
|
||||
|
||||
|
||||
# - REVERT
|
||||
# Used for the Pipedrive integration (via/ Revert (https://revert.dev))
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-revert-api-keys
|
||||
REVERT_API_KEY=
|
||||
REVERT_PUBLIC_TOKEN=
|
||||
|
||||
# NOTE: If you're self hosting Revert, update this URL to point to your own instance.
|
||||
REVERT_API_URL=https://api.revert.dev/
|
||||
# *********************************************************************************************************
|
||||
|
|
|
@ -133,6 +133,11 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
|||
# Used for capturing exceptions and logging messages
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Formbricks Experience Management Integration
|
||||
FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||
FORMBRICKS_ENVIRONMENT_ID=
|
||||
FORMBRICKS_FEEDBACK_SURVEY_ID=
|
||||
|
||||
# Twilio
|
||||
# Used to send SMS reminders in workflows
|
||||
TWILIO_SID=
|
||||
|
@ -322,3 +327,7 @@ APP_ROUTER_SETTINGS_TEAMS_ENABLED=0
|
|||
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
|
||||
APP_ROUTER_APPS_ENABLED=0
|
||||
APP_ROUTER_VIDEO_ENABLED=0
|
||||
APP_ROUTER_TEAMS_ENABLED=0
|
||||
|
||||
# disable setry server source maps
|
||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||
|
|
|
@ -19,12 +19,12 @@ Fixes # (issue)
|
|||
|
||||
<!-- Please delete bullets that are not relevant. -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Tests (Unit/Integration/E2E or any other test)
|
||||
- [ ] This change requires a documentation update
|
||||
- Bug fix (non-breaking change which fixes an issue)
|
||||
- Chore (refactoring code, technical debt, workflow improvements)
|
||||
- New feature (non-breaking change which adds functionality)
|
||||
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- Tests (Unit/Integration/E2E or any other test)
|
||||
- This change requires a documentation update
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
|
|
|
@ -24,7 +24,6 @@ runs:
|
|||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}
|
||||
DATABASE_URL: ${{ inputs.DATABASE_URL }}
|
||||
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
|
||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
|
|
|
@ -5,7 +5,7 @@ runs:
|
|||
steps:
|
||||
- name: Cache playwright binaries
|
||||
id: playwright-cache
|
||||
uses: buildjet/cache@v2
|
||||
uses: buildjet/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/ms-playwright
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
name: "Apply issue labels to PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
label_on_pr:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: none
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Apply labels from linked issue to PR
|
||||
uses: actions/github-script@v5
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
async function getLinkedIssues(owner, repo, prNumber) {
|
||||
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $prNumber) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 10) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables = {
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
prNumber: prNumber,
|
||||
};
|
||||
|
||||
const result = await github.graphql(query, variables);
|
||||
return result.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
}
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
const linkedIssues = await getLinkedIssues(
|
||||
context.repo.owner,
|
||||
context.repo.repo,
|
||||
pr.number
|
||||
);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
for (const issue of linkedIssues) {
|
||||
if (issue.labels && issue.labels.nodes) {
|
||||
for (const label of issue.labels.nodes) {
|
||||
labelsToAdd.add(label.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (labelsToAdd.size) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
labels: Array.from(labelsToAdd),
|
||||
});
|
||||
}
|
|
@ -2,7 +2,7 @@ name: Check types
|
|||
on:
|
||||
workflow_call:
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
jobs:
|
||||
check-types:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
|
|
|
@ -17,10 +17,11 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/stale@v7
|
||||
with:
|
||||
days-before-close: -1
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: 7
|
||||
days-before-pr-close: -1
|
||||
stale-pr-message: "This PR is being marked as stale due to inactivity."
|
||||
close-pr-message: "This PR is being closed due to inactivity. Please reopen if work is intended to be continued."
|
||||
operations-per-run: 100
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
name: E2E App-Store Apps Tests
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
jobs:
|
||||
e2e-app-store:
|
||||
timeout-minutes: 20
|
||||
|
@ -29,7 +30,6 @@ jobs:
|
|||
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/cache-db
|
||||
|
@ -75,7 +75,7 @@ jobs:
|
|||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-store-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
path: test-results
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
name: E2E Embed React tests and booking flow (for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
|
@ -24,7 +25,6 @@ jobs:
|
|||
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/cache-db
|
||||
|
@ -61,7 +61,7 @@ jobs:
|
|||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: embed-react-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
path: test-results
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
name: E2E Embed Core tests and booking flow (for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
|
@ -29,7 +30,6 @@ jobs:
|
|||
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/cache-db
|
||||
|
@ -65,7 +65,7 @@ jobs:
|
|||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: embed-core-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
path: test-results
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
name: E2E tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
|
@ -28,7 +28,6 @@ jobs:
|
|||
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/cache-db
|
||||
|
@ -68,7 +67,7 @@ jobs:
|
|||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
path: test-results
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
pull_request_target:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
@ -16,3 +17,81 @@ jobs:
|
|||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
|
||||
sync-labels: ""
|
||||
team-labels:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: equitybee/team-label-action@main
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: none
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Apply labels from linked issue to PR
|
||||
uses: actions/github-script@v5
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
async function getLinkedIssues(owner, repo, prNumber) {
|
||||
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $prNumber) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 10) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables = {
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
prNumber: prNumber,
|
||||
};
|
||||
|
||||
const result = await github.graphql(query, variables);
|
||||
return result.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
}
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
const linkedIssues = await getLinkedIssues(
|
||||
context.repo.owner,
|
||||
context.repo.repo,
|
||||
pr.number
|
||||
);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
for (const issue of linkedIssues) {
|
||||
if (issue.labels && issue.labels.nodes) {
|
||||
for (const label of issue.labels.nodes) {
|
||||
labelsToAdd.add(label.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (labelsToAdd.size) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
labels: Array.from(labelsToAdd),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
|
||||
- name: Upload ESLint report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lint-results
|
||||
path: lint-results
|
||||
|
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
npx -p nextjs-bundle-analysis@0.5.0 report
|
||||
|
||||
- name: Upload bundle
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundle
|
||||
path: apps/web/.next/analyze/__bundle_analysis.json
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
name: Assign PR team labels
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
team-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: equitybee/team-label-action@main
|
||||
with:
|
||||
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
|
@ -4,9 +4,6 @@ on:
|
|||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
|
@ -15,36 +12,97 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
has-files-requiring-all-checks:
|
||||
- "!(**.md|.github/CODEOWNERS)"
|
||||
type-check:
|
||||
name: Type check
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/check-types.yml
|
||||
secrets: inherit
|
||||
|
||||
test:
|
||||
name: Unit tests
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
name: Linters
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production build
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-without-database:
|
||||
name: Production build (without database)
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: E2E tests
|
||||
needs: [changes, lint, build]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: E2E App Store tests
|
||||
needs: [changes, lint, build]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-app-store.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed:
|
||||
name: E2E embeds tests
|
||||
needs: [changes, lint, build]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: E2E React embeds tests
|
||||
needs: [changes, lint, build]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
needs: build
|
||||
name: Analyze Build
|
||||
needs: [changes, build]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [lint, type-check, test, build]
|
||||
needs: [changes, lint, type-check, test, build, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
if: always()
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: fail if conditional jobs failed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
|
||||
if: needs.changes.outputs.has-files-requiring-all-checks == 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled'))
|
||||
run: exit 1
|
||||
|
|
|
@ -2,9 +2,6 @@ name: Pre-release checks
|
|||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
|
|
|
@ -11,7 +11,6 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=6144"' >> $GITHUB_ENV
|
||||
- uses: ./.github/actions/yarn-install
|
||||
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
|
||||
- run: yarn test
|
||||
|
|
|
@ -554,6 +554,10 @@ following
|
|||
|
||||
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||
|
||||
### Obtaining Pipedrive Client ID and Secret
|
||||
|
||||
[Follow these steps](./packages/app-store/pipedrive-crm/)
|
||||
|
||||
## Workflows
|
||||
|
||||
### Setting up SendGrid for Email reminders
|
||||
|
|
|
@ -48,17 +48,21 @@ Here is the full architecture:
|
|||
|
||||
### Email Router
|
||||
|
||||
To expose the AI app, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||
To expose the AI app, you can use either [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client), an open source tunnelling tool; or [nGrok](https://ngrok.com/), a popular closed source tunnelling tool.
|
||||
|
||||
For Tunnelmole, run `tmole 3005` (or the AI app's port number) in a new terminal. Please replace `3005` with the port number if it is different. In the output, you'll see two URLs, one http and a https (we recommend using the https url for privacy and security). To install Tunnelmole, use `curl -O https://install.tunnelmole.com/8dPBw/install && sudo bash install`. (On Windows, download [tmole.exe](https://tunnelmole.com/downloads/tmole.exe))
|
||||
|
||||
For nGrok, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install nGrok first.
|
||||
|
||||
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||
|
||||
1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
|
||||
2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
|
||||
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`, both with priority `10` if prompted.
|
||||
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`. Set the priority to `10` if prompted.
|
||||
4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
|
||||
5. In the Destination URL field, use the nGrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
|
||||
5. In the Destination URL field, use the Tunnelmole or ngrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.tunnelmole.net/api/receive?parseKey=ABC-123` or `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
|
||||
6. Activate "POST the raw, full MIME message".
|
||||
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the nGrok listener and server.
|
||||
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the Tunnelmole or ngrok listener and server.
|
||||
8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||
|
||||
Please feel free to improve any part of this architecture!
|
||||
|
|
|
@ -17,6 +17,7 @@ const ROUTES: [URLPattern, boolean][] = [
|
|||
["/apps", process.env.APP_ROUTER_APPS_ENABLED === "1"] as const,
|
||||
["/bookings/:status", process.env.APP_ROUTER_BOOKINGS_STATUS_ENABLED === "1"] as const,
|
||||
["/video/:path*", process.env.APP_ROUTER_VIDEO_ENABLED === "1"] as const,
|
||||
["/teams", process.env.APP_ROUTER_TEAMS_ENABLED === "1"] as const,
|
||||
].map(([pathname, enabled]) => [
|
||||
new URLPattern({
|
||||
pathname,
|
||||
|
|
|
@ -1,3 +1,14 @@
|
|||
export type Params = {
|
||||
[param: string]: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export type SearchParams = {
|
||||
[param: string]: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export type PageProps = {
|
||||
params: Params;
|
||||
searchParams: SearchParams;
|
||||
};
|
||||
|
||||
export type LayoutProps = { params: Params; children: React.ReactElement };
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
import { type ReactElement } from "react";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
type EventTypesLayoutProps = {
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
export default function Layout({ children }: EventTypesLayoutProps) {
|
||||
return (
|
||||
<PageWrapper getLayout={null} requiresLicense={false} nonce={undefined} themeBasis={null}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import { type ReactElement } from "react";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
type EventTypesLayoutProps = {
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
export default function Layout({ children }: EventTypesLayoutProps) {
|
||||
return (
|
||||
<PageWrapper getLayout={null} requiresLicense={false} nonce={undefined} themeBasis={null}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import LegacyPage from "@pages/getting-started/[[...step]]";
|
||||
import { ssrInit } from "app/_trpc/ssrInit";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
async function getData() {
|
||||
const req = { headers: headers(), cookies: cookies() };
|
||||
|
||||
//@ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const ssr = await ssrInit();
|
||||
await ssr.viewer.me.prefetch();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
completedOnboarding: true,
|
||||
teams: {
|
||||
select: {
|
||||
accepted: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
logo: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User from session not found");
|
||||
}
|
||||
|
||||
if (user.completedOnboarding) {
|
||||
redirect("/event-types");
|
||||
}
|
||||
|
||||
return {
|
||||
dehydratedState: await ssr.dehydrate(),
|
||||
hasPendingInvites: user.teams.find((team: any) => team.accepted === false) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const props = await getData();
|
||||
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={null} requiresLicense={false} nonce={nonce} themeBasis={null} {...props}>
|
||||
<LegacyPage />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { headers } from "next/headers";
|
||||
import { type ReactElement } from "react";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir";
|
||||
|
||||
type WrapperWithLayoutProps = {
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import Page from "@pages/settings/admin/oAuth/index";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "OAuth",
|
||||
() => "Add new OAuth Clients"
|
||||
);
|
||||
|
||||
export default Page;
|
|
@ -1,10 +0,0 @@
|
|||
import Page from "@pages/settings/admin/index";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Admin",
|
||||
() => "admin_description"
|
||||
);
|
||||
|
||||
export default Page;
|
|
@ -1,22 +0,0 @@
|
|||
// pages without layout (e.g., /availability/index.tsx) are supposed to go under (layout) folder
|
||||
import { headers } from "next/headers";
|
||||
import { type ReactElement } from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
type WrapperWithLayoutProps = {
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
// pages containing layout (e.g., /availability/[schedule].tsx) are supposed to go under (no-layout) folder
|
||||
import { headers } from "next/headers";
|
||||
import { type ReactElement } from "react";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
type WrapperWithoutLayoutProps = {
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
export default async function WrapperWithoutLayout({ children }: WrapperWithoutLayoutProps) {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={null} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import Page from "@pages/video/no-meeting-found";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "",
|
||||
() => ""
|
||||
);
|
||||
|
||||
export default Page;
|
|
@ -1,21 +0,0 @@
|
|||
import { headers } from "next/headers";
|
||||
import { type ReactElement } from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
type WrapperWithLayoutProps = {
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
export default WithLayout({ getLayout: null })<"L">;
|
|
@ -1,6 +1,7 @@
|
|||
import CategoryPage from "@pages/apps/categories/[category]";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { notFound } from "next/navigation";
|
||||
import z from "zod";
|
||||
|
||||
|
@ -9,8 +10,6 @@ import { APP_NAME } from "@calcom/lib/constants";
|
|||
import prisma from "@calcom/prisma";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => `${APP_NAME} | ${APP_NAME}`,
|
||||
|
@ -67,13 +66,6 @@ const getPageProps = async ({ params }: { params: Record<string, string | string
|
|||
};
|
||||
};
|
||||
|
||||
export default async function Page({ params }: { params: Record<string, string | string[]> }) {
|
||||
const { apps } = await getPageProps({ params });
|
||||
return (
|
||||
<PageWrapper getLayout={null} requiresLicense={false} nonce={undefined} themeBasis={null}>
|
||||
<CategoryPage apps={apps} />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error getData arg
|
||||
export default WithLayout({ getData: getPageProps, Page: CategoryPage })<P>;
|
||||
export const dynamic = "force-static";
|
|
@ -1,14 +1,13 @@
|
|||
import LegacyPage from "@pages/apps/categories/index";
|
||||
import { ssrInit } from "app/_trpc/ssrInit";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => `Categories | ${APP_NAME}`,
|
||||
|
@ -43,14 +42,4 @@ async function getPageProps() {
|
|||
};
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const props = await getPageProps();
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={null} requiresLicense={false} nonce={nonce} themeBasis={null} {...props}>
|
||||
<LegacyPage {...props} />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
export default WithLayout({ getData: getPageProps, Page: LegacyPage, getLayout: null })<"P">;
|
|
@ -0,0 +1,3 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
export default WithLayout({ getLayout: null })<"L">;
|
|
@ -30,7 +30,7 @@ const getPageProps = async ({ params }: { params: Record<string, string | string
|
|||
};
|
||||
|
||||
export default async function Page({ params }: { params: Record<string, string | string[]> }) {
|
||||
const { category } = await getPageProps({ params });
|
||||
await getPageProps({ params });
|
||||
|
||||
return <LegacyPage />;
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import AppsPage from "@pages/apps";
|
||||
import { ssrInit } from "app/_trpc/ssrInit";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => `Apps | ${APP_NAME}`,
|
||||
() => ""
|
||||
);
|
||||
};
|
||||
|
||||
const getPageProps = async () => {
|
||||
const ssr = await ssrInit();
|
||||
const req = { headers: headers(), cookies: cookies() };
|
||||
|
||||
// @ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
let appStore, userAdminTeams: UserAdminTeams;
|
||||
if (session?.user?.id) {
|
||||
userAdminTeams = await getUserAdminTeams({ userId: session.user.id, getUserInfo: true });
|
||||
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams);
|
||||
} else {
|
||||
appStore = await getAppRegistry();
|
||||
userAdminTeams = [];
|
||||
}
|
||||
|
||||
const categoryQuery = appStore.map(({ categories }) => ({
|
||||
categories: categories || [],
|
||||
}));
|
||||
|
||||
const categories = categoryQuery.reduce((c, app) => {
|
||||
for (const category of app.categories) {
|
||||
c[category] = c[category] ? c[category] + 1 : 1;
|
||||
}
|
||||
return c;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return {
|
||||
categories: Object.entries(categories)
|
||||
.map(([name, count]): { name: AppCategories; count: number } => ({
|
||||
name: name as AppCategories,
|
||||
count,
|
||||
}))
|
||||
.sort(function (a, b) {
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
userAdminTeams,
|
||||
dehydratedState: await ssr.dehydrate(),
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AppPageAppDir() {
|
||||
const { categories, appStore, userAdminTeams, dehydratedState } = await getPageProps();
|
||||
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
getLayout={getLayout}
|
||||
requiresLicense={false}
|
||||
nonce={nonce}
|
||||
themeBasis={null}
|
||||
dehydratedState={dehydratedState}>
|
||||
<AppsPage categories={categories} appStore={appStore} userAdminTeams={userAdminTeams} />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { ssgInit } from "app/_trpc/ssgInit";
|
||||
import type { Params } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { ReactElement } from "react";
|
||||
import { z } from "zod";
|
||||
|
@ -8,8 +9,6 @@ import { z } from "zod";
|
|||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
||||
|
||||
const querySchema = z.object({
|
||||
|
@ -43,14 +42,6 @@ const getData = async ({ params }: { params: Params }) => {
|
|||
};
|
||||
};
|
||||
|
||||
export default async function BookingPageLayout({ params, children }: Props) {
|
||||
const props = await getData({ params });
|
||||
|
||||
return (
|
||||
<PageWrapper requiresLicense={false} getLayout={getLayout} nonce={undefined} themeBasis={null} {...props}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
export default WithLayout({ getLayout, getData })<"L">;
|
||||
|
||||
export const dynamic = "force-static";
|
|
@ -0,0 +1,5 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
|
||||
export default WithLayout({ getLayout })<"L">;
|
|
@ -0,0 +1,5 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir";
|
||||
|
||||
export default WithLayout({ getLayout })<"L">;
|
|
@ -0,0 +1,5 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir";
|
||||
|
||||
export default WithLayout({ getLayout })<"L">;
|
|
@ -0,0 +1,5 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir";
|
||||
|
||||
export default WithLayout({ getLayout })<"L">;
|
|
@ -0,0 +1,3 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
export default WithLayout({ getLayout: null })<"L">;
|
|
@ -0,0 +1,13 @@
|
|||
import LegacyPage from "@pages/settings/admin/oAuth/index";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "OAuth",
|
||||
() => "Add new OAuth Clients"
|
||||
);
|
||||
|
||||
export default WithLayout({ getLayout, Page: LegacyPage })<"P">;
|
|
@ -0,0 +1,5 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
|
||||
|
||||
export default WithLayout({ getLayout })<"L">;
|
|
@ -0,0 +1,13 @@
|
|||
import LegacyPage from "@pages/settings/admin/index";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Admin",
|
||||
() => "admin_description"
|
||||
);
|
||||
|
||||
export default WithLayout({ getLayout, Page: LegacyPage })<"P">;
|
|
@ -0,0 +1,5 @@
|
|||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
|
||||
|
||||
export default WithLayout({ getLayout })<"L">;
|
|
@ -1,28 +1,19 @@
|
|||
import OldPage from "@pages/teams/index";
|
||||
import { ssrInit } from "app/_trpc/ssrInit";
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { type GetServerSidePropsContext } from "next";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("teams"),
|
||||
(t) => t("create_manage_teams_collaborative")
|
||||
);
|
||||
|
||||
type PageProps = {
|
||||
params: Params;
|
||||
};
|
||||
|
||||
async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolvedUrl">) {
|
||||
const ssr = await ssrInit();
|
||||
await ssr.viewer.me.prefetch();
|
||||
|
@ -41,24 +32,5 @@ async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolve
|
|||
return { dehydratedState: await ssr.dehydrate() };
|
||||
}
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
const legacyCtx = buildLegacyCtx(h, cookies(), params);
|
||||
// @ts-expect-error `req` of type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to `req` in `GetServerSidePropsContext`
|
||||
const props = await getData(legacyCtx);
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
getLayout={getLayout}
|
||||
requiresLicense={false}
|
||||
nonce={nonce}
|
||||
themeBasis={null}
|
||||
dehydratedState={props.dehydratedState}>
|
||||
<OldPage />
|
||||
</PageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
// @ts-expect-error getData arg
|
||||
export default WithLayout({ getData, getLayout, Page: OldPage })<"P">;
|
|
@ -1,30 +1,21 @@
|
|||
import OldPage from "@pages/video/[uid]";
|
||||
import { ssrInit } from "app/_trpc/ssrInit";
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { type GetServerSidePropsContext } from "next";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => `${APP_NAME} Video`,
|
||||
(t) => t("quick_video_meeting")
|
||||
);
|
||||
|
||||
type PageProps = Readonly<{
|
||||
params: Params;
|
||||
}>;
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolvedUrl">) {
|
||||
|
@ -107,24 +98,5 @@ async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolve
|
|||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
const legacyCtx = buildLegacyCtx(headers(), cookies(), params);
|
||||
// @ts-expect-error `req` of type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to `req` in `GetServerSidePropsContext`
|
||||
const { dehydratedState, ...restProps } = await getData(legacyCtx);
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
getLayout={null}
|
||||
requiresLicense={false}
|
||||
nonce={nonce}
|
||||
themeBasis={null}
|
||||
dehydratedState={dehydratedState}>
|
||||
<OldPage {...restProps} />
|
||||
</PageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
// @ts-expect-error getData arg
|
||||
export default WithLayout({ getData, Page: OldPage, getLayout: null })<"P">;
|
|
@ -1,26 +1,17 @@
|
|||
import OldPage from "@pages/video/meeting-ended/[uid]";
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { type GetServerSidePropsContext } from "next";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Meeting Unavailable",
|
||||
() => "Meeting Unavailable"
|
||||
);
|
||||
|
||||
type PageProps = Readonly<{
|
||||
params: Params;
|
||||
}>;
|
||||
|
||||
async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolvedUrl">) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
|
@ -58,19 +49,5 @@ async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolve
|
|||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
const legacyCtx = buildLegacyCtx(headers(), cookies(), params);
|
||||
// @ts-expect-error `req` of type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to `req` in `GetServerSidePropsContext`
|
||||
const props = await getData(legacyCtx);
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={null} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||
<OldPage {...props} />
|
||||
</PageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
// @ts-expect-error getData arg
|
||||
export default WithLayout({ getData, Page: OldPage, getLayout: null })<"P">;
|
|
@ -1,16 +1,12 @@
|
|||
import OldPage from "@pages/video/meeting-not-started/[uid]";
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { type GetServerSidePropsContext } from "next";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
type PageProps = Readonly<{
|
||||
params: Params;
|
||||
}>;
|
||||
|
@ -51,19 +47,5 @@ async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolve
|
|||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
const legacyCtx = buildLegacyCtx(headers(), cookies(), params);
|
||||
// @ts-expect-error `req` of type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to `req` in `GetServerSidePropsContext`
|
||||
const props = await getData(legacyCtx);
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={null} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||
<OldPage {...props} />
|
||||
</PageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
// @ts-expect-error getData arg
|
||||
export default WithLayout({ getData, Page: OldPage, getLayout: null })<"P">;
|
|
@ -0,0 +1,20 @@
|
|||
import LegacyPage from "@pages/video/no-meeting-found";
|
||||
import { ssrInit } from "app/_trpc/ssrInit";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("no_meeting_found"),
|
||||
(t) => t("no_meeting_found")
|
||||
);
|
||||
|
||||
const getData = async () => {
|
||||
const ssr = await ssrInit();
|
||||
|
||||
return {
|
||||
dehydratedState: await ssr.dehydrate(),
|
||||
};
|
||||
};
|
||||
|
||||
export default WithLayout({ getData, Page: LegacyPage, getLayout: null })<"P">;
|
|
@ -84,7 +84,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||
`}</style>
|
||||
</head>
|
||||
<body
|
||||
className="dark:bg-darkgray-50 desktop-transparent bg-subtle antialiased"
|
||||
className="dark:bg-darkgray-50 todesktop:!bg-transparent bg-subtle antialiased"
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import type { LayoutProps, PageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
type WithLayoutParams<T extends Record<string, any>> = {
|
||||
getLayout: ((page: React.ReactElement) => React.ReactNode) | null;
|
||||
Page?: (props: T) => React.ReactElement;
|
||||
getData?: (arg: ReturnType<typeof buildLegacyCtx>) => Promise<T>;
|
||||
};
|
||||
|
||||
export function WithLayout<T extends Record<string, any>>({ getLayout, getData, Page }: WithLayoutParams<T>) {
|
||||
return async <P extends "P" | "L">(p: P extends "P" ? PageProps : LayoutProps) => {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
const props = getData ? await getData(buildLegacyCtx(h, cookies(), p.params)) : ({} as T);
|
||||
|
||||
const children = "children" in p ? p.children : null;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null} {...props}>
|
||||
{Page ? <Page {...props} /> : children}
|
||||
</PageWrapper>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -20,7 +20,7 @@ export interface CalPageWrapper {
|
|||
|
||||
export type PageWrapperProps = Readonly<{
|
||||
getLayout: ((page: React.ReactElement) => ReactNode) | null;
|
||||
children: React.ReactElement;
|
||||
children: React.ReactNode;
|
||||
requiresLicense: boolean;
|
||||
nonce: string | undefined;
|
||||
themeBasis: string | null;
|
||||
|
@ -62,7 +62,7 @@ function PageWrapper(props: PageWrapperProps) {
|
|||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
{getLayout(
|
||||
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : props.children
|
||||
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : <>{props.children}</>
|
||||
)}
|
||||
</>
|
||||
</AppProviders>
|
||||
|
|
|
@ -26,7 +26,7 @@ export default function AdminLayout({
|
|||
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
|
||||
return (
|
||||
<SettingsLayout {...rest}>
|
||||
<div className="divide-subtle mx-auto flex max-w-4xl flex-row divide-y">
|
||||
<div className="divide-subtle bg-default mx-auto flex max-w-4xl flex-row divide-y">
|
||||
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
|
|
|
@ -384,7 +384,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
rel="noreferrer"
|
||||
className="text-sm leading-6 text-blue-600 hover:underline">
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
<img
|
||||
|
|
|
@ -3,7 +3,6 @@ import type { FormEvent } from "react";
|
|||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
|
@ -11,6 +10,7 @@ import turndown from "@calcom/lib/turndownService";
|
|||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { UserAvatar } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
type FormData = {
|
||||
|
@ -108,9 +108,7 @@ const UserProfile = () => {
|
|||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && (
|
||||
<OrganizationMemberAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />
|
||||
)}
|
||||
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
|
|
|
@ -5,8 +5,7 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
|||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
|
||||
import { UserAvatar } from "@components/ui/avatar/UserAvatar";
|
||||
import { UserAvatar } from "@calcom/ui";
|
||||
|
||||
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
||||
type MembersType = TeamType["members"];
|
||||
|
|
|
@ -81,7 +81,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||
|
||||
const ActionButtons = () => {
|
||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||
<div className="me-2 ms-2 flex flex-row space-x-2">
|
||||
<div className="relative bottom-[6px] me-2 ms-2 flex flex-row space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
|
@ -137,7 +137,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-6 flex flex-row">
|
||||
<span className={classNames("mx-2 py-3.5")}>
|
||||
{usernameIsAvailable ? <Check className="h-4 w-4" /> : <></>}
|
||||
{usernameIsAvailable ? <Check className="relative bottom-[6px] h-4 w-4" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageSrc"> & {
|
||||
user: Pick<User, "organizationId" | "name" | "username">;
|
||||
/**
|
||||
* Useful when allowing the user to upload their own avatar and showing the avatar before it's uploaded
|
||||
*/
|
||||
previewSrc?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* It is aware of the user's organization to correctly show the avatar from the correct URL
|
||||
*/
|
||||
export function UserAvatar(props: UserAvatarProps) {
|
||||
const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
|
||||
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} />;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,855 @@
|
|||
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["orgMigration"] });
|
||||
|
||||
type UserMetadata = {
|
||||
migratedToOrgFrom?: {
|
||||
username: string;
|
||||
reverted: boolean;
|
||||
revertTime: string;
|
||||
lastMigrationTime: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure that the migration is idempotent
|
||||
*/
|
||||
export async function moveUserToOrg({
|
||||
user: { id: userId, userName: userName },
|
||||
targetOrg: {
|
||||
id: targetOrgId,
|
||||
username: targetOrgUsername,
|
||||
membership: { role: targetOrgRole, accepted: targetOrgMembershipAccepted = true },
|
||||
},
|
||||
shouldMoveTeams,
|
||||
}: {
|
||||
user: { id?: number; userName?: string };
|
||||
targetOrg: {
|
||||
id: number;
|
||||
username?: string;
|
||||
membership: { role: MembershipRole; accepted?: boolean };
|
||||
};
|
||||
shouldMoveTeams: boolean;
|
||||
}) {
|
||||
assertUserIdOrUserName(userId, userName);
|
||||
const team = await getTeamOrThrowError(targetOrgId);
|
||||
|
||||
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
||||
|
||||
if (!teamMetadata?.isOrganization) {
|
||||
throw new Error(`Team with ID:${targetOrgId} is not an Org`);
|
||||
}
|
||||
|
||||
const targetOrganization = {
|
||||
...team,
|
||||
metadata: teamMetadata,
|
||||
};
|
||||
const userToMoveToOrg = await getUniqueUserThatDoesntBelongToOrg(userName, userId, targetOrgId);
|
||||
assertUserPartOfOtherOrg(userToMoveToOrg, userName, userId, targetOrgId);
|
||||
|
||||
if (!targetOrgUsername) {
|
||||
targetOrgUsername = getOrgUsernameFromEmail(
|
||||
userToMoveToOrg.email,
|
||||
targetOrganization.metadata.orgAutoAcceptEmail || ""
|
||||
);
|
||||
}
|
||||
|
||||
const userWithSameUsernameInOrg = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: targetOrgUsername,
|
||||
organizationId: targetOrgId,
|
||||
},
|
||||
});
|
||||
|
||||
log.debug({
|
||||
userWithSameUsernameInOrg,
|
||||
targetOrgUsername,
|
||||
targetOrgId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (userWithSameUsernameInOrg && userWithSameUsernameInOrg.id !== userId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `Username ${targetOrgUsername} already exists for orgId: ${targetOrgId} for some other user`,
|
||||
});
|
||||
}
|
||||
|
||||
assertUserPartOfOrgAndRemigrationAllowed(userToMoveToOrg, targetOrgId, targetOrgUsername, userId);
|
||||
|
||||
const orgMetadata = teamMetadata;
|
||||
|
||||
const userToMoveToOrgMetadata = (userToMoveToOrg.metadata || {}) as UserMetadata;
|
||||
|
||||
const nonOrgUserName =
|
||||
(userToMoveToOrgMetadata.migratedToOrgFrom?.username as string) || userToMoveToOrg.username;
|
||||
if (!nonOrgUserName) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `User with id: ${userId} doesn't have a non-org username`,
|
||||
});
|
||||
}
|
||||
|
||||
await dbMoveUserToOrg({ userToMoveToOrg, targetOrgId, targetOrgUsername, nonOrgUserName });
|
||||
|
||||
let teamsToBeMovedToOrg;
|
||||
if (shouldMoveTeams) {
|
||||
teamsToBeMovedToOrg = await moveTeamsWithoutMembersToOrg({ targetOrgId, userToMoveToOrg });
|
||||
}
|
||||
|
||||
await updateMembership({ targetOrgId, userToMoveToOrg, targetOrgRole, targetOrgMembershipAccepted });
|
||||
|
||||
await addRedirect({
|
||||
nonOrgUserName,
|
||||
teamsToBeMovedToOrg: teamsToBeMovedToOrg || [],
|
||||
organization: targetOrganization,
|
||||
targetOrgUsername,
|
||||
});
|
||||
|
||||
await setOrgSlugIfNotSet(targetOrganization, orgMetadata, targetOrgId);
|
||||
|
||||
log.debug(`orgId:${targetOrgId} attached to userId:${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the migration is idempotent
|
||||
*/
|
||||
export async function removeUserFromOrg({ targetOrgId, userId }: { targetOrgId: number; userId: number }) {
|
||||
const userToRemoveFromOrg = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userToRemoveFromOrg) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `User with id: ${userId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
if (userToRemoveFromOrg.organizationId !== targetOrgId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `User with id: ${userId} is not part of orgId: ${targetOrgId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const userToRemoveFromOrgMetadata = (userToRemoveFromOrg.metadata || {}) as {
|
||||
migratedToOrgFrom?: {
|
||||
username: string;
|
||||
reverted: boolean;
|
||||
revertTime: string;
|
||||
lastMigrationTime: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (!userToRemoveFromOrgMetadata.migratedToOrgFrom) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `User with id: ${userId} wasn't migrated. So, there is nothing to revert`,
|
||||
});
|
||||
}
|
||||
|
||||
const nonOrgUserName = userToRemoveFromOrgMetadata.migratedToOrgFrom.username as string;
|
||||
if (!nonOrgUserName) {
|
||||
throw new HttpError({
|
||||
statusCode: 500,
|
||||
message: `User with id: ${userId} doesn't have a non-org username`,
|
||||
});
|
||||
}
|
||||
|
||||
const teamsToBeRemovedFromOrg = await removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg });
|
||||
await dbRemoveUserFromOrg({ userToRemoveFromOrg, nonOrgUserName });
|
||||
|
||||
await removeUserAlongWithItsTeamsRedirects({ nonOrgUserName, teamsToBeRemovedFromOrg });
|
||||
await removeMembership({ targetOrgId, userToRemoveFromOrg });
|
||||
|
||||
log.debug(`orgId:${targetOrgId} attached to userId:${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the migration is idempotent
|
||||
*/
|
||||
export async function moveTeamToOrg({
|
||||
targetOrg,
|
||||
teamId,
|
||||
moveMembers,
|
||||
}: {
|
||||
targetOrg: { id: number; teamSlug: string };
|
||||
teamId: number;
|
||||
moveMembers?: boolean;
|
||||
}) {
|
||||
const possibleOrg = await getTeamOrThrowError(targetOrg.id);
|
||||
const { oldTeamSlug, updatedTeam } = await dbMoveTeamToOrg({ teamId, targetOrg });
|
||||
|
||||
const teamMetadata = teamMetadataSchema.parse(possibleOrg?.metadata);
|
||||
|
||||
if (!teamMetadata?.isOrganization) {
|
||||
throw new Error(`${targetOrg.id} is not an Org`);
|
||||
}
|
||||
|
||||
const targetOrganization = possibleOrg;
|
||||
const orgMetadata = teamMetadata;
|
||||
await addTeamRedirect({
|
||||
oldTeamSlug,
|
||||
teamSlug: updatedTeam.slug,
|
||||
orgSlug: targetOrganization.slug || orgMetadata.requestedSlug || null,
|
||||
});
|
||||
await setOrgSlugIfNotSet({ slug: targetOrganization.slug }, orgMetadata, targetOrg.id);
|
||||
if (moveMembers) {
|
||||
for (const membership of updatedTeam.members) {
|
||||
await moveUserToOrg({
|
||||
user: {
|
||||
id: membership.userId,
|
||||
},
|
||||
targetOrg: {
|
||||
id: targetOrg.id,
|
||||
membership: {
|
||||
role: membership.role,
|
||||
accepted: membership.accepted,
|
||||
},
|
||||
},
|
||||
shouldMoveTeams: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
log.debug(`Successfully moved team ${teamId} to org ${targetOrg.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the migration is idempotent
|
||||
*/
|
||||
export async function removeTeamFromOrg({ targetOrgId, teamId }: { targetOrgId: number; teamId: number }) {
|
||||
const removedTeam = await dbRemoveTeamFromOrg({ teamId });
|
||||
|
||||
await removeTeamRedirect(removedTeam.slug);
|
||||
|
||||
log.debug(`Successfully removed team ${teamId} from org ${targetOrgId}`);
|
||||
}
|
||||
|
||||
async function dbMoveTeamToOrg({
|
||||
teamId,
|
||||
targetOrg,
|
||||
}: {
|
||||
teamId: number;
|
||||
targetOrg: {
|
||||
id: number;
|
||||
teamSlug: string;
|
||||
};
|
||||
}) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `Team with id: ${teamId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
||||
const oldTeamSlug = teamMetadata?.migratedToOrgFrom?.teamSlug || team.slug;
|
||||
|
||||
const updatedTeam = await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
data: {
|
||||
slug: targetOrg.teamSlug,
|
||||
parentId: targetOrg.id,
|
||||
metadata: {
|
||||
...teamMetadata,
|
||||
migratedToOrgFrom: {
|
||||
teamSlug: team.slug,
|
||||
lastMigrationTime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { oldTeamSlug, updatedTeam };
|
||||
}
|
||||
|
||||
async function getUniqueUserThatDoesntBelongToOrg(
|
||||
userName: string | undefined,
|
||||
userId: number | undefined,
|
||||
excludeOrgId: number
|
||||
) {
|
||||
log.debug("getUniqueUserThatDoesntBelongToOrg", { userName, userId, excludeOrgId });
|
||||
if (userName) {
|
||||
const matchingUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
username: userName,
|
||||
},
|
||||
});
|
||||
const foundUsers = matchingUsers.filter(
|
||||
(user) => user.organizationId === excludeOrgId || user.organizationId === null
|
||||
);
|
||||
if (foundUsers.length > 1) {
|
||||
throw new Error(`More than one user found with username: ${userName}`);
|
||||
}
|
||||
return foundUsers[0];
|
||||
} else {
|
||||
return await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function setOrgSlugIfNotSet(
|
||||
targetOrganization: {
|
||||
slug: string | null;
|
||||
},
|
||||
orgMetadata: {
|
||||
requestedSlug?: string | undefined;
|
||||
},
|
||||
targetOrgId: number
|
||||
) {
|
||||
if (targetOrganization.slug) {
|
||||
return;
|
||||
}
|
||||
if (!orgMetadata.requestedSlug) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `Org with id: ${targetOrgId} doesn't have a slug. Tried using requestedSlug but that's also not present. So, all migration done but failed to set the Organization slug. Please set it manually`,
|
||||
});
|
||||
}
|
||||
await setOrgSlug({
|
||||
targetOrgId,
|
||||
targetSlug: orgMetadata.requestedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
function assertUserPartOfOrgAndRemigrationAllowed(
|
||||
userToMoveToOrg: {
|
||||
organizationId: User["organizationId"];
|
||||
},
|
||||
targetOrgId: number,
|
||||
targetOrgUsername: string,
|
||||
userId: number | undefined
|
||||
) {
|
||||
if (userToMoveToOrg.organizationId) {
|
||||
if (userToMoveToOrg.organizationId !== targetOrgId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `User ${targetOrgUsername} already exists for different Org with orgId: ${targetOrgId}`,
|
||||
});
|
||||
} else {
|
||||
log.debug(`Redoing migration for userId: ${userId} to orgId:${targetOrgId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getTeamOrThrowError(targetOrgId: number) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: targetOrgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `Org with id: ${targetOrgId} not found`,
|
||||
});
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
function assertUserPartOfOtherOrg(
|
||||
userToMoveToOrg: {
|
||||
organizationId: User["organizationId"];
|
||||
} | null,
|
||||
userName: string | undefined,
|
||||
userId: number | undefined,
|
||||
targetOrgId: number
|
||||
): asserts userToMoveToOrg {
|
||||
if (!userToMoveToOrg) {
|
||||
throw new HttpError({
|
||||
message: `User ${userName ? userName : `ID:${userId}`} is part of an org already`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (userToMoveToOrg.organizationId && userToMoveToOrg.organizationId !== targetOrgId) {
|
||||
throw new HttpError({
|
||||
message: `User is already a part of different organization ID: ${userToMoveToOrg.organizationId}`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertUserIdOrUserName(userId: number | undefined, userName: string | undefined) {
|
||||
if (!userId && !userName) {
|
||||
throw new HttpError({ statusCode: 400, message: "userId or userName is required" });
|
||||
}
|
||||
if (userId && userName) {
|
||||
throw new HttpError({ statusCode: 400, message: "Provide either userId or userName" });
|
||||
}
|
||||
}
|
||||
|
||||
async function addRedirect({
|
||||
nonOrgUserName,
|
||||
organization,
|
||||
targetOrgUsername,
|
||||
teamsToBeMovedToOrg,
|
||||
}: {
|
||||
nonOrgUserName: string | null;
|
||||
organization: Team;
|
||||
targetOrgUsername: string;
|
||||
teamsToBeMovedToOrg: { slug: string | null }[];
|
||||
}) {
|
||||
if (!nonOrgUserName) {
|
||||
return;
|
||||
}
|
||||
const orgSlug = organization.slug || (organization.metadata as { requestedSlug?: string })?.requestedSlug;
|
||||
if (!orgSlug) {
|
||||
log.debug("No slug for org. Not adding the redirect", safeStringify({ organization, nonOrgUserName }));
|
||||
return;
|
||||
}
|
||||
// If the user had a username earlier, we need to redirect it to the new org username
|
||||
const orgUrlPrefix = getOrgFullOrigin(orgSlug);
|
||||
log.debug({
|
||||
orgUrlPrefix,
|
||||
nonOrgUserName,
|
||||
targetOrgUsername,
|
||||
});
|
||||
|
||||
await prisma.tempOrgRedirect.upsert({
|
||||
where: {
|
||||
from_type_fromOrgId: {
|
||||
type: RedirectType.User,
|
||||
from: nonOrgUserName,
|
||||
fromOrgId: 0,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
type: RedirectType.User,
|
||||
from: nonOrgUserName,
|
||||
fromOrgId: 0,
|
||||
toUrl: `${orgUrlPrefix}/${targetOrgUsername}`,
|
||||
},
|
||||
update: {
|
||||
toUrl: `${orgUrlPrefix}/${targetOrgUsername}`,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [, team] of Object.entries(teamsToBeMovedToOrg)) {
|
||||
if (!team.slug) {
|
||||
log.debug("No slug for team. Not adding the redirect", safeStringify({ team }));
|
||||
continue;
|
||||
}
|
||||
await prisma.tempOrgRedirect.upsert({
|
||||
where: {
|
||||
from_type_fromOrgId: {
|
||||
type: RedirectType.Team,
|
||||
from: team.slug,
|
||||
fromOrgId: 0,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
type: RedirectType.Team,
|
||||
from: team.slug,
|
||||
fromOrgId: 0,
|
||||
toUrl: `${orgUrlPrefix}/team/${team.slug}`,
|
||||
},
|
||||
update: {
|
||||
toUrl: `${orgUrlPrefix}/team/${team.slug}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function addTeamRedirect({
|
||||
oldTeamSlug,
|
||||
teamSlug,
|
||||
orgSlug,
|
||||
}: {
|
||||
oldTeamSlug: string | null;
|
||||
teamSlug: string | null;
|
||||
orgSlug: string | null;
|
||||
}) {
|
||||
if (!oldTeamSlug) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "No oldSlug for team. Not adding the redirect",
|
||||
});
|
||||
}
|
||||
if (!teamSlug) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "No slug for team. Not adding the redirect",
|
||||
});
|
||||
}
|
||||
if (!orgSlug) {
|
||||
log.warn(`No slug for org. Not adding the redirect`);
|
||||
return;
|
||||
}
|
||||
const orgUrlPrefix = getOrgFullOrigin(orgSlug);
|
||||
|
||||
await prisma.tempOrgRedirect.upsert({
|
||||
where: {
|
||||
from_type_fromOrgId: {
|
||||
type: RedirectType.Team,
|
||||
from: oldTeamSlug,
|
||||
fromOrgId: 0,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
type: RedirectType.Team,
|
||||
from: oldTeamSlug,
|
||||
fromOrgId: 0,
|
||||
toUrl: `${orgUrlPrefix}/${teamSlug}`,
|
||||
},
|
||||
update: {
|
||||
toUrl: `${orgUrlPrefix}/${teamSlug}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function updateMembership({
|
||||
targetOrgId,
|
||||
userToMoveToOrg,
|
||||
targetOrgRole,
|
||||
targetOrgMembershipAccepted,
|
||||
}: {
|
||||
targetOrgId: number;
|
||||
userToMoveToOrg: User;
|
||||
targetOrgRole: MembershipRole;
|
||||
targetOrgMembershipAccepted: boolean;
|
||||
}) {
|
||||
log.debug("updateMembership", { targetOrgId, userToMoveToOrg, targetOrgRole, targetOrgMembershipAccepted });
|
||||
await prisma.membership.upsert({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId: targetOrgId,
|
||||
userId: userToMoveToOrg.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId: targetOrgId,
|
||||
userId: userToMoveToOrg.id,
|
||||
role: targetOrgRole,
|
||||
accepted: targetOrgMembershipAccepted,
|
||||
},
|
||||
update: {
|
||||
role: targetOrgRole,
|
||||
accepted: targetOrgMembershipAccepted,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function dbMoveUserToOrg({
|
||||
userToMoveToOrg,
|
||||
targetOrgId,
|
||||
targetOrgUsername,
|
||||
nonOrgUserName,
|
||||
}: {
|
||||
userToMoveToOrg: User;
|
||||
targetOrgId: number;
|
||||
targetOrgUsername: string;
|
||||
nonOrgUserName: string | null;
|
||||
}) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userToMoveToOrg.id,
|
||||
},
|
||||
data: {
|
||||
organizationId: targetOrgId,
|
||||
username: targetOrgUsername,
|
||||
metadata: {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
...(userToMoveToOrg.metadata || {}),
|
||||
migratedToOrgFrom: {
|
||||
username: nonOrgUserName,
|
||||
lastMigrationTime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function moveTeamsWithoutMembersToOrg({
|
||||
targetOrgId,
|
||||
userToMoveToOrg,
|
||||
}: {
|
||||
targetOrgId: number;
|
||||
userToMoveToOrg: User;
|
||||
}) {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: userToMoveToOrg.id,
|
||||
},
|
||||
});
|
||||
|
||||
const membershipTeamIds = memberships.map((m) => m.teamId);
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: membershipTeamIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const teamsToBeMovedToOrg = teams
|
||||
.map((team) => {
|
||||
return {
|
||||
...team,
|
||||
metadata: teamMetadataSchema.parse(team.metadata),
|
||||
};
|
||||
})
|
||||
// Remove Orgs from the list
|
||||
.filter((team) => !team.metadata?.isOrganization);
|
||||
|
||||
const teamIdsToBeMovedToOrg = teamsToBeMovedToOrg.map((t) => t.id);
|
||||
|
||||
if (memberships.length) {
|
||||
// Add the user's teams to the org
|
||||
await prisma.team.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamIdsToBeMovedToOrg,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
parentId: targetOrgId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return teamsToBeMovedToOrg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure you pass it an organization ID only and not a team ID.
|
||||
*/
|
||||
async function setOrgSlug({ targetOrgId, targetSlug }: { targetOrgId: number; targetSlug: string }) {
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: targetOrgId,
|
||||
},
|
||||
data: {
|
||||
slug: targetSlug,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function removeTeamRedirect(teamSlug: string | null) {
|
||||
if (!teamSlug) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "No slug for team. Not removing the redirect",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.tempOrgRedirect.deleteMany({
|
||||
where: {
|
||||
type: RedirectType.Team,
|
||||
from: teamSlug,
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function removeUserAlongWithItsTeamsRedirects({
|
||||
nonOrgUserName,
|
||||
teamsToBeRemovedFromOrg,
|
||||
}: {
|
||||
nonOrgUserName: string | null;
|
||||
teamsToBeRemovedFromOrg: { slug: string | null }[];
|
||||
}) {
|
||||
if (!nonOrgUserName) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.tempOrgRedirect.deleteMany({
|
||||
// This where clause is unique, so we will get only one result but using deleteMany because it doesn't throw an error if there are no rows to delete
|
||||
where: {
|
||||
type: RedirectType.User,
|
||||
from: nonOrgUserName,
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [, team] of Object.entries(teamsToBeRemovedFromOrg)) {
|
||||
if (!team.slug) {
|
||||
log.debug("No slug for team. Not removing the redirect", safeStringify({ team }));
|
||||
continue;
|
||||
}
|
||||
await prisma.tempOrgRedirect.deleteMany({
|
||||
where: {
|
||||
type: RedirectType.Team,
|
||||
from: team.slug,
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function dbRemoveTeamFromOrg({ teamId }: { teamId: number }) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `Team with id: ${teamId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
||||
try {
|
||||
return await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
data: {
|
||||
parentId: null,
|
||||
slug: teamMetadata?.migratedToOrgFrom?.teamSlug || team.slug,
|
||||
metadata: {
|
||||
...teamMetadata,
|
||||
migratedToOrgFrom: {
|
||||
reverted: true,
|
||||
lastRevertTime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === "P2002") {
|
||||
throw new HttpError({
|
||||
message: `Looks like the team's name is already taken by some other team outside the org or an org itself. Please change this team's name or the other team/org's name. If you rename the team that you are trying to remove from the org, you will have to manually remove the redirect from the database for that team as the slug would have changed.`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg }: { userToRemoveFromOrg: User }) {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: userToRemoveFromOrg.id,
|
||||
},
|
||||
});
|
||||
|
||||
const membershipTeamIds = memberships.map((m) => m.teamId);
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: membershipTeamIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const teamsToBeRemovedFromOrg = teams
|
||||
.map((team) => {
|
||||
return {
|
||||
...team,
|
||||
metadata: teamMetadataSchema.parse(team.metadata),
|
||||
};
|
||||
})
|
||||
// Remove Orgs from the list
|
||||
.filter((team) => !team.metadata?.isOrganization);
|
||||
|
||||
const teamIdsToBeRemovedFromOrg = teamsToBeRemovedFromOrg.map((t) => t.id);
|
||||
|
||||
if (memberships.length) {
|
||||
// Remove the user's teams from the org
|
||||
await prisma.team.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamIdsToBeRemovedFromOrg,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
parentId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return teamsToBeRemovedFromOrg;
|
||||
}
|
||||
|
||||
async function dbRemoveUserFromOrg({
|
||||
userToRemoveFromOrg,
|
||||
nonOrgUserName,
|
||||
}: {
|
||||
userToRemoveFromOrg: User;
|
||||
nonOrgUserName: string;
|
||||
}) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userToRemoveFromOrg.id,
|
||||
},
|
||||
data: {
|
||||
organizationId: null,
|
||||
username: nonOrgUserName,
|
||||
metadata: {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
...(userToRemoveFromOrg.metadata || {}),
|
||||
migratedToOrgFrom: {
|
||||
username: null,
|
||||
reverted: true,
|
||||
revertTime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function removeMembership({
|
||||
targetOrgId,
|
||||
userToRemoveFromOrg,
|
||||
}: {
|
||||
targetOrgId: number;
|
||||
userToRemoveFromOrg: User;
|
||||
}) {
|
||||
await prisma.membership.deleteMany({
|
||||
where: {
|
||||
teamId: targetOrgId,
|
||||
userId: userToRemoveFromOrg.id,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -2,7 +2,7 @@ import type { GetServerSideProps } from "next";
|
|||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
export type WithNonceProps<T extends Record<string, any>> = T & {
|
||||
export type WithNonceProps<T extends Record<string, unknown>> = T & {
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
|
@ -11,7 +11,7 @@ export type WithNonceProps<T extends Record<string, any>> = T & {
|
|||
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
|
||||
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
|
||||
*/
|
||||
export default function withNonce<T extends Record<string, any>>(
|
||||
export default function withNonce<T extends Record<string, unknown>>(
|
||||
getServerSideProps: GetServerSideProps<T>
|
||||
): GetServerSideProps<WithNonceProps<T>> {
|
||||
return async (context) => {
|
||||
|
|
|
@ -142,6 +142,8 @@ export const config = {
|
|||
"/future/bookings/:status/",
|
||||
"/video/:path*",
|
||||
"/future/video/:path*",
|
||||
"/teams",
|
||||
"/future/teams/",
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
@ -572,6 +572,8 @@ if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
|||
nextConfig["sentry"] = {
|
||||
autoInstrumentServerFunctions: true,
|
||||
hideSourceMaps: true,
|
||||
// disable source map generation for the server code
|
||||
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
|
||||
};
|
||||
|
||||
plugins.push(withSentryConfig);
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
"@calcom/tsconfig": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@daily-co/daily-js": "^0.37.0",
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@formkit/auto-animate": "^0.8.1",
|
||||
"@glidejs/glide": "^3.5.2",
|
||||
"@hookform/error-message": "^2.0.0",
|
||||
"@hookform/resolvers": "^2.9.7",
|
||||
|
|
|
@ -85,7 +85,7 @@ export default function Custom404() {
|
|||
|
||||
const isSuccessPage = pathname?.startsWith("/booking");
|
||||
const isSubpage = pathname?.includes("/", 2) || isSuccessPage;
|
||||
const isSignup = pathname?.startsWith("/signup");
|
||||
|
||||
/**
|
||||
* If we're on 404 and the route is insights it means it is disabled
|
||||
* TODO: Abstract this for all disabled features
|
||||
|
@ -112,7 +112,7 @@ export default function Custom404() {
|
|||
</div>
|
||||
<div className="mt-12">
|
||||
<div className="mt-8">
|
||||
<Link href="/" className="text-base font-medium text-black hover:text-gray-500">
|
||||
<Link href={WEBSITE_URL} className="text-base font-medium text-black hover:text-gray-500">
|
||||
{t("or_go_back_home")}
|
||||
<span aria-hidden="true"> →</span>
|
||||
</Link>
|
||||
|
@ -129,7 +129,7 @@ export default function Custom404() {
|
|||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
title={isSignup ? t("signup_requires") : t("404_page_not_found")}
|
||||
title={t("404_page_not_found")}
|
||||
description={t("404_page_not_found")}
|
||||
nextSeoProps={{
|
||||
nofollow: true,
|
||||
|
@ -138,120 +138,13 @@ export default function Custom404() {
|
|||
/>
|
||||
<div className="bg-default min-h-screen px-4" data-testid="404-page">
|
||||
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
|
||||
{isSignup && process.env.NEXT_PUBLIC_WEBAPP_URL !== "https://app.cal.com" ? (
|
||||
<div>
|
||||
<div>
|
||||
<p className="text-emphasis text-sm font-semibold uppercase tracking-wide">
|
||||
{t("missing_license")}
|
||||
</p>
|
||||
<h1 className="font-cal text-emphasis mt-2 text-3xl font-extrabold">
|
||||
{t("signup_requires")}
|
||||
</h1>
|
||||
<p className="mt-4">{t("signup_requires_description", { companyName: "Cal.com" })}</p>
|
||||
</div>
|
||||
<div className="mt-12">
|
||||
<h2 className="text-subtle text-sm font-semibold uppercase tracking-wide">
|
||||
{t("next_steps")}
|
||||
</h2>
|
||||
<ul role="list" className="mt-4">
|
||||
<li className="border-2 border-green-500 px-4 py-2">
|
||||
<a
|
||||
href="https://console.cal.com"
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
|
||||
<Check className="h-6 w-6 text-green-500" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="focus:outline-none">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{t("acquire_commercial_license")}
|
||||
</span>
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t("the_infrastructure_plan")}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul role="list" className="border-subtle divide-subtle divide-y">
|
||||
<li className="px-4 py-2">
|
||||
<Link
|
||||
href="https://cal.com/self-hosting/installation"
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<FileText className="text-default h-6 w-6" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{t("prisma_studio_tip")}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t("prisma_studio_tip_description")}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
<li className="px-4 py-2">
|
||||
<a
|
||||
href={JOIN_DISCORD}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Discord className="text-default h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Discord
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t("join_our_community")}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href={`${WEBSITE_URL}/enterprise`}
|
||||
className="hover:text-subtle text-emphasis text-base font-medium">
|
||||
{t("contact_sales")}
|
||||
<span aria-hidden="true"> →</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<p className="text-emphasis text-sm font-semibold uppercase tracking-wide">
|
||||
{t("error_404")}
|
||||
</p>
|
||||
<p className="text-emphasis text-sm font-semibold uppercase tracking-wide">{t("error_404")}</p>
|
||||
<h1 className="font-cal text-emphasis mt-2 text-4xl font-extrabold sm:text-5xl">
|
||||
{isSuccessPage ? "Booking not found" : t("page_doesnt_exist")}
|
||||
</h1>
|
||||
{isSubpage && currentPageType !== pageType.TEAM ? (
|
||||
<span className="mt-2 inline-block text-lg ">
|
||||
{t("check_spelling_mistakes_or_go_back")}
|
||||
</span>
|
||||
<span className="mt-2 inline-block text-lg ">{t("check_spelling_mistakes_or_go_back")}</span>
|
||||
) : IS_CALCOM ? (
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||
|
@ -289,9 +182,7 @@ export default function Custom404() {
|
|||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{t("register")}{" "}
|
||||
<strong className="text-green-500">{`${
|
||||
currentPageType === pageType.TEAM
|
||||
? `${new URL(WEBSITE_URL).host}/team/`
|
||||
: ""
|
||||
currentPageType === pageType.TEAM ? `${new URL(WEBSITE_URL).host}/team/` : ""
|
||||
}${username}${
|
||||
currentPageType === pageType.ORG ? `.${subdomainSuffix()}` : ""
|
||||
}`}</strong>
|
||||
|
@ -365,14 +256,12 @@ export default function Custom404() {
|
|||
</li>
|
||||
</ul>
|
||||
<div className="mt-8">
|
||||
<Link href="/" className="hover:text-subtle text-emphasis text-base font-medium">
|
||||
<Link href={WEBSITE_URL} className="hover:text-subtle text-emphasis text-base font-medium">
|
||||
{t("or_go_back_home")}
|
||||
<span aria-hidden="true"> →</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -11,7 +11,6 @@ import {
|
|||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
|
@ -28,6 +27,7 @@ import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
|
|||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { UserAvatar } from "@calcom/ui";
|
||||
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
@ -101,7 +101,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
|||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
<div className="mb-8 text-center">
|
||||
<OrganizationMemberAvatar
|
||||
<UserAvatar
|
||||
size="xl"
|
||||
user={{
|
||||
organizationId: profile.organization?.id,
|
||||
|
|
|
@ -28,6 +28,7 @@ MyApp.getInitialProps = async (ctx: AppContextType) => {
|
|||
|
||||
if (req) {
|
||||
const { getLocale } = await import("@calcom/features/auth/lib/getLocale");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
newLocale = await getLocale(req as IncomingMessage & { cookies: Record<string, any> });
|
||||
} else if (typeof window !== "undefined" && window.calNewLocale) {
|
||||
newLocale = window.calNewLocale;
|
||||
|
|
|
@ -32,7 +32,8 @@ class MyDocument extends Document<Props> {
|
|||
|
||||
const newLocale =
|
||||
ctx.req && getLocaleModule
|
||||
? await getLocaleModule.getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await getLocaleModule.getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
||||
: "en";
|
||||
|
||||
const asPath = ctx.asPath || "";
|
||||
|
@ -87,7 +88,7 @@ class MyDocument extends Document<Props> {
|
|||
</Head>
|
||||
|
||||
<body
|
||||
className="dark:bg-darkgray-50 desktop-transparent bg-subtle antialiased"
|
||||
className="dark:bg-darkgray-50 todesktop:!bg-transparent bg-subtle antialiased"
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
|
|
|
@ -3,9 +3,9 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||
import isAuthorized from "@calcom/features/auth/lib/oAuthAuthorization";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const requriedScopes = ["READ_PROFILE"];
|
||||
const requiredScopes = ["READ_PROFILE"];
|
||||
|
||||
const account = await isAuthorized(req, requriedScopes);
|
||||
const account = await isAuthorized(req, requiredScopes);
|
||||
|
||||
if (!account) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveTeamToOrg";
|
||||
import type { NextApiRequest, NextApiResponse } from "next/types";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { moveTeamToOrg } from "../../../lib/orgMigration";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["moveTeamToOrg"] });
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const rawBody = req.body;
|
||||
|
||||
log.debug(
|
||||
"Moving team to org:",
|
||||
safeStringify({
|
||||
body: rawBody,
|
||||
})
|
||||
);
|
||||
|
||||
const translate = await getTranslation("en", "common");
|
||||
const moveTeamToOrgSchema = getFormSchema(translate);
|
||||
|
||||
const parsedBody = moveTeamToOrgSchema.safeParse(rawBody);
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (!session) {
|
||||
return res.status(403).json({ message: "No session found" });
|
||||
}
|
||||
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
|
||||
if (!parsedBody.success) {
|
||||
log.error("moveTeamToOrg failed:", safeStringify(parsedBody.error));
|
||||
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
|
||||
}
|
||||
|
||||
const { teamId, targetOrgId, moveMembers, teamSlugInOrganization } = parsedBody.data;
|
||||
const isAllowed = isAdmin;
|
||||
if (!isAllowed) {
|
||||
return res.status(403).json({ message: "Not Authorized" });
|
||||
}
|
||||
|
||||
try {
|
||||
await moveTeamToOrg({
|
||||
targetOrg: {
|
||||
id: targetOrgId,
|
||||
teamSlug: teamSlugInOrganization,
|
||||
},
|
||||
teamId,
|
||||
moveMembers,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.statusCode > 300) {
|
||||
log.error("moveTeamToOrg failed:", safeStringify(error.message));
|
||||
}
|
||||
return res.status(error.statusCode).json({ message: error.message });
|
||||
}
|
||||
log.error("moveTeamToOrg failed:", safeStringify(error));
|
||||
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
|
||||
|
||||
return res.status(500).json({ message: errorMessage });
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Added team ${teamId} to Org: ${targetOrgId} ${
|
||||
moveMembers ? " along with the members" : " without the members"
|
||||
}`,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveUserToOrg";
|
||||
import type { NextApiRequest, NextApiResponse } from "next/types";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { moveUserToOrg } from "../../../lib/orgMigration";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["moveUserToOrg"] });
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const rawBody = req.body;
|
||||
const translate = await getTranslation("en", "common");
|
||||
const migrateBodySchema = getFormSchema(translate);
|
||||
log.debug(
|
||||
"Starting migration:",
|
||||
safeStringify({
|
||||
body: rawBody,
|
||||
})
|
||||
);
|
||||
const parsedBody = migrateBodySchema.safeParse(rawBody);
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
if (!session) {
|
||||
res.status(403).json({ message: "No session found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
|
||||
if (parsedBody.success) {
|
||||
const { userId, userName, shouldMoveTeams, targetOrgId, targetOrgUsername, targetOrgRole } =
|
||||
parsedBody.data;
|
||||
const isAllowed = isAdmin;
|
||||
if (isAllowed) {
|
||||
try {
|
||||
await moveUserToOrg({
|
||||
targetOrg: {
|
||||
id: targetOrgId,
|
||||
username: targetOrgUsername,
|
||||
membership: {
|
||||
role: targetOrgRole,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
id: userId,
|
||||
userName,
|
||||
},
|
||||
shouldMoveTeams,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.statusCode > 300) {
|
||||
log.error("Migration failed:", safeStringify(error));
|
||||
}
|
||||
return res.status(error.statusCode).json({ message: error.message });
|
||||
}
|
||||
log.error("Migration failed:", safeStringify(error));
|
||||
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
|
||||
|
||||
return res.status(400).json({ message: errorMessage });
|
||||
}
|
||||
} else {
|
||||
return res.status(403).json({ message: "Not Authorized" });
|
||||
}
|
||||
return res.status(200).json({ message: "Migrated" });
|
||||
}
|
||||
log.error("Migration failed:", safeStringify(parsedBody.error));
|
||||
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeTeamFromOrg";
|
||||
import type { NextApiRequest, NextApiResponse } from "next/types";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { removeTeamFromOrg } from "../../../lib/orgMigration";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["removeTeamFromOrg"] });
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const rawBody = req.body;
|
||||
const translate = await getTranslation("en", "common");
|
||||
const removeTeamFromOrgSchema = getFormSchema(translate);
|
||||
log.debug(
|
||||
"Removing team from org:",
|
||||
safeStringify({
|
||||
body: rawBody,
|
||||
})
|
||||
);
|
||||
const parsedBody = removeTeamFromOrgSchema.safeParse(rawBody);
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
if (!session) {
|
||||
return res.status(403).json({ message: "No session found" });
|
||||
}
|
||||
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
|
||||
if (!parsedBody.success) {
|
||||
log.error("RemoveTeamFromOrg failed:", safeStringify(parsedBody.error));
|
||||
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
|
||||
}
|
||||
const { teamId, targetOrgId } = parsedBody.data;
|
||||
const isAllowed = isAdmin;
|
||||
if (!isAllowed) {
|
||||
return res.status(403).json({ message: "Not Authorized" });
|
||||
}
|
||||
|
||||
try {
|
||||
await removeTeamFromOrg({
|
||||
targetOrgId,
|
||||
teamId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.statusCode > 300) {
|
||||
log.error("RemoveTeamFromOrg failed:", safeStringify(error));
|
||||
}
|
||||
return res.status(error.statusCode).json({ message: error.message });
|
||||
}
|
||||
log.error("RemoveTeamFromOrg failed:", safeStringify(error));
|
||||
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
|
||||
return res.status(500).json({ message: errorMessage });
|
||||
}
|
||||
|
||||
return res.status(200).json({ message: `Removed team ${teamId} from ${targetOrgId}` });
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeUserFromOrg";
|
||||
import type { NextApiRequest, NextApiResponse } from "next/types";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { removeUserFromOrg } from "../../../lib/orgMigration";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["removeUserFromOrg"] });
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body;
|
||||
|
||||
log.debug(
|
||||
"Starting reverse migration:",
|
||||
safeStringify({
|
||||
body,
|
||||
})
|
||||
);
|
||||
|
||||
const translate = await getTranslation("en", "common");
|
||||
const migrateRevertBodySchema = getFormSchema(translate);
|
||||
const parsedBody = migrateRevertBodySchema.safeParse(body);
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
if (!session) {
|
||||
return res.status(403).json({ message: "No session found" });
|
||||
}
|
||||
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
if (!isAdmin) {
|
||||
return res.status(403).json({ message: "Only admin can take this action" });
|
||||
}
|
||||
|
||||
if (parsedBody.success) {
|
||||
const { userId, targetOrgId } = parsedBody.data;
|
||||
try {
|
||||
await removeUserFromOrg({ targetOrgId, userId });
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.statusCode > 300) {
|
||||
log.error("Reverse migration failed:", safeStringify(error));
|
||||
}
|
||||
return res.status(error.statusCode).json({ message: error.message });
|
||||
}
|
||||
log.error("Reverse migration failed:", safeStringify(error));
|
||||
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
|
||||
|
||||
return res.status(500).json({ message: errorMessage });
|
||||
}
|
||||
return res.status(200).json({ message: "Reverted" });
|
||||
}
|
||||
log.error("Reverse Migration failed:", safeStringify(parsedBody.error));
|
||||
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
|
||||
}
|
|
@ -21,16 +21,16 @@ const schema = z
|
|||
id: z.string(),
|
||||
payload: z.object({
|
||||
recording_id: z.string(),
|
||||
end_ts: z.number(),
|
||||
end_ts: z.number().optional(),
|
||||
room_name: z.string(),
|
||||
start_ts: z.number(),
|
||||
start_ts: z.number().optional(),
|
||||
status: z.string(),
|
||||
|
||||
max_participants: z.number(),
|
||||
duration: z.number(),
|
||||
s3_key: z.string(),
|
||||
max_participants: z.number().optional(),
|
||||
duration: z.number().optional(),
|
||||
s3_key: z.string().optional(),
|
||||
}),
|
||||
event_ts: z.number(),
|
||||
event_ts: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
|
@ -64,7 +66,7 @@ export default function Apps({
|
|||
categories,
|
||||
appStore,
|
||||
userAdminTeams,
|
||||
}: inferSSRProps<typeof getServerSideProps>) {
|
||||
}: Omit<inferSSRProps<typeof getServerSideProps>, "trpcState">) {
|
||||
const { t } = useLocale();
|
||||
const [searchText, setSearchText] = useState<string | undefined>(undefined);
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
|
|
|
@ -15,7 +15,7 @@ function VerifyEmailPage() {
|
|||
const { data } = useEmailVerifyCheck();
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -24,7 +24,9 @@ function VerifyEmailPage() {
|
|||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.isVerified]);
|
||||
|
||||
if (!isLocaleReady) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="h-[100vh] w-full ">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
|
|
|
@ -486,48 +486,24 @@ export default function Success(props: SuccessProps) {
|
|||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3" data-testid="where">
|
||||
{!rescheduleLocation || locationToDisplay === rescheduleLocationToDisplay ? (
|
||||
locationToDisplay.startsWith("http") ? (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
className="text-default flex items-center gap-2"
|
||||
rel="noreferrer">
|
||||
{providerName || "Link"}
|
||||
<ExternalLink className="text-default inline h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
locationToDisplay
|
||||
)
|
||||
<DisplayLocation
|
||||
locationToDisplay={locationToDisplay}
|
||||
providerName={providerName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!!formerTime &&
|
||||
(locationToDisplay.startsWith("http") ? (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
className="text-default flex items-center gap-2 line-through"
|
||||
rel="noreferrer">
|
||||
{providerName || "Link"}
|
||||
<ExternalLink className="text-default inline h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
<p className="line-through">{locationToDisplay}</p>
|
||||
))}
|
||||
{rescheduleLocationToDisplay.startsWith("http") ? (
|
||||
<a
|
||||
href={rescheduleLocationToDisplay}
|
||||
target="_blank"
|
||||
title={rescheduleLocationToDisplay}
|
||||
className="text-default flex items-center gap-2"
|
||||
rel="noreferrer">
|
||||
{rescheduleProviderName || "Link"}
|
||||
<ExternalLink className="text-default inline h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
rescheduleLocationToDisplay
|
||||
{!!formerTime && (
|
||||
<DisplayLocation
|
||||
locationToDisplay={locationToDisplay}
|
||||
providerName={providerName}
|
||||
className="line-through"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DisplayLocation
|
||||
locationToDisplay={rescheduleLocationToDisplay}
|
||||
providerName={rescheduleProviderName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -830,6 +806,29 @@ export default function Success(props: SuccessProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const DisplayLocation = ({
|
||||
locationToDisplay,
|
||||
providerName,
|
||||
className,
|
||||
}: {
|
||||
locationToDisplay: string;
|
||||
providerName?: string;
|
||||
className?: string;
|
||||
}) =>
|
||||
locationToDisplay.startsWith("http") ? (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
className={classNames("text-default flex items-center gap-2", className)}
|
||||
rel="noreferrer">
|
||||
{providerName || "Link"}
|
||||
<ExternalLink className="text-default inline h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
<p className={className}>{locationToDisplay}</p>
|
||||
);
|
||||
|
||||
Success.isBookingPage = true;
|
||||
Success.PageWrapper = PageWrapper;
|
||||
|
||||
|
|
|
@ -522,6 +522,32 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||
const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState<ChildrenEventType[]>([]);
|
||||
const slug = formMethods.watch("slug") ?? eventType.slug;
|
||||
|
||||
// Optional prerender all tabs after 300 ms on mount
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
const Components = [
|
||||
EventSetupTab,
|
||||
EventAvailabilityTab,
|
||||
EventTeamTab,
|
||||
EventLimitsTab,
|
||||
EventAdvancedTab,
|
||||
EventInstantTab,
|
||||
EventRecurringTab,
|
||||
EventAppsTab,
|
||||
EventWorkflowsTab,
|
||||
EventWebhooksTab,
|
||||
];
|
||||
|
||||
Components.forEach((C) => {
|
||||
// @ts-expect-error Property 'render' does not exist on type 'ComponentClass
|
||||
C.render.preload();
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<EventTypeSingleLayout
|
||||
|
|
|
@ -19,6 +19,7 @@ import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
|||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
|
@ -66,6 +67,7 @@ import {
|
|||
Trash,
|
||||
Upload,
|
||||
Users,
|
||||
VenetianMask,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
@ -388,6 +390,8 @@ export const EventTypeList = ({
|
|||
{types.map((type, index) => {
|
||||
const embedLink = `${group.profile.slug}/${type.slug}`;
|
||||
const calLink = `${bookerUrl}/${embedLink}`;
|
||||
const isPrivateURLEnabled = type.hashedLink?.link;
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${type.hashedLink?.link}/${type.slug}`;
|
||||
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
|
||||
const isChildrenManagedEventType =
|
||||
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
|
||||
|
@ -465,6 +469,20 @@ export const EventTypeList = ({
|
|||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{isPrivateURLEnabled && (
|
||||
<Tooltip content={t("copy_link")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={VenetianMask}
|
||||
onClick={() => {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dropdown modal={false}>
|
||||
|
@ -907,6 +925,7 @@ const EventTypesPage = () => {
|
|||
const searchParams = useCompatSearchParams();
|
||||
const { open } = useIntercom();
|
||||
const { data: user } = useMeQuery();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [showProfileBanner, setShowProfileBanner] = useState(false);
|
||||
const orgBranding = useOrgBranding();
|
||||
const routerQuery = useRouterQuery();
|
||||
|
@ -919,12 +938,6 @@ const EventTypesPage = () => {
|
|||
staleTime: 1 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
function closeBanner() {
|
||||
setShowProfileBanner(false);
|
||||
document.cookie = `calcom-profile-banner=1;max-age=${60 * 60 * 24 * 90}`; // 3 months
|
||||
showToast(t("we_wont_show_again"), "success");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams?.get("openIntercom") === "true") {
|
||||
open();
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import Head from "next/head";
|
||||
|
@ -51,13 +53,18 @@ const stepRouteSchema = z.object({
|
|||
const OnboardingPage = () => {
|
||||
const pathname = usePathname();
|
||||
const params = useParamsWithFallback();
|
||||
|
||||
const router = useRouter();
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const result = stepRouteSchema.safeParse(params);
|
||||
|
||||
const result = stepRouteSchema.safeParse({
|
||||
...params,
|
||||
step: Array.isArray(params.step) ? params.step : [params.step],
|
||||
});
|
||||
|
||||
const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
|
||||
const from = result.success ? result.data.from : "";
|
||||
|
||||
const headers = [
|
||||
{
|
||||
title: `${t("welcome_to_cal_header", { appName: APP_NAME })}`,
|
||||
|
@ -218,7 +225,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
return { redirect: { permanent: false, destination: "/event-types" } };
|
||||
}
|
||||
const locale = await getLocale(context.req);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import { getLayout as getSettingsLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { HorizontalTabs } from "@calcom/ui";
|
||||
|
||||
export default function OrgMigrationLayout({ children }: { children: React.ReactElement }) {
|
||||
return getSettingsLayout(
|
||||
<div>
|
||||
<HorizontalTabs
|
||||
tabs={[
|
||||
{
|
||||
name: "Move Team to Org",
|
||||
href: "/settings/admin/orgMigrations/moveTeamToOrg",
|
||||
},
|
||||
{
|
||||
name: "Move User to Org",
|
||||
href: "/settings/admin/orgMigrations/moveUserToOrg",
|
||||
},
|
||||
{
|
||||
name: "Revert: Move Team to Org",
|
||||
href: "/settings/admin/orgMigrations/removeTeamFromOrg",
|
||||
},
|
||||
{
|
||||
name: "Revert: Move User to Org",
|
||||
href: "/settings/admin/orgMigrations/removeUserFromOrg",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export const getLayout = (page: React.ReactElement) => {
|
||||
return <OrgMigrationLayout>{page}</OrgMigrationLayout>;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user