resole merge conflicts
This commit is contained in:
commit
6c1f8a3895
18
.env.example
18
.env.example
|
@ -293,16 +293,12 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
|||
# redirected from the legacy to the future pages
|
||||
AB_TEST_BUCKET_PROBABILITY=50
|
||||
# whether we redirect to the future/event-types from event-types or not
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=1
|
||||
APP_ROUTER_SETTINGS_ADMIN_ENABLED=1
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=1
|
||||
APP_ROUTER_APPS_SLUG_ENABLED=1
|
||||
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=1
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
||||
# whether we redirect to the future/apps/categories from /apps/categories or not
|
||||
APP_ROUTER_APPS_CATEGORIES_ENABLED=1
|
||||
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
|
||||
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
|
||||
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=1
|
||||
|
||||
|
||||
# api v2
|
||||
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
||||
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: E2E App-Store Apps
|
||||
name: E2E App-Store Apps Tests
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: E2E Embed React tests and booking flow(for non-embed as well)
|
||||
name: E2E Embed React tests and booking flow (for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: E2E Embed Core tests and booking flow(for non-embed as well)
|
||||
name: E2E Embed Core tests and booking flow (for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: E2E test
|
||||
name: E2E tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
|
|
@ -15,35 +15,6 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
app-store: ${{ steps.filter.outputs.app-store }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
embed-react: ${{ steps.filter.outputs.embed-react }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
app-store:
|
||||
- 'apps/web/**'
|
||||
- 'packages/app-store/**'
|
||||
- 'playwright.config.ts'
|
||||
embed:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
embed-react:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
|
||||
type-check:
|
||||
name: Type check
|
||||
uses: ./.github/workflows/check-types.yml
|
||||
|
@ -51,7 +22,7 @@ jobs:
|
|||
|
||||
test:
|
||||
name: Unit tests
|
||||
uses: ./.github/workflows/test.yml
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
|
@ -64,42 +35,13 @@ jobs:
|
|||
uses: ./.github/workflows/production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-without-database:
|
||||
name: Production build (without database)
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: E2E tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: E2E App Store tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-app-store.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed:
|
||||
name: E2E embeds tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: E2E React embeds tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
needs: build
|
||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [lint, type-check, test, build, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
needs: [lint, type-check, test, build]
|
||||
if: always()
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
name: Pre-release checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
app-store: ${{ steps.filter.outputs.app-store }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
embed-react: ${{ steps.filter.outputs.embed-react }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
app-store:
|
||||
- 'apps/web/**'
|
||||
- 'packages/app-store/**'
|
||||
- 'playwright.config.ts'
|
||||
embed:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
embed-react:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
|
||||
lint:
|
||||
name: Linters
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production build
|
||||
uses: ./.github/workflows/production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: E2E tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: E2E App Store tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-app-store.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed:
|
||||
name: E2E embeds tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: E2E React embeds tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
build-without-database:
|
||||
name: Production build (without database)
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [e2e, e2e-app-store, e2e-embed, e2e-embed-react, build-without-database]
|
||||
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')
|
||||
run: exit 1
|
|
@ -92,7 +92,7 @@ export const schemaUserBaseBodyParams = User.pick({
|
|||
// Here we can both require or not (adding optional or nullish) and also rewrite validations for any value
|
||||
// for example making weekStart only accept weekdays as input
|
||||
const schemaUserEditParams = z.object({
|
||||
email: z.string().email(),
|
||||
email: z.string().email().toLowerCase(),
|
||||
username: usernameSchema,
|
||||
weekStart: z.nativeEnum(weekdays).optional(),
|
||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
|
@ -114,7 +114,7 @@ const schemaUserEditParams = z.object({
|
|||
// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end.
|
||||
|
||||
const schemaUserCreateParams = z.object({
|
||||
email: z.string().email(),
|
||||
email: z.string().email().toLowerCase(),
|
||||
username: usernameSchema,
|
||||
weekStart: z.nativeEnum(weekdays).optional(),
|
||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
|
|
|
@ -3,8 +3,10 @@ import type { NextApiRequest } from "next";
|
|||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
|
||||
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||
import { canUserAccessTeamWithRole } from "~/pages/api/teams/[teamId]/_auth-middleware";
|
||||
|
||||
import checkParentEventOwnership from "./_utils/checkParentEventOwnership";
|
||||
import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission";
|
||||
|
@ -316,7 +318,13 @@ async function checkPermissions(req: NextApiRequest) {
|
|||
statusCode: 401,
|
||||
message: "ADMIN required for `userId`",
|
||||
});
|
||||
if (!isAdmin && body.teamId)
|
||||
if (
|
||||
body.teamId &&
|
||||
!isAdmin &&
|
||||
!(await canUserAccessTeamWithRole(req.prisma, req.userId, isAdmin, body.teamId, {
|
||||
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
}))
|
||||
)
|
||||
throw new HttpError({
|
||||
statusCode: 401,
|
||||
message: "ADMIN required for `teamId`",
|
||||
|
|
|
@ -27,6 +27,16 @@ export async function checkPermissions(
|
|||
version: req.query.version,
|
||||
apiKey: req.query.apiKey,
|
||||
});
|
||||
return canUserAccessTeamWithRole(prisma, userId, isAdmin, teamId, role);
|
||||
}
|
||||
|
||||
export async function canUserAccessTeamWithRole(
|
||||
prisma: NextApiRequest["prisma"],
|
||||
userId: number,
|
||||
isAdmin: boolean,
|
||||
teamId: number,
|
||||
role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER
|
||||
) {
|
||||
const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } };
|
||||
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
|
||||
if (!isAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
|
||||
|
|
|
@ -197,88 +197,91 @@
|
|||
@layer {
|
||||
:root {
|
||||
/* background */
|
||||
|
||||
--cal-bg-emphasis: #e5e7eb;
|
||||
--cal-bg: white;
|
||||
--cal-bg-subtle: #f3f4f6;
|
||||
--cal-bg-muted: #f9fafb;
|
||||
--cal-bg-inverted: #111827;
|
||||
|
||||
|
||||
--cal-bg-emphasis: hsla(220,13%,91%,1);
|
||||
--cal-bg: hsla(0,0%,100%,1);
|
||||
--cal-bg-subtle: hsla(220, 14%, 96%,1);
|
||||
--cal-bg-muted: hsla(210,20%,98%,1);
|
||||
--cal-bg-inverted: hsla(0,0%,6%,1);
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #dee9fc;
|
||||
--cal-bg-success: #e2fbe8;
|
||||
--cal-bg-attention: #fceed8;
|
||||
--cal-bg-error: #f9e3e2;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
--cal-bg-info: hsla(218,83%,98%,1);
|
||||
--cal-bg-success: hsla(134,76%,94%,1);
|
||||
--cal-bg-attention: hsla(37, 86%, 92%, 1);
|
||||
--cal-bg-error: hsla(3,66,93,1);
|
||||
--cal-bg-dark-error: hsla(2, 55%, 30%, 1);
|
||||
|
||||
/* Borders */
|
||||
--cal-border-emphasis: #9ca3af;
|
||||
--cal-border: #d1d5db;
|
||||
--cal-border-subtle: #e5e7eb;
|
||||
--cal-border-muted: #f3f4f6;
|
||||
--cal-border-error: #aa2e26;
|
||||
|
||||
--cal-border-emphasis: hsla(218, 11%, 65%, 1);
|
||||
--cal-border: hsla(216, 12%, 84%, 1);
|
||||
--cal-border-subtle: hsla(220, 13%, 91%, 1);
|
||||
--cal-border-booker: #e5e7eb;
|
||||
--cal-border-muted: hsla(220, 14%, 96%, 1);
|
||||
--cal-border-error: hsla(4, 63%, 41%, 1);
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #111827;
|
||||
--cal-text: #374151;
|
||||
--cal-text-subtle: #6b7280;
|
||||
--cal-text-muted: #9ca3af;
|
||||
--cal-text-inverted: white;
|
||||
|
||||
--cal-text-emphasis: hsla(217, 19%, 27%, 1);
|
||||
--cal-text: hsla(217, 19%, 27%, 1);
|
||||
--cal-text-subtle: hsla(220, 9%, 46%, 1);
|
||||
--cal-text-muted: hsla(218, 11%, 65%, 1);
|
||||
--cal-text-inverted: hsla(0, 0%, 100%, 1);
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321b;
|
||||
--cal-text-error: #752522;
|
||||
|
||||
--cal-text-info: hsla(228, 56%, 33%, 1);
|
||||
--cal-text-success: hsla(133, 34%, 24%, 1);
|
||||
--cal-text-attention: hsla(16, 62%, 28%, 1);
|
||||
--cal-text-error: hsla(2, 55%, 30%, 1);
|
||||
|
||||
/* Brand shinanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: #111827;
|
||||
--cal-brand-emphasis: #101010;
|
||||
--cal-brand-text: white;
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: hsla(221, 39%, 11%, 1);
|
||||
--cal-brand-emphasis: hsla(0, 0%, 6%, 1);
|
||||
--cal-brand-text: hsla(0, 0%, 100%, 1);
|
||||
}
|
||||
.dark {
|
||||
/* background */
|
||||
|
||||
--cal-bg-emphasis: #2b2b2b;
|
||||
--cal-bg: #101010;
|
||||
--cal-bg-subtle: #2b2b2b;
|
||||
--cal-bg-muted: #1c1c1c;
|
||||
--cal-bg-inverted: #f3f4f6;
|
||||
|
||||
|
||||
--cal-bg-emphasis: hsla(0, 0%, 32%, 1);
|
||||
--cal-bg: hsla(0, 0%, 10%, 1);
|
||||
--cal-bg-subtle: hsla(0, 0%, 18%, 1);
|
||||
--cal-bg-muted: hsla(0, 0%, 12%, 1);
|
||||
--cal-bg-inverted: hsla(220, 14%, 96%, 1);
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #263fa9;
|
||||
--cal-bg-success: #306339;
|
||||
--cal-bg-attention: #8e3b1f;
|
||||
--cal-bg-error: #8c2822;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
--cal-bg-info: hsla(228, 56%, 33%, 1);
|
||||
--cal-bg-success: hsla(133, 34%, 24%, 1);
|
||||
--cal-bg-attention: hsla(16, 62%, 28%, 1);
|
||||
--cal-bg-error: hsla(2, 55%, 30%, 1);
|
||||
--cal-bg-dark-error: hsla(2, 55%, 30%, 1);
|
||||
|
||||
/* Borders */
|
||||
--cal-border-emphasis: #575757;
|
||||
--cal-border: #444444;
|
||||
--cal-border-subtle: #2b2b2b;
|
||||
--cal-border-muted: #1c1c1c;
|
||||
--cal-border-error: #aa2e26;
|
||||
|
||||
--cal-border-emphasis: hsla(0, 0%, 46%, 1);
|
||||
--cal-border: hsla(0, 0%, 34%, 1);
|
||||
--cal-border-subtle: hsla(0, 0%, 22%, 1);
|
||||
--cal-border-booker: hsla(0, 0%, 22%, 1);
|
||||
--cal-border-muted: hsla(0, 0%, 18%, 1);
|
||||
--cal-border-error: hsla(4, 63%, 41%, 1);
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #f3f4f6;
|
||||
--cal-text: #d6d6d6;
|
||||
--cal-text-subtle: #757575;
|
||||
--cal-text-muted: #575757;
|
||||
--cal-text-inverted: #101010;
|
||||
|
||||
--cal-text-emphasis: hsla(240, 20%, 99%, 1);
|
||||
--cal-text: hsla(0, 0%, 84%, 1);
|
||||
--cal-text-subtle: hsla(0, 0%, 65%, 1);
|
||||
--cal-text-muted: hsla(0, 0%, 34%, 1);
|
||||
--cal-text-inverted: hsla(0, 0%, 10%, 1);
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #dee9fc;
|
||||
--cal-text-success: #e2fbe8;
|
||||
--cal-text-attention: #fceed8;
|
||||
--cal-text-error: #f9e3e2;
|
||||
|
||||
--cal-text-info: hsla(218, 83%, 93%, 1);
|
||||
--cal-text-success: hsla(134, 76%, 94%, 1);
|
||||
--cal-text-attention: hsla(37, 86%, 92%, 1);
|
||||
--cal-text-error: hsla(3, 66%, 93%, 1);
|
||||
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: white;
|
||||
--cal-brand-emphasis: #e1e1e1;
|
||||
--cal-brand-text: black;
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: hsla(0, 0%, 100%, 1);
|
||||
--cal-brand-emphasis: hsla(218, 11%, 65%, 1);
|
||||
--cal-brand-text: hsla(0, 0%, 0%,1);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { createHydrateClient } from "app/_trpc/createHydrateClient";
|
||||
import { createHydrateClient } from "_app/_trpc/createHydrateClient";
|
||||
import superjson from "superjson";
|
||||
|
||||
export const HydrateClient = createHydrateClient({
|
|
@ -1,6 +1,6 @@
|
|||
import { type DehydratedState, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HydrateClient } from "app/_trpc/HydrateClient";
|
||||
import { trpc } from "app/_trpc/client";
|
||||
import { HydrateClient } from "_app/_trpc/HydrateClient";
|
||||
import { trpc } from "_app/_trpc/client";
|
||||
import { useState } from "react";
|
||||
import superjson from "superjson";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import AppPage from "@pages/apps/[slug]/index";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
import fs from "fs";
|
||||
import matter from "gray-matter";
|
||||
import { notFound } from "next/navigation";
|
|
@ -1,5 +1,5 @@
|
|||
import SetupPage from "@pages/apps/[slug]/setup";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
|
@ -1,6 +1,6 @@
|
|||
import CategoryPage from "@pages/apps/categories/[category]";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import z from "zod";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import LegacyPage from "@pages/apps/categories/index";
|
||||
import { ssrInit } from "app/_trpc/ssrInit";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
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";
|
|
@ -1,5 +1,5 @@
|
|||
import LegacyPage from "@pages/apps/installed/[category]";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import Page from "@pages/settings/admin/apps/[category]";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
|
@ -1,5 +1,5 @@
|
|||
import Page from "@pages/settings/admin/apps/index";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
|
@ -1,5 +1,5 @@
|
|||
import Page from "@pages/settings/admin/flags";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
|
@ -1,5 +1,5 @@
|
|||
import Page from "@pages/settings/admin/impersonation";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
|
@ -1,5 +1,5 @@
|
|||
import Page from "@pages/settings/admin/oAuth/index";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
|
@ -1,5 +1,5 @@
|
|||
import Page from "@pages/settings/admin/index";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
|
@ -1,5 +1,5 @@
|
|||
import EventTypes from "@pages/event-types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
|
@ -1,5 +1,5 @@
|
|||
import Page from "@pages/settings/admin/oAuth/oAuthView";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
|
@ -1,4 +1,4 @@
|
|||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
import Page from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { getServerCaller } from "app/_trpc/serverClient";
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { getServerCaller } from "_app/_trpc/serverClient";
|
||||
import { type Params } from "_app/_types";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
import Page from "@calcom/features/ee/users/pages/users-add-view";
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata } from "_app/_utils";
|
||||
|
||||
import Page from "@calcom/features/ee/users/pages/users-listing-view";
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { DefaultSeo } from "next-seo";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
|
@ -17,13 +18,7 @@ export interface CalPageWrapper {
|
|||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
}
|
||||
|
||||
const interFont = localFont({
|
||||
src: "../fonts/InterVariable.woff2",
|
||||
variable: "--font-inter",
|
||||
preload: true,
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
src: "../fonts/CalSans-SemiBold.woff2",
|
||||
variable: "--font-cal",
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
|
||||
import {
|
||||
getEventLocationType,
|
||||
getSuccessPageLocationMessage,
|
||||
guessEventLocationType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
|
@ -14,6 +18,7 @@ import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { ActionType } from "@calcom/ui";
|
||||
|
@ -93,6 +98,16 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
|
||||
const paymentAppData = getPaymentAppData(booking.eventType);
|
||||
|
||||
const location = booking.location as ReturnType<typeof getEventLocationValue>;
|
||||
const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl;
|
||||
|
||||
const locationToDisplay = getSuccessPageLocationMessage(
|
||||
locationVideoCallUrl ? locationVideoCallUrl : location,
|
||||
t,
|
||||
booking.status
|
||||
);
|
||||
const provider = guessEventLocationType(location);
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
|
@ -359,6 +374,33 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
{!isPending && (
|
||||
<div>
|
||||
{(provider?.label || locationToDisplay?.startsWith("https://")) &&
|
||||
locationToDisplay.startsWith("http") && (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
rel="noreferrer"
|
||||
className="text-sm leading-6 text-blue-600 hover:underline">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider?.label })
|
||||
: t("join_meeting")}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
|
|
|
@ -322,6 +322,7 @@ function EventTypeSingleLayout({
|
|||
StartIcon={Code}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
namespace={eventType.slug}
|
||||
tooltip={t("embed")}
|
||||
tooltipSide="bottom"
|
||||
tooltipOffset={4}
|
||||
|
|
|
@ -152,9 +152,7 @@ const UserProfile = () => {
|
|||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
<p className="dark:text-inverted text-default mt-2 font-sans text-sm font-normal">
|
||||
{t("few_sentences_about_yourself")}
|
||||
</p>
|
||||
<p className="text-default mt-2 font-sans text-sm font-normal">{t("few_sentences_about_yourself")}</p>
|
||||
</fieldset>
|
||||
<Button EndIcon={ArrowRight} type="submit" className="mt-8 w-full items-center justify-center">
|
||||
{t("finish")}
|
||||
|
|
|
@ -74,7 +74,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||
const debouncedApiCall = useMemo(
|
||||
() =>
|
||||
debounce(async (username: string) => {
|
||||
const { data } = await fetchUsername(username);
|
||||
// TODO: Support orgSlug
|
||||
const { data } = await fetchUsername(username, null);
|
||||
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
|
||||
setIsInputUsernamePremium(data.premium);
|
||||
setUsernameIsAvailable(data.available);
|
||||
|
|
|
@ -44,7 +44,8 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||
const debouncedApiCall = useMemo(
|
||||
() =>
|
||||
debounce(async (username) => {
|
||||
const { data } = await fetchUsername(username);
|
||||
// TODO: Support orgSlug
|
||||
const { data } = await fetchUsername(username, null);
|
||||
setMarkAsError(!data.available);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
|
|
Binary file not shown.
|
@ -1,5 +1,5 @@
|
|||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { TrpcProvider } from "app/_trpc/trpc-provider";
|
||||
import { TrpcProvider } from "_app/_trpc/trpc-provider";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider, useSession } from "next-auth/react";
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.5.5",
|
||||
"version": "3.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
|
@ -8,8 +9,14 @@ type Response = {
|
|||
premium: boolean;
|
||||
};
|
||||
|
||||
const bodySchema = z.object({
|
||||
username: z.string(),
|
||||
orgSlug: z.string().optional(),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
||||
const { currentOrgDomain } = orgDomainConfig(req);
|
||||
const result = await checkUsername(req.body.username, currentOrgDomain);
|
||||
const { username, orgSlug } = bodySchema.parse(req.body);
|
||||
const result = await checkUsername(username, currentOrgDomain || orgSlug);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
|
|
@ -173,7 +173,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
|||
: isSAMLLoginEnabled && !isLoading && data?.connectionExists;
|
||||
|
||||
return (
|
||||
<div className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
|
||||
<div className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:#9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
|
||||
<AuthContainer
|
||||
title={t("login")}
|
||||
description={t("login")}
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import { motion } from "framer-motion";
|
||||
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
import { AlertTriangle, Check, MailOpen } from "@calcom/ui/components/icon";
|
||||
import { AlertTriangle, ExternalLink, MailOpen } from "@calcom/ui/components/icon";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
@ -54,7 +57,62 @@ const querySchema = z.object({
|
|||
t: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function Verify() {
|
||||
const PaymentFailedIcon = () => (
|
||||
<div className="rounded-full bg-orange-900 p-3">
|
||||
<AlertTriangle className="h-6 w-6 flex-shrink-0 p-0.5 font-extralight text-orange-100" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const PaymentSuccess = () => (
|
||||
<div
|
||||
className="rounded-full"
|
||||
style={{
|
||||
padding: "6px",
|
||||
border: "0.6px solid rgba(0, 0, 0, 0.02)",
|
||||
background: "rgba(123, 203, 197, 0.10)",
|
||||
}}>
|
||||
<motion.div
|
||||
className="rounded-full"
|
||||
style={{
|
||||
padding: "6px",
|
||||
border: "0.6px solid rgba(0, 0, 0, 0.04)",
|
||||
background: "rgba(123, 203, 197, 0.16)",
|
||||
}}
|
||||
animate={{ scale: [1, 1.1, 1] }} // Define the pulsing animation for the second ring
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
repeatType: "reverse",
|
||||
delay: 0.2, // Delay the start of animation for the second ring
|
||||
}}>
|
||||
<motion.div
|
||||
className="rounded-full p-3"
|
||||
style={{
|
||||
border: "1px solid rgba(255, 255, 255, 0.40)",
|
||||
background: "linear-gradient(180deg, #66C9CF 0%, #9CCCB2 100%)",
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2.69185 10.6919L2.9297 10.9297L2.69185 10.6919C1.96938 11.4143 1.96938 12.5857 2.69185 13.3081L7.69185 18.3081C8.41432 19.0306 9.58568 19.0306 10.3081 18.3081L21.3081 7.30815C22.0306 6.58568 22.0306 5.41432 21.3081 4.69185C20.5857 3.96938 19.4143 3.96938 18.6919 4.69185L9 14.3837L5.30815 10.6919C4.58568 9.96938 3.41432 9.96938 2.69185 10.6919Z"
|
||||
fill="white"
|
||||
stroke="#48BAAE"
|
||||
strokeWidth="0.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MailOpenIcon = () => (
|
||||
<div className="bg-default rounded-full p-3">
|
||||
<MailOpen className="text-emphasis h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function Verify(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
@ -112,7 +170,7 @@ export default function Verify() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="text-inverted bg-black bg-opacity-90 backdrop-blur-md backdrop-grayscale backdrop-filter">
|
||||
<div className="text-default bg-muted bg-opacity-90 backdrop-blur-md backdrop-grayscale backdrop-filter">
|
||||
<Head>
|
||||
<title>
|
||||
{/* @note: Ternary can look ugly ant his might be extracted later but I think at 3 it's not yet worth
|
||||
|
@ -125,17 +183,9 @@ export default function Verify() {
|
|||
</title>
|
||||
</Head>
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6">
|
||||
<div className="m-10 flex max-w-2xl flex-col items-start border border-white p-12 text-left">
|
||||
<div className="rounded-full border border-white p-3">
|
||||
{hasPaymentFailed ? (
|
||||
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||
) : sessionId ? (
|
||||
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight dark:text-white" />
|
||||
) : (
|
||||
<MailOpen className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-cal text-inverted my-6 text-3xl font-normal dark:text-white">
|
||||
<div className="border-subtle bg-default m-10 flex max-w-2xl flex-col items-center rounded-xl border px-8 py-14 text-left">
|
||||
{hasPaymentFailed ? <PaymentFailedIcon /> : sessionId ? <PaymentSuccess /> : <MailOpenIcon />}
|
||||
<h3 className="font-cal text-emphasis my-6 text-2xl font-normal leading-none">
|
||||
{hasPaymentFailed
|
||||
? "Your payment failed"
|
||||
: sessionId
|
||||
|
@ -145,41 +195,60 @@ export default function Verify() {
|
|||
{hasPaymentFailed && (
|
||||
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
|
||||
)}
|
||||
<p className="text-inverted dark:text-white">
|
||||
<p className="text-muted dark:text-subtle text-base font-normal">
|
||||
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
|
||||
{hasPaymentFailed &&
|
||||
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
|
||||
</p>
|
||||
<p className="text-muted mt-6">
|
||||
Don't see an email? Click the button below to send another email.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex space-x-5 text-center">
|
||||
<div className="mt-7">
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={secondsLeft > 0}
|
||||
onClick={async (e) => {
|
||||
if (!customer) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setSecondsLeft(30);
|
||||
// Update query params with t:timestamp, shallow: true doesn't re-render the page
|
||||
const _searchParams = new URLSearchParams(searchParams?.toString());
|
||||
_searchParams.set("t", `${Date.now()}`);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
return await sendVerificationLogin(customer.email, customer.username);
|
||||
}}>
|
||||
{secondsLeft > 0 ? `Resend in ${secondsLeft} seconds` : "Send another mail"}
|
||||
</Button>
|
||||
<Button color="primary" href={`${WEBAPP_URL || "https://app.cal.com"}/auth/login`}>
|
||||
Login using another method
|
||||
href={
|
||||
props.EMAIL_FROM
|
||||
? encodeURIComponent(`https://mail.google.com/mail/u/0/#search/from:${props.EMAIL_FROM}`)
|
||||
: "https://mail.google.com/mail/u/0/"
|
||||
}
|
||||
target="_blank"
|
||||
EndIcon={ExternalLink}>
|
||||
Open in Gmail
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-subtle text-base font-normal ">Don’t seen an email?</p>
|
||||
<button
|
||||
className={classNames(
|
||||
"font-light",
|
||||
secondsLeft > 0 ? "text-muted" : "underline underline-offset-2 hover:font-normal"
|
||||
)}
|
||||
disabled={secondsLeft > 0}
|
||||
onClick={async (e) => {
|
||||
if (!customer) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setSecondsLeft(30);
|
||||
// Update query params with t:timestamp, shallow: true doesn't re-render the page
|
||||
const _searchParams = new URLSearchParams(searchParams?.toString());
|
||||
_searchParams.set("t", `${Date.now()}`);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
return await sendVerificationLogin(customer.email, customer.username);
|
||||
}}>
|
||||
{secondsLeft > 0 ? `Resend in ${secondsLeft} seconds` : "Resend"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const EMAIL_FROM = process.env.EMAIL_FROM;
|
||||
|
||||
return {
|
||||
props: {
|
||||
EMAIL_FROM,
|
||||
},
|
||||
};
|
||||
};
|
||||
Verify.PageWrapper = PageWrapper;
|
||||
|
|
|
@ -507,6 +507,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
{!isManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<EventTypeEmbedButton
|
||||
namespace={type.slug}
|
||||
as={DropdownItem}
|
||||
type="button"
|
||||
StartIcon={Code}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { z } from "zod";
|
|||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
|
@ -105,7 +106,12 @@ const OnboardingPage = () => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:9CA3AF] [--cal-brand:#111827] [--cal-brand-text:#FFFFFF] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand:white] dark:[--cal-brand-text:#000000]"
|
||||
className={classNames(
|
||||
"dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]",
|
||||
"[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1]",
|
||||
"[--cal-brand-subtle:#9CA3AF]",
|
||||
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]"
|
||||
)}
|
||||
data-testid="onboarding"
|
||||
key={pathname}>
|
||||
<Head>
|
||||
|
|
|
@ -73,6 +73,7 @@ function UsernameField({
|
|||
setPremium,
|
||||
premium,
|
||||
setUsernameTaken,
|
||||
orgSlug,
|
||||
usernameTaken,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TextField> & {
|
||||
|
@ -80,6 +81,7 @@ function UsernameField({
|
|||
setPremium: (value: boolean) => void;
|
||||
premium: boolean;
|
||||
usernameTaken: boolean;
|
||||
orgSlug?: string;
|
||||
setUsernameTaken: (value: boolean) => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
@ -95,7 +97,7 @@ function UsernameField({
|
|||
setUsernameTaken(false);
|
||||
return;
|
||||
}
|
||||
fetchUsername(debouncedUsername).then(({ data }) => {
|
||||
fetchUsername(debouncedUsername, orgSlug ?? null).then(({ data }) => {
|
||||
setPremium(data.premium);
|
||||
setUsernameTaken(!data.available);
|
||||
});
|
||||
|
@ -229,7 +231,13 @@ export default function Signup({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center [--cal-brand-emphasis:#101010] [--cal-brand:#111827] [--cal-brand-text:#FFFFFF] [--cal-brand-subtle:#9CA3AF] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand:white] dark:[--cal-brand-text:#000000]">
|
||||
<div
|
||||
className={classNames(
|
||||
"light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]",
|
||||
"[--cal-brand-subtle:#9CA3AF]",
|
||||
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]",
|
||||
"[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1] "
|
||||
)}>
|
||||
<div className="bg-muted 2xl:border-subtle grid w-full max-w-[1440px] grid-cols-1 grid-rows-1 overflow-hidden lg:grid-cols-2 2xl:rounded-[20px] 2xl:border 2xl:py-6">
|
||||
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
||||
{/* Left side */}
|
||||
|
@ -270,6 +278,7 @@ export default function Signup({
|
|||
{/* Username */}
|
||||
{!isOrgInviteByLink ? (
|
||||
<UsernameField
|
||||
orgSlug={orgSlug}
|
||||
label={t("username")}
|
||||
username={watch("username") || ""}
|
||||
premium={premiumUsername}
|
||||
|
@ -604,15 +613,17 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
|||
metadata: teamMetadataSchema.parse(verificationToken?.team?.metadata),
|
||||
};
|
||||
|
||||
const isATeamInOrganization = tokenTeam?.parentId !== null;
|
||||
const isOrganization = tokenTeam.metadata?.isOrganization;
|
||||
// Detect if the team is an org by either the metadata flag or if it has a parent team
|
||||
const isOrganization = tokenTeam.metadata?.isOrganization || tokenTeam?.parentId !== null;
|
||||
const isOrganizationOrATeamInOrganization = isOrganization || isATeamInOrganization;
|
||||
// If we are dealing with an org, the slug may come from the team itself or its parent
|
||||
const orgSlug = isOrganization
|
||||
const orgSlug = isOrganizationOrATeamInOrganization
|
||||
? tokenTeam.metadata?.requestedSlug || tokenTeam.parent?.slug || tokenTeam.slug
|
||||
: null;
|
||||
|
||||
// Org context shouldn't check if a username is premium
|
||||
if (!IS_SELF_HOSTED && !isOrganization) {
|
||||
if (!IS_SELF_HOSTED && !isOrganizationOrATeamInOrganization) {
|
||||
// Im not sure we actually hit this because of next redirects signup to website repo - but just in case this is pretty cool :)
|
||||
const { available, suggestion } = await checkPremiumUsername(username);
|
||||
|
||||
|
@ -620,7 +631,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
|||
}
|
||||
|
||||
const isValidEmail = checkValidEmail(verificationToken.identifier);
|
||||
const isOrgInviteByLink = isOrganization && !isValidEmail;
|
||||
const isOrgInviteByLink = isOrganizationOrATeamInOrganization && !isValidEmail;
|
||||
const parentMetaDataForSubteam = tokenTeam?.parent?.metadata
|
||||
? teamMetadataSchema.parse(tokenTeam.parent.metadata)
|
||||
: null;
|
||||
|
@ -632,7 +643,14 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
|||
prepopulateFormValues: !isOrgInviteByLink
|
||||
? {
|
||||
email: verificationToken.identifier,
|
||||
username: slugify(username),
|
||||
username: isOrganizationOrATeamInOrganization
|
||||
? getOrgUsernameFromEmail(
|
||||
verificationToken.identifier,
|
||||
(isOrganization
|
||||
? tokenTeam.metadata?.orgAutoAcceptEmail
|
||||
: parentMetaDataForSubteam?.orgAutoAcceptEmail) || ""
|
||||
)
|
||||
: slugify(username),
|
||||
}
|
||||
: null,
|
||||
orgSlug,
|
||||
|
|
|
@ -4,7 +4,7 @@ import { test } from "./lib/fixtures";
|
|||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("apps/ A/B tests", () => {
|
||||
test.describe.skip("apps/ A/B tests", () => {
|
||||
test("should point to the /future/apps/installed/[category]", async ({ page, users, context }) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
|
|
|
@ -365,7 +365,7 @@ test.describe("Booking round robin event", () => {
|
|||
teammates: teamMatesObj,
|
||||
}
|
||||
);
|
||||
const team = await testUser.getFirstTeam();
|
||||
const team = await testUser.getFirstTeamMembership();
|
||||
await page.goto(`/team/${team.team.slug}`);
|
||||
});
|
||||
|
||||
|
@ -373,7 +373,7 @@ test.describe("Booking round robin event", () => {
|
|||
const [testUser] = users.get();
|
||||
testUser.apiLogin();
|
||||
|
||||
const team = await testUser.getFirstTeam();
|
||||
const team = await testUser.getFirstTeamMembership();
|
||||
|
||||
// Click first event type (round robin)
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
|
|
@ -11,7 +11,7 @@ import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth
|
|||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Event Types A/B tests", () => {
|
||||
test("should point to the /future/event-types page", async ({ page, users, context }) => {
|
||||
test.skip("should point to the /future/event-types page", async ({ page, users, context }) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "x-calcom-future-routes-override",
|
||||
|
|
|
@ -62,6 +62,7 @@ export const createBookingsFixture = (page: Page) => {
|
|||
rescheduled,
|
||||
paid,
|
||||
status,
|
||||
iCalUID: `${uid}@cal.com`,
|
||||
},
|
||||
});
|
||||
const bookingFixture = createBookingFixture(booking, store.page);
|
||||
|
|
|
@ -458,7 +458,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
getFirstTeam: async () => {
|
||||
getFirstTeamMembership: async () => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { team: true },
|
||||
|
|
|
@ -84,7 +84,7 @@ test.describe("Stripe integration", () => {
|
|||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
const teamEvent = await owner.getFirstTeamEvent(team.id);
|
||||
|
|
|
@ -2,7 +2,7 @@ import { test } from "./fixtures";
|
|||
|
||||
export type RouteVariant = "future" | "legacy";
|
||||
|
||||
const routeVariants = ["future", "legacy"];
|
||||
const routeVariants = [/*"future",*/ "legacy"];
|
||||
|
||||
/**
|
||||
* Small wrapper around test.describe().
|
||||
|
|
|
@ -336,3 +336,7 @@ export async function fillStripeTestCheckout(page: Page) {
|
|||
await page.fill("[name=billingName]", "Stripe Stripeson");
|
||||
await page.click(".SubmitButton--complete-Shimmer");
|
||||
}
|
||||
|
||||
// When App directory is there, this is the 404 page text. It is commented till it's disabled
|
||||
// export const NotFoundPageText = "This page could not be found";
|
||||
export const NotFoundPageText = "ERROR 404";
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import type { Browser, Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
@ -13,169 +14,471 @@ test.afterEach(async ({ users, emails }) => {
|
|||
emails?.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("Invitation (non verified)", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("To the organization by email (external user)", async () => {
|
||||
const invitedUserEmail = `rick-${Date.now()}@domain.com`;
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator('button:text("Send invite")').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
// Check newly invited member exists and is pending
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(1);
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
const signupPage = await context.newPage();
|
||||
signupPage.goto(inviteLink);
|
||||
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
|
||||
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
|
||||
await signupPage.waitForLoadState("networkidle");
|
||||
|
||||
// Check required fields
|
||||
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await signupPage.locator("button[type=submit]").click();
|
||||
await signupPage.waitForURL("/getting-started?from=signup");
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await context.close();
|
||||
await signupPage.close();
|
||||
|
||||
// Check newly invited member is not pending anymore
|
||||
await page.bringToFront();
|
||||
test.describe.serial("Organization", () => {
|
||||
test.describe("Email not matching orgAutoAcceptEmail", () => {
|
||||
test("Org Invitation", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
page.locator(`[data-testid="login-form"]`);
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(0);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("By email", async () => {
|
||||
const invitedUserEmail = `rick-${Date.now()}@domain.com`;
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: false,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedEmail: invitedUserEmail,
|
||||
expectedUsername: usernameDerivedFromEmail,
|
||||
});
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const email = `rick-${Date.now()}@domain.com`;
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
|
||||
await signupFromInviteLink({ browser, inviteLink, email });
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("To the organization by invite link", async () => {
|
||||
// Get the invite link
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
|
||||
test("Team invitation", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, hasSubteam: true });
|
||||
await orgOwner.apiLogin();
|
||||
const { team } = await orgOwner.getFirstTeamMembership();
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
|
||||
const inviteLink = await getInviteLink(page);
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
const inviteLinkPage = await context.newPage();
|
||||
await inviteLinkPage.goto(inviteLink);
|
||||
await inviteLinkPage.waitForLoadState("networkidle");
|
||||
await test.step("By email", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
const invitedUserEmail = `rick-${Date.now()}@domain.com`;
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: false,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
// Check required fields
|
||||
const button = inviteLinkPage.locator("button[type=submit][disabled]");
|
||||
await expect(button).toBeVisible(); // email + 3 password hints
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: false,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
// Happy path
|
||||
const email = `rick-${Date.now()}@domain.com`;
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
|
||||
await inviteLinkPage.locator("input[name=email]").fill(email);
|
||||
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
await inviteLinkPage.waitForURL("/getting-started");
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await page.waitForLoadState("networkidle");
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${team.name}'s admin invited you to join the team ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedEmail: invitedUserEmail,
|
||||
expectedUsername: usernameDerivedFromEmail,
|
||||
});
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const email = `rick-${Date.now()}@domain.com`;
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
|
||||
await signupFromInviteLink({ browser, inviteLink, email });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
teamId: team.id,
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: email,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: email,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true });
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("To the organization by email (internal user)", async () => {
|
||||
const invitedUserEmail = `rick-${Date.now()}@example.com`;
|
||||
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator('button:text("Send invite")').click();
|
||||
test.describe("Email matching orgAutoAcceptEmail and a Verified Organization", () => {
|
||||
test("Org Invitation", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true });
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
await test.step("By email", async () => {
|
||||
const invitedUserEmail = `rick-${Date.now()}@example.com`;
|
||||
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
// Check newly invited member exists and is not pending
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(0);
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
const signupPage = await context.newPage();
|
||||
signupPage.goto(inviteLink);
|
||||
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
|
||||
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
|
||||
await signupPage.waitForLoadState("networkidle");
|
||||
assertInviteLink(inviteLink);
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedEmail: invitedUserEmail,
|
||||
expectedUsername: usernameDerivedFromEmail,
|
||||
});
|
||||
|
||||
// Check required fields
|
||||
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await signupPage.locator("button[type=submit]").click();
|
||||
await signupPage.waitForURL("/getting-started?from=signup");
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await context.close();
|
||||
await signupPage.close();
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const email = `rick-${Date.now()}@example.com`;
|
||||
const usernameDerivedFromEmail = email.split("@")[0];
|
||||
await signupFromInviteLink({ browser, inviteLink, email });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("To the organization by invite link", async () => {
|
||||
// Get the invite link
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
|
||||
test("Team Invitation", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
isOrgVerified: true,
|
||||
});
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
const { team } = await orgOwner.getFirstTeamMembership();
|
||||
|
||||
const inviteLink = await getInviteLink(page);
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
const inviteLinkPage = await context.newPage();
|
||||
await inviteLinkPage.goto(inviteLink);
|
||||
await inviteLinkPage.waitForLoadState("networkidle");
|
||||
await orgOwner.apiLogin();
|
||||
|
||||
// Check required fields
|
||||
const button = inviteLinkPage.locator("button[type=submit][disabled]");
|
||||
await expect(button).toBeVisible(); // email + 3 password hints
|
||||
await test.step("By email", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
const invitedUserEmail = `rick-${Date.now()}@example.com`;
|
||||
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
// Happy path
|
||||
const email = `rick-${Date.now()}@example.com`;
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}`;
|
||||
await inviteLinkPage.locator("input[name=email]").fill(email);
|
||||
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
await inviteLinkPage.waitForURL("/getting-started");
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${team.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedEmail: invitedUserEmail,
|
||||
expectedUsername: usernameDerivedFromEmail,
|
||||
});
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const email = `rick-${Date.now()}@example.com`;
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}`;
|
||||
|
||||
await signupFromInviteLink({ browser, inviteLink, email });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
teamId: team.id,
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: email,
|
||||
});
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: email,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function signupFromInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
email,
|
||||
}: {
|
||||
browser: Browser;
|
||||
inviteLink: string;
|
||||
email: string;
|
||||
}) {
|
||||
const context = await browser.newContext();
|
||||
const inviteLinkPage = await context.newPage();
|
||||
await inviteLinkPage.goto(inviteLink);
|
||||
await inviteLinkPage.waitForLoadState("networkidle");
|
||||
|
||||
// Check required fields
|
||||
const button = inviteLinkPage.locator("button[type=submit][disabled]");
|
||||
await expect(button).toBeVisible(); // email + 3 password hints
|
||||
|
||||
await inviteLinkPage.locator("input[name=email]").fill(email);
|
||||
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
await inviteLinkPage.waitForURL("/getting-started");
|
||||
return { email };
|
||||
}
|
||||
|
||||
async function signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedUsername,
|
||||
expectedEmail,
|
||||
}: {
|
||||
browser: Browser;
|
||||
inviteLink: string;
|
||||
expectedUsername: string;
|
||||
expectedEmail: string;
|
||||
}) {
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
const signupPage = await context.newPage();
|
||||
|
||||
signupPage.goto(inviteLink);
|
||||
await signupPage.waitForLoadState("networkidle");
|
||||
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
|
||||
expect(await signupPage.locator(`[data-testid="signup-usernamefield"]`).inputValue()).toBe(
|
||||
expectedUsername
|
||||
);
|
||||
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
|
||||
expect(await signupPage.locator(`[data-testid="signup-emailfield"]`).inputValue()).toBe(expectedEmail);
|
||||
|
||||
await signupPage.waitForLoadState("networkidle");
|
||||
// Check required fields
|
||||
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await signupPage.locator("button[type=submit]").click();
|
||||
await signupPage.waitForURL("/getting-started?from=signup");
|
||||
await context.close();
|
||||
await signupPage.close();
|
||||
}
|
||||
|
||||
async function inviteAnEmail(page: Page, invitedUserEmail: string) {
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator('button:text("Send invite")').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
async function expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
isMemberShipAccepted,
|
||||
}: {
|
||||
page: Page;
|
||||
username: string;
|
||||
role: string;
|
||||
isMemberShipAccepted: boolean;
|
||||
email: string;
|
||||
}) {
|
||||
// Check newly invited member is not pending anymore
|
||||
await page.goto("/settings/organizations/members");
|
||||
expect(await page.locator(`[data-testid="member-${username}-username"]`).textContent()).toBe(username);
|
||||
expect(await page.locator(`[data-testid="member-${username}-email"]`).textContent()).toBe(email);
|
||||
expect((await page.locator(`[data-testid="member-${username}-role"]`).textContent())?.toLowerCase()).toBe(
|
||||
role.toLowerCase()
|
||||
);
|
||||
if (isMemberShipAccepted) {
|
||||
await expect(page.locator(`[data-testid2="member-${username}-pending"]`)).toBeHidden();
|
||||
} else {
|
||||
await expect(page.locator(`[data-testid2="member-${username}-pending"]`)).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId,
|
||||
email,
|
||||
role,
|
||||
username,
|
||||
isMemberShipAccepted,
|
||||
}: {
|
||||
page: Page;
|
||||
username: string;
|
||||
role: string;
|
||||
teamId: number;
|
||||
isMemberShipAccepted: boolean;
|
||||
email: string;
|
||||
}) {
|
||||
// Check newly invited member is not pending anymore
|
||||
await page.goto(`/settings/teams/${teamId}/members`);
|
||||
expect(
|
||||
(
|
||||
await page.locator(`[data-testid="member-${username}"] [data-testid=member-role]`).textContent()
|
||||
)?.toLowerCase()
|
||||
).toBe(role.toLowerCase());
|
||||
if (isMemberShipAccepted) {
|
||||
await expect(page.locator(`[data-testid="email-${email.replace("@", "")}-pending"]`)).toBeHidden();
|
||||
} else {
|
||||
await expect(page.locator(`[data-testid="email-${email.replace("@", "")}-pending"]`)).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
function assertInviteLink(inviteLink: string | null | undefined): asserts inviteLink is string {
|
||||
if (!inviteLink) throw new Error("Invite link not found");
|
||||
}
|
||||
|
||||
async function copyInviteLink(page: Page) {
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
|
||||
const inviteLink = await getInviteLink(page);
|
||||
return inviteLink;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { test } from "./lib/fixtures";
|
|||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Settings/admin A/B tests", () => {
|
||||
test("should point to the /future/settings/admin page", async ({ page, users, context }) => {
|
||||
test.skip("should point to the /future/settings/admin page", async ({ page, users, context }) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "x-calcom-future-routes-override",
|
||||
|
|
|
@ -242,7 +242,7 @@ test.describe("Signup Flow Test", async () => {
|
|||
|
||||
const t = await localize("en");
|
||||
const teamOwner = await users.create(undefined, { hasTeam: true });
|
||||
const { team } = await teamOwner.getFirstTeam();
|
||||
const { team } = await teamOwner.getFirstTeamMembership();
|
||||
await teamOwner.apiLogin();
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
|
|
@ -17,7 +17,7 @@ test.describe("Team", () => {
|
|||
test("Invitation (non verified)", async ({ browser, page, users, emails }) => {
|
||||
const t = await localize("en");
|
||||
const teamOwner = await users.create(undefined, { hasTeam: true });
|
||||
const { team } = await teamOwner.getFirstTeam();
|
||||
const { team } = await teamOwner.getFirstTeamMembership();
|
||||
await teamOwner.apiLogin();
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
@ -98,7 +98,7 @@ test.describe("Team", () => {
|
|||
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
|
||||
const t = await localize("en");
|
||||
const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true });
|
||||
const { team } = await teamOwner.getFirstTeam();
|
||||
const { team } = await teamOwner.getFirstTeamMembership();
|
||||
await teamOwner.apiLogin();
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
|
|
@ -7,6 +7,7 @@ import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
|||
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
NotFoundPageText,
|
||||
bookTimeSlot,
|
||||
fillStripeTestCheckout,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
|
@ -21,7 +22,7 @@ test.describe("Teams - NonOrg", () => {
|
|||
|
||||
test("Team Onboarding Invite Members", async ({ page, users }) => {
|
||||
const user = await users.create(undefined, { hasTeam: true });
|
||||
const { team } = await user.getFirstTeam();
|
||||
const { team } = await user.getFirstTeamMembership();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
|
||||
await user.apiLogin();
|
||||
|
@ -79,7 +80,7 @@ test.describe("Teams - NonOrg", () => {
|
|||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -117,7 +118,7 @@ test.describe("Teams - NonOrg", () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -234,7 +235,7 @@ test.describe("Teams - NonOrg", () => {
|
|||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
|
||||
// Mark team as private
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
@ -347,12 +348,12 @@ test.describe("Teams - Org", () => {
|
|||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
||||
await expect(page.locator("text=This page could not be found")).toBeVisible();
|
||||
await expect(page.locator(`text=${NotFoundPageText}`)).toBeVisible();
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
|
@ -396,7 +397,7 @@ test.describe("Teams - Org", () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -447,7 +448,7 @@ test.describe("Teams - Org", () => {
|
|||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
const teamSlugUpperCase = team.slug?.toUpperCase();
|
||||
|
|
|
@ -18,7 +18,7 @@ test.afterAll(async ({ users }) => {
|
|||
test.describe("Unpublished", () => {
|
||||
test("Regular team profile", async ({ page, users }) => {
|
||||
const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true });
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { requestedSlug } = team.metadata as { requestedSlug: string };
|
||||
await page.goto(`/team/${requestedSlug}`);
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
|
@ -33,7 +33,7 @@ test.describe("Unpublished", () => {
|
|||
isUnpublished: true,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { requestedSlug } = team.metadata as { requestedSlug: string };
|
||||
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
await page.goto(`/team/${requestedSlug}/${teamEventSlug}`);
|
||||
|
|
|
@ -2130,6 +2130,8 @@
|
|||
"overlay_my_calendar":"Overlay my calendar",
|
||||
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
||||
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
|
||||
"join_event_location":"Join {{eventLocationType}}",
|
||||
"join_meeting":"Join Meeting",
|
||||
"troubleshooting":"Troubleshooting",
|
||||
"calendars_were_checking_for_conflicts":"Calendars we’re checking for conflicts",
|
||||
"availabilty_schedules":"Availability schedules",
|
||||
|
|
|
@ -7,90 +7,90 @@
|
|||
:root {
|
||||
/* background */
|
||||
|
||||
--cal-bg-emphasis: #e5e7eb;
|
||||
--cal-bg: white;
|
||||
--cal-bg-subtle: #f3f4f6;
|
||||
--cal-bg-muted: #f9fafb;
|
||||
--cal-bg-inverted: #111827;
|
||||
--cal-bg-emphasis: hsla(220,13%,91%,1);
|
||||
--cal-bg: hsla(0,0%,100%,1);
|
||||
--cal-bg-subtle: hsla(220, 14%, 96%,1);
|
||||
--cal-bg-muted: hsla(210,20%,98%,1);
|
||||
--cal-bg-inverted: hsla(0,0%,6%,1);
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #dee9fc;
|
||||
--cal-bg-success: #e2fbe8;
|
||||
--cal-bg-attention: #fceed8;
|
||||
--cal-bg-error: #f9e3e2;
|
||||
--cal-bg-dark-error: #752522;
|
||||
--cal-bg-info: hsla(218,83%,98%,1);
|
||||
--cal-bg-success: hsla(134,76%,94%,1);
|
||||
--cal-bg-attention: hsla(37, 86%, 92%, 1);
|
||||
--cal-bg-error: hsla(3,66,93,1);
|
||||
--cal-bg-dark-error: hsla(2, 55%, 30%, 1);
|
||||
|
||||
/* Borders */
|
||||
--cal-border-emphasis: #9ca3af;
|
||||
--cal-border: #d1d5db;
|
||||
--cal-border-subtle: #e5e7eb;
|
||||
--cal-border-emphasis: hsla(218, 11%, 65%, 1);
|
||||
--cal-border: hsla(216, 12%, 84%, 1);
|
||||
--cal-border-subtle: hsla(220, 13%, 91%, 1);
|
||||
--cal-border-booker: #e5e7eb;
|
||||
--cal-border-muted: #f3f4f6;
|
||||
--cal-border-error: #aa2e26;
|
||||
--cal-border-muted: hsla(220, 14%, 96%, 1);
|
||||
--cal-border-error: hsla(4, 63%, 41%, 1);
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #111827;
|
||||
--cal-text: #374151;
|
||||
--cal-text-subtle: #6b7280;
|
||||
--cal-text-muted: #9ca3af;
|
||||
--cal-text-inverted: white;
|
||||
--cal-text-emphasis: hsla(217, 19%, 27%, 1);
|
||||
--cal-text: hsla(217, 19%, 27%, 1);
|
||||
--cal-text-subtle: hsla(220, 9%, 46%, 1);
|
||||
--cal-text-muted: hsla(218, 11%, 65%, 1);
|
||||
--cal-text-inverted: hsla(0, 0%, 100%, 1);
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321b;
|
||||
--cal-text-error: #752522;
|
||||
--cal-text-info: hsla(228, 56%, 33%, 1);
|
||||
--cal-text-success: hsla(133, 34%, 24%, 1);
|
||||
--cal-text-attention: hsla(16, 62%, 28%, 1);
|
||||
--cal-text-error: hsla(2, 55%, 30%, 1);
|
||||
|
||||
/* Brand shinanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: #111827;
|
||||
--cal-brand-emphasis: #101010;
|
||||
--cal-brand-text: white;
|
||||
--cal-brand: hsla(221, 39%, 11%, 1);
|
||||
--cal-brand-emphasis: hsla(0, 0%, 6%, 1);
|
||||
--cal-brand-text: hsla(0, 0%, 100%, 1);
|
||||
}
|
||||
.dark {
|
||||
/* background */
|
||||
|
||||
--cal-bg-emphasis: #2b2b2b;
|
||||
--cal-bg: #101010;
|
||||
--cal-bg-subtle: #2b2b2b;
|
||||
--cal-bg-muted: #1c1c1c;
|
||||
--cal-bg-inverted: #f3f4f6;
|
||||
--cal-bg-emphasis: hsla(0, 0%, 32%, 1);
|
||||
--cal-bg: hsla(0, 0%, 10%, 1);
|
||||
--cal-bg-subtle: hsla(0, 0%, 18%, 1);
|
||||
--cal-bg-muted: hsla(0, 0%, 12%, 1);
|
||||
--cal-bg-inverted: hsla(220, 14%, 96%, 1);
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #263fa9;
|
||||
--cal-bg-success: #306339;
|
||||
--cal-bg-attention: #8e3b1f;
|
||||
--cal-bg-error: #8c2822;
|
||||
--cal-bg-dark-error: #752522;
|
||||
--cal-bg-info: hsla(228, 56%, 33%, 1);
|
||||
--cal-bg-success: hsla(133, 34%, 24%, 1);
|
||||
--cal-bg-attention: hsla(16, 62%, 28%, 1);
|
||||
--cal-bg-error: hsla(2, 55%, 30%, 1);
|
||||
--cal-bg-dark-error: hsla(2, 55%, 30%, 1);
|
||||
|
||||
/* Borders */
|
||||
--cal-border-emphasis: #575757;
|
||||
--cal-border: #444444;
|
||||
--cal-border-subtle: #2b2b2b;
|
||||
--cal-border-booker: #2b2b2b;
|
||||
--cal-border-muted: #1c1c1c;
|
||||
--cal-border-error: #aa2e26;
|
||||
--cal-border-emphasis: hsla(0, 0%, 46%, 1);
|
||||
--cal-border: hsla(0, 0%, 34%, 1);
|
||||
--cal-border-subtle: hsla(0, 0%, 22%, 1);
|
||||
--cal-border-booker: hsla(0, 0%, 22%, 1);
|
||||
--cal-border-muted: hsla(0, 0%, 18%, 1);
|
||||
--cal-border-error: hsla(4, 63%, 41%, 1);
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #f3f4f6;
|
||||
--cal-text: #d6d6d6;
|
||||
--cal-text-subtle: #a5a5a5;
|
||||
--cal-text-muted: #575757;
|
||||
--cal-text-inverted: #101010;
|
||||
--cal-text-emphasis: hsla(240, 20%, 99%, 1);
|
||||
--cal-text: hsla(0, 0%, 84%, 1);
|
||||
--cal-text-subtle: hsla(0, 0%, 65%, 1);
|
||||
--cal-text-muted: hsla(0, 0%, 34%, 1);
|
||||
--cal-text-inverted: hsla(0, 0%, 10%, 1);
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #dee9fc;
|
||||
--cal-text-success: #e2fbe8;
|
||||
--cal-text-attention: #fceed8;
|
||||
--cal-text-error: #f9e3e2;
|
||||
--cal-text-info: hsla(218, 83%, 93%, 1);
|
||||
--cal-text-success: hsla(134, 76%, 94%, 1);
|
||||
--cal-text-attention: hsla(37, 86%, 92%, 1);
|
||||
--cal-text-error: hsla(3, 66%, 93%, 1);
|
||||
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: white;
|
||||
--cal-brand-emphasis: #e1e1e1;
|
||||
--cal-brand-text: black;
|
||||
--cal-brand: hsla(0, 0%, 100%, 1);
|
||||
--cal-brand-emphasis: hsla(218, 11%, 65%, 1);
|
||||
--cal-brand-text: hsla(0, 0%, 0%,1);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
@ -68,6 +68,7 @@ type ExpectedEmail = {
|
|||
filename: string;
|
||||
iCalUID?: string;
|
||||
recurrence?: Recurrence;
|
||||
method: string;
|
||||
};
|
||||
/**
|
||||
* Checks that there is no
|
||||
|
@ -162,9 +163,14 @@ expect.extend({
|
|||
}
|
||||
|
||||
if (!expectedEmail.noIcs && !isIcsUIDExpected) {
|
||||
const icsObjectKeys = icsObject ? Object.keys(icsObject) : [];
|
||||
const icsKey = icsObjectKeys.find((key) => key !== "vcalendar");
|
||||
if (!icsKey) throw new Error("icsKey not found");
|
||||
return {
|
||||
pass: false,
|
||||
actual: JSON.stringify(icsObject),
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
actual: icsObject[icsKey].uid!,
|
||||
expected: expectedEmail.ics?.iCalUID,
|
||||
message: () => `Expected ICS UID ${isNot ? "is" : "isn't"} present in actual`,
|
||||
};
|
||||
|
@ -375,6 +381,7 @@ export function expectSuccessfulBookingCreationEmails({
|
|||
filename: "event.ics",
|
||||
iCalUID: `${iCalUID}`,
|
||||
recurrence,
|
||||
method: "REQUEST",
|
||||
},
|
||||
},
|
||||
`${organizer.email}`
|
||||
|
@ -397,8 +404,9 @@ export function expectSuccessfulBookingCreationEmails({
|
|||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
iCalUID: `${iCalUID}`,
|
||||
recurrence,
|
||||
method: "REQUEST",
|
||||
},
|
||||
links: recurrence
|
||||
? [
|
||||
|
@ -436,7 +444,8 @@ export function expectSuccessfulBookingCreationEmails({
|
|||
to: `${otherTeamMember.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
iCalUID: `${iCalUID}`,
|
||||
method: "REQUEST",
|
||||
},
|
||||
links: [
|
||||
{
|
||||
|
@ -472,7 +481,8 @@ export function expectSuccessfulBookingCreationEmails({
|
|||
to: `${guest.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
iCalUID: `${iCalUID}`,
|
||||
method: "REQUEST",
|
||||
},
|
||||
},
|
||||
`${guest.name} <${guest.email}`
|
||||
|
@ -529,6 +539,7 @@ export function expectCalendarEventCreationFailureEmails({
|
|||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID,
|
||||
method: "REQUEST",
|
||||
},
|
||||
},
|
||||
`${organizer.email}`
|
||||
|
@ -541,6 +552,7 @@ export function expectCalendarEventCreationFailureEmails({
|
|||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID,
|
||||
method: "REQUEST",
|
||||
},
|
||||
},
|
||||
`${booker.name} <${booker.email}>`
|
||||
|
@ -612,6 +624,7 @@ export function expectSuccessfulBookingRescheduledEmails({
|
|||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID,
|
||||
method: "REQUEST",
|
||||
},
|
||||
appsStatus,
|
||||
},
|
||||
|
@ -625,6 +638,7 @@ export function expectSuccessfulBookingRescheduledEmails({
|
|||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID,
|
||||
method: "REQUEST",
|
||||
},
|
||||
},
|
||||
`${booker.name} <${booker.email}>`
|
||||
|
@ -711,6 +725,7 @@ export function expectBookingRequestRescheduledEmails({
|
|||
to: `${booker.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
method: "REQUEST",
|
||||
},
|
||||
},
|
||||
`${booker.email}`
|
||||
|
@ -724,6 +739,7 @@ export function expectBookingRequestRescheduledEmails({
|
|||
to: `${loggedInUser.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
method: "REQUEST",
|
||||
},
|
||||
},
|
||||
`${loggedInUser.email}`
|
||||
|
@ -1074,3 +1090,11 @@ export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { fro
|
|||
...to,
|
||||
});
|
||||
}
|
||||
|
||||
export function expectICalUIDAsString(iCalUID: string | undefined | null) {
|
||||
if (typeof iCalUID !== "string") {
|
||||
throw new Error("iCalUID is not a string");
|
||||
}
|
||||
|
||||
return iCalUID;
|
||||
}
|
||||
|
|
|
@ -202,6 +202,7 @@ export default class GoogleCalendarService implements Calendar {
|
|||
useDefault: true,
|
||||
},
|
||||
guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true,
|
||||
iCalUID: calEventRaw.iCalUID,
|
||||
};
|
||||
|
||||
if (calEventRaw.location) {
|
||||
|
@ -250,7 +251,6 @@ export default class GoogleCalendarService implements Calendar {
|
|||
type: "google_calendar",
|
||||
password: "",
|
||||
url: "",
|
||||
iCalUID: event.data.iCalUID,
|
||||
};
|
||||
} catch (error) {
|
||||
this.log.error(
|
||||
|
|
|
@ -12,6 +12,7 @@ import { CAL_URL } from "@calcom/lib/constants";
|
|||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import {
|
||||
|
@ -434,6 +435,8 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
|||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
embedUrl: embedLink,
|
||||
// We are okay with namespace clashing here if just in case names clash
|
||||
namespace: slugify((routingForm?.name || "").substring(0, 5)),
|
||||
},
|
||||
edit: {
|
||||
href: `${appUrl}/form-edit/${routingForm?.id}`,
|
||||
|
|
|
@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
|
|||
|
||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||
import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
|
||||
import { NotFoundPageText, gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
|
||||
|
||||
import {
|
||||
addForm,
|
||||
|
@ -36,7 +36,7 @@ test.describe("Routing Forms", () => {
|
|||
await page.goto(`apps/routing-forms/route-builder/${formId}`);
|
||||
await disableForm(page);
|
||||
await gotoRoutingLink({ page, formId });
|
||||
await expect(page.locator("text=This page could not be found")).toBeVisible();
|
||||
await expect(page.locator(`text=${NotFoundPageText}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("should be able to edit the form", async ({ page }) => {
|
||||
|
|
|
@ -179,7 +179,7 @@ export default class EventManager {
|
|||
}
|
||||
const isCalendarType = isCalendarResult(result);
|
||||
if (isCalendarType) {
|
||||
evt.iCalUID = result.iCalUID || undefined;
|
||||
evt.iCalUID = result.iCalUID || event.iCalUID || undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -30,6 +30,7 @@ class CalendarEventClass implements CalendarEvent {
|
|||
hideCalendarNotes?: boolean;
|
||||
additionalNotes?: string | null | undefined;
|
||||
recurrence?: string;
|
||||
iCalUID?: string | null;
|
||||
|
||||
constructor(initProps?: CalendarEvent) {
|
||||
// If more parameters are given we update this
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
import type { DateArray, ParticipationStatus, ParticipationRole, EventStatus } from "ics";
|
||||
import { createEvent } from "ics";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { RRule } from "rrule";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { getWhen } from "@calcom/lib/CalEventParser";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
export enum BookingAction {
|
||||
Create = "create",
|
||||
Cancel = "cancel",
|
||||
Reschedule = "reschedule",
|
||||
RequestReschedule = "request_reschedule",
|
||||
LocationChange = "location_change",
|
||||
}
|
||||
|
||||
const generateIcsString = ({
|
||||
event,
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
role,
|
||||
isRequestReschedule,
|
||||
t,
|
||||
}: {
|
||||
event: CalendarEvent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: EventStatus;
|
||||
role: "attendee" | "organizer";
|
||||
isRequestReschedule?: boolean;
|
||||
t?: TFunction;
|
||||
}) => {
|
||||
// Taking care of recurrence rule
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
const partstat: ParticipationStatus = "ACCEPTED";
|
||||
const icsRole: ParticipationRole = "REQ-PARTICIPANT";
|
||||
if (event.recurringEvent?.count) {
|
||||
// ics appends "RRULE:" already, so removing it from RRule generated string
|
||||
recurrenceRule = new RRule(event.recurringEvent).toString().replace("RRULE:", "");
|
||||
}
|
||||
|
||||
const getTextBody = (title: string, subtitle: string): string => {
|
||||
let body: string;
|
||||
if (isRequestReschedule && role === "attendee" && t) {
|
||||
body = `
|
||||
${title}
|
||||
${getWhen(event, t)}
|
||||
${subtitle}`;
|
||||
}
|
||||
body = `
|
||||
${title}
|
||||
${subtitle}
|
||||
|
||||
${getRichDescription(event, t)}
|
||||
`.trim();
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const icsEvent = createEvent({
|
||||
uid: event.iCalUID || event.uid!,
|
||||
sequence: event.iCalSequence || 0,
|
||||
start: dayjs(event.startTime)
|
||||
.utc()
|
||||
.toArray()
|
||||
.slice(0, 6)
|
||||
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
|
||||
startInputType: "utc",
|
||||
productId: "calcom/ics",
|
||||
title: event.title,
|
||||
description: getTextBody(title, subtitle),
|
||||
duration: { minutes: dayjs(event.endTime).diff(dayjs(event.startTime), "minute") },
|
||||
organizer: { name: event.organizer.name, email: event.organizer.email },
|
||||
...{ recurrenceRule },
|
||||
attendees: [
|
||||
...event.attendees.map((attendee: Person) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
partstat,
|
||||
role: icsRole,
|
||||
rsvp: true,
|
||||
})),
|
||||
...(event.team?.members
|
||||
? event.team?.members.map((member: Person) => ({
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
partstat,
|
||||
role: icsRole,
|
||||
rsvp: true,
|
||||
}))
|
||||
: []),
|
||||
],
|
||||
method: "REQUEST",
|
||||
status,
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
throw icsEvent.error;
|
||||
}
|
||||
return icsEvent.value;
|
||||
};
|
||||
|
||||
export default generateIcsString;
|
|
@ -0,0 +1,36 @@
|
|||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
/**
|
||||
* This function returns the iCalUID if a uid is passed or if it is present in the event that is passed
|
||||
* @param uid - the uid of the event
|
||||
* @param event - an event that already has an iCalUID or one that has a uid
|
||||
* @param defaultToEventUid - if true, will default to the event.uid if present
|
||||
*
|
||||
* @returns the iCalUID whether already present or generated
|
||||
*/
|
||||
const getICalUID = ({
|
||||
uid,
|
||||
event,
|
||||
defaultToEventUid,
|
||||
}: {
|
||||
uid?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
event?: { iCalUID?: string | null; uid?: string | null; [key: string]: any };
|
||||
defaultToEventUid?: boolean;
|
||||
}) => {
|
||||
if (event?.iCalUID) return event.iCalUID;
|
||||
|
||||
if (defaultToEventUid && event?.uid) return `${event.uid}@${APP_NAME}`;
|
||||
|
||||
if (uid) return `${uid}@${APP_NAME}`;
|
||||
|
||||
const translator = short();
|
||||
|
||||
uid = translator.fromUUID(uuidv5(APP_NAME, uuidv5.URL));
|
||||
return `${uid}@${APP_NAME}`;
|
||||
};
|
||||
|
||||
export default getICalUID;
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, expect } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
import { buildCalendarEvent, buildPerson } from "../../../lib/test/builder";
|
||||
import generateIcsString from "../generateIcsString";
|
||||
|
||||
const assertHasIcsString = (icsString: string | undefined) => {
|
||||
if (!icsString) throw new Error("icsString is undefined");
|
||||
|
||||
expect(icsString).toBeDefined();
|
||||
|
||||
return icsString;
|
||||
};
|
||||
|
||||
const testIcsStringContains = ({
|
||||
icsString,
|
||||
event,
|
||||
status,
|
||||
}: {
|
||||
icsString: string;
|
||||
event: CalendarEvent;
|
||||
status: string;
|
||||
}) => {
|
||||
const DTSTART = event.startTime.split(".")[0].replace(/[-:]/g, "");
|
||||
const startTime = dayjs(event.startTime);
|
||||
const endTime = dayjs(event.endTime);
|
||||
const duration = endTime.diff(startTime, "minute");
|
||||
|
||||
expect(icsString).toEqual(expect.stringContaining(`UID:${event.iCalUID}`));
|
||||
// Sometimes the deeply equal stringMatching error appears. Don't want to add flakey tests
|
||||
// expect(icsString).toEqual(expect.stringContaining(`SUMMARY:${event.title}`));
|
||||
expect(icsString).toEqual(expect.stringContaining(`DTSTART:${DTSTART}`));
|
||||
expect(icsString).toEqual(
|
||||
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`)
|
||||
);
|
||||
expect(icsString).toEqual(expect.stringContaining(`DURATION:PT${duration}M`));
|
||||
expect(icsString).toEqual(expect.stringContaining(`STATUS:${status}`));
|
||||
// Getting an error expected icsString to deeply equal stringMatching
|
||||
// for (const attendee of event.attendees) {
|
||||
// expect(icsString).toEqual(
|
||||
// expect.stringMatching(
|
||||
// `RSVP=TRUE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=${attendee.name}:mailto:${attendee.email}`
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
};
|
||||
|
||||
describe("generateIcsString", () => {
|
||||
test("when bookingAction is Create", () => {
|
||||
const event = buildCalendarEvent({
|
||||
iCalSequence: 0,
|
||||
attendees: [buildPerson()],
|
||||
});
|
||||
|
||||
const title = "new_event_scheduled_recurring";
|
||||
const subtitle = "emailed_you_and_any_other_attendees";
|
||||
const status = "CONFIRMED";
|
||||
|
||||
const icsString = generateIcsString({
|
||||
event: event,
|
||||
title,
|
||||
subtitle,
|
||||
role: "organizer",
|
||||
status,
|
||||
});
|
||||
|
||||
const assertedIcsString = assertHasIcsString(icsString);
|
||||
|
||||
testIcsStringContains({ icsString: assertedIcsString, event, status });
|
||||
});
|
||||
test("when bookingAction is Cancel", () => {
|
||||
const event = buildCalendarEvent({
|
||||
iCalSequence: 0,
|
||||
attendees: [buildPerson()],
|
||||
});
|
||||
const title = "event_request_cancelled";
|
||||
const subtitle = "emailed_you_and_any_other_attendees";
|
||||
const status = "CANCELLED";
|
||||
|
||||
const icsString = generateIcsString({
|
||||
event: event,
|
||||
title,
|
||||
subtitle,
|
||||
role: "organizer",
|
||||
status,
|
||||
});
|
||||
|
||||
const assertedIcsString = assertHasIcsString(icsString);
|
||||
|
||||
testIcsStringContains({ icsString: assertedIcsString, event, status });
|
||||
});
|
||||
test("when bookingAction is Reschedule", () => {
|
||||
const event = buildCalendarEvent({
|
||||
iCalSequence: 0,
|
||||
attendees: [buildPerson()],
|
||||
});
|
||||
const title = "event_type_has_been_rescheduled";
|
||||
const subtitle = "emailed_you_and_any_other_attendees";
|
||||
const status = "CONFIRMED";
|
||||
|
||||
const icsString = generateIcsString({
|
||||
event: event,
|
||||
title,
|
||||
subtitle,
|
||||
role: "organizer",
|
||||
status,
|
||||
});
|
||||
|
||||
const assertedIcsString = assertHasIcsString(icsString);
|
||||
|
||||
testIcsStringContains({ icsString: assertedIcsString, event, status });
|
||||
});
|
||||
test("when bookingAction is RequestReschedule", () => {
|
||||
const event = buildCalendarEvent({
|
||||
iCalSequence: 0,
|
||||
attendees: [buildPerson()],
|
||||
});
|
||||
const title = "request_reschedule_title_organizer";
|
||||
const subtitle = "request_reschedule_subtitle_organizer";
|
||||
const status = "CANCELLED";
|
||||
|
||||
const icsString = generateIcsString({
|
||||
event: event,
|
||||
title,
|
||||
subtitle,
|
||||
role: "organizer",
|
||||
status,
|
||||
});
|
||||
|
||||
const assertedIcsString = assertHasIcsString(icsString);
|
||||
|
||||
testIcsStringContains({ icsString: assertedIcsString, event, status });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect } from "vitest";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { buildCalendarEvent } from "@calcom/lib/test/builder";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
import getICalUID from "../getICalUID";
|
||||
|
||||
describe("getICalUid", () => {
|
||||
test("returns iCalUID when passing a uid", () => {
|
||||
const iCalUID = getICalUID({ uid: "123" });
|
||||
expect(iCalUID).toEqual(`123@${APP_NAME}`);
|
||||
});
|
||||
test("returns iCalUID when passing an event", () => {
|
||||
const event = buildCalendarEvent({ iCalUID: `123@${APP_NAME}` });
|
||||
const iCalUID = getICalUID({ event });
|
||||
expect(iCalUID).toEqual(`123@${APP_NAME}`);
|
||||
});
|
||||
test("returns new iCalUID when passing in an event with no iCalUID but has an uid", () => {
|
||||
const event = buildCalendarEvent({ iCalUID: "" });
|
||||
const iCalUID = getICalUID({ event, defaultToEventUid: true });
|
||||
expect(iCalUID).toEqual(`${event.uid}@${APP_NAME}`);
|
||||
});
|
||||
test("returns new iCalUID when passing in an event with no iCalUID and uses uid passed", () => {
|
||||
const event = buildCalendarEvent({ iCalUID: "" });
|
||||
const iCalUID = getICalUID({ event, uid: "123" });
|
||||
expect(iCalUID).toEqual(`123@${APP_NAME}`);
|
||||
});
|
||||
});
|
|
@ -1,9 +1,21 @@
|
|||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_request_cancelled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
status: "CANCELLED",
|
||||
role: "attendee",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail {
|
||||
|
@ -6,7 +7,14 @@ export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail
|
|||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_location_changed"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "attendee",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
|
||||
|
@ -6,7 +7,14 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
|
|||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_type_has_been_rescheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "attendee",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
|
|
|
@ -1,16 +1,13 @@
|
|||
import type { DateArray, ParticipationStatus, ParticipationRole } from "ics";
|
||||
import { createEvent } from "ics";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { RRule } from "rrule";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import BaseEmail from "./_base-email";
|
||||
|
||||
export default class AttendeeScheduledEmail extends BaseEmail {
|
||||
|
@ -32,65 +29,21 @@ export default class AttendeeScheduledEmail extends BaseEmail {
|
|||
this.t = attendee.language.translate;
|
||||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
// Taking care of recurrence rule
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (this.calEvent.recurringEvent?.count) {
|
||||
// ics appends "RRULE:" already, so removing it from RRule generated string
|
||||
recurrenceRule = new RRule(this.calEvent.recurringEvent).toString().replace("RRULE:", "");
|
||||
}
|
||||
const partstat: ParticipationStatus = "ACCEPTED";
|
||||
const role: ParticipationRole = "REQ-PARTICIPANT";
|
||||
const icsEvent = createEvent({
|
||||
uid: this.calEvent.iCalUID || this.calEvent.uid!,
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
.toArray()
|
||||
.slice(0, 6)
|
||||
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
|
||||
startInputType: "utc",
|
||||
productId: "calcom/ics",
|
||||
title: this.calEvent.title,
|
||||
description: this.getTextBody(),
|
||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||
attendees: [
|
||||
...this.calEvent.attendees.map((attendee: Person) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
partstat,
|
||||
role,
|
||||
rsvp: true,
|
||||
})),
|
||||
...(this.calEvent.team?.members
|
||||
? this.calEvent.team?.members.map((member: Person) => ({
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
partstat,
|
||||
role,
|
||||
rsvp: true,
|
||||
}))
|
||||
: []),
|
||||
],
|
||||
method: "REQUEST",
|
||||
...{ recurrenceRule },
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
throw icsEvent.error;
|
||||
}
|
||||
return icsEvent.value;
|
||||
}
|
||||
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
const clonedCalEvent = cloneDeep(this.calEvent);
|
||||
|
||||
this.getiCalEventAsString();
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.calEvent.recurringEvent?.count
|
||||
? this.t("your_event_has_been_scheduled_recurring")
|
||||
: this.t("your_event_has_been_scheduled"),
|
||||
role: "attendee",
|
||||
subtitle: "emailed_you_and_any_other_attendees",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import type { DateArray } from "ics";
|
||||
import { createEvent } from "ics";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getManageLink } from "@calcom/lib/CalEventParser";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "..";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerScheduledEmail {
|
||||
|
@ -22,7 +19,16 @@ export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerSche
|
|||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("request_reschedule_booking"),
|
||||
subtitle: this.t("request_reschedule_subtitle", {
|
||||
organizer: this.calEvent.organizer.name,
|
||||
}),
|
||||
role: "attendee",
|
||||
status: "CANCELLED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
|
@ -39,35 +45,6 @@ export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerSche
|
|||
};
|
||||
}
|
||||
|
||||
// @OVERRIDE
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
const icsEvent = createEvent({
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
.toArray()
|
||||
.slice(0, 6)
|
||||
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
|
||||
startInputType: "utc",
|
||||
productId: "calcom/ics",
|
||||
title: this.t("ics_event_title", {
|
||||
eventType: this.calEvent.type,
|
||||
name: this.calEvent.attendees[0].name,
|
||||
}),
|
||||
description: this.getTextBody(),
|
||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||
attendees: this.calEvent.attendees.map((attendee: Person) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
})),
|
||||
status: "CANCELLED",
|
||||
method: "CANCEL",
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
throw icsEvent.error;
|
||||
}
|
||||
return icsEvent.value;
|
||||
}
|
||||
// @OVERRIDE
|
||||
protected getWhen(): string {
|
||||
return `
|
||||
|
|
|
@ -53,7 +53,7 @@ ${this.t(
|
|||
)}
|
||||
${this.t(subtitle)}
|
||||
${extraInfo}
|
||||
${getRichDescription(this.calEvent)}
|
||||
${getRichDescription(this.calEvent, this.t, true)}
|
||||
${callToAction}
|
||||
`.trim();
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
|
||||
|
@ -8,6 +9,17 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
|
|||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_request_cancelled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
status: "CANCELLED",
|
||||
role: "organizer",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
subject: `${this.t("event_cancelled_subject", {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmail {
|
||||
|
@ -10,7 +11,14 @@ export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmai
|
|||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_location_changed"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "organizer",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
|
|
|
@ -7,6 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants";
|
|||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "..";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerRequestedToRescheduleEmail extends OrganizerScheduledEmail {
|
||||
|
@ -21,7 +22,18 @@ export default class OrganizerRequestedToRescheduleEmail extends OrganizerSchedu
|
|||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("request_reschedule_title_organizer", {
|
||||
attendee: this.calEvent.attendees[0].name,
|
||||
}),
|
||||
subtitle: this.t("request_reschedule_subtitle_organizer", {
|
||||
attendee: this.calEvent.attendees[0].name,
|
||||
}),
|
||||
role: "organizer",
|
||||
status: "CANCELLED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
|
@ -83,13 +95,11 @@ export default class OrganizerRequestedToRescheduleEmail extends OrganizerSchedu
|
|||
}
|
||||
|
||||
// @OVERRIDE
|
||||
protected getTextBody(title = "", subtitle = "", extraInfo = "", callToAction = ""): string {
|
||||
protected getTextBody(title = "", subtitle = ""): string {
|
||||
return `
|
||||
${this.t(title)}
|
||||
${this.t(subtitle)}
|
||||
${extraInfo}
|
||||
${getRichDescription(this.calEvent)}
|
||||
${callToAction}
|
||||
${getRichDescription(this.calEvent, this.t, true)}
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
|
||||
|
@ -10,7 +11,14 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
|
|||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("event_type_has_been_rescheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "organizer",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
import type { DateArray } from "ics";
|
||||
import { createEvent } from "ics";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { RRule } from "rrule";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import BaseEmail from "./_base-email";
|
||||
|
||||
export default class OrganizerScheduledEmail extends BaseEmail {
|
||||
|
@ -29,47 +26,6 @@ export default class OrganizerScheduledEmail extends BaseEmail {
|
|||
this.teamMember = input.teamMember;
|
||||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
// Taking care of recurrence rule
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (this.calEvent.recurringEvent?.count) {
|
||||
// ics appends "RRULE:" already, so removing it from RRule generated string
|
||||
recurrenceRule = new RRule(this.calEvent.recurringEvent).toString().replace("RRULE:", "");
|
||||
}
|
||||
const icsEvent = createEvent({
|
||||
uid: this.calEvent.iCalUID || this.calEvent.uid!,
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
.toArray()
|
||||
.slice(0, 6)
|
||||
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
|
||||
startInputType: "utc",
|
||||
productId: "calcom/ics",
|
||||
title: this.calEvent.title,
|
||||
description: this.getTextBody(),
|
||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||
...{ recurrenceRule },
|
||||
attendees: [
|
||||
...this.calEvent.attendees.map((attendee: Person) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
})),
|
||||
...(this.calEvent.team?.members
|
||||
? this.calEvent.team?.members.map((member: Person) => ({
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
}))
|
||||
: []),
|
||||
],
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
throw icsEvent.error;
|
||||
}
|
||||
return icsEvent.value;
|
||||
}
|
||||
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
const clonedCalEvent = cloneDeep(this.calEvent);
|
||||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
@ -77,7 +33,16 @@ export default class OrganizerScheduledEmail extends BaseEmail {
|
|||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.calEvent.recurringEvent?.count
|
||||
? this.t("new_event_scheduled_recurring")
|
||||
: this.t("new_event_scheduled"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "organizer",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
|
@ -105,7 +70,7 @@ ${this.t(
|
|||
)}
|
||||
${this.t(subtitle)}
|
||||
${extraInfo}
|
||||
${getRichDescription(this.calEvent)}
|
||||
${getRichDescription(this.calEvent, this.t, true)}
|
||||
${callToAction}
|
||||
`.trim();
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user